gateway/uplink: bound writes against the block-pool constant - #313
gateway/uplink: bound writes against the block-pool constant#313fderepas wants to merge 1 commit into
Conversation
| * constant the block pool is dimensioned from (MAX_BLOCK_PAYLOAD_SIZE = | ||
| * MAX_PLAINTEXT_BLOCK_SIZE - BLOCK_HEADER_SIZE), NOT the raw CONFIG_POUCH_BLOCK_SIZE: | ||
| * LOG2() rounds the allocation size down to a power of two, so for a | ||
| * non-power-of-two CONFIG_POUCH_BLOCK_SIZE the raw value exceeds the pool element. |
There was a problem hiding this comment.
I think the original comment (with CONFIG_POUCH_BLOCK_SIZE replaced with MAX_BLOCK_PAYLOAD_SIZE) is more appropriate here. This is another example of a comment describing the change rather than the result, when the change is not visible to someone who is reading the code after the change is made. I think this new comment would make for an excellent commit message though.
… raw `CONFIG_POUCH_BLOCK_SIZE`
| * time so the configured size is the size actually used. | ||
| */ | ||
| POUCH_STATIC_ASSERT((CONFIG_POUCH_BLOCK_SIZE & (CONFIG_POUCH_BLOCK_SIZE - 1)) == 0, | ||
| "CONFIG_POUCH_BLOCK_SIZE must be a power of two"); |
There was a problem hiding this comment.
I actually explicitly want to avoid this, as the rounding down is deliberate on the device side. As the block size always has to be a power of two, rounding down works well here as well, as long as we account for it when calculating the capacity of the block.
I made a counter proposal for this problem in #336, which changes the uplink module to use the appropriate block API for calculating the capacity of the buffer.
Alternative take on the issue in #313. The blockbuf module doesn't use the raw config value to size the allocated blocks, so we can't use that when comparing the size. When working with the blockbuf buffers, we should use the block.h API to measure the capacity of the block. This switches the gateway uplink module to use these APIs, which allows us to keep using the rounding down mechanism in the block allocation module. Signed-off-by: Trond Snekvik <trond.snekvik@canonical.com>
Summary
pouch_gateway_uplink_write()(src/gateway/uplink.c) bounds itsbuf_writeinto a block-pool element against
GW_BLOCK_MAX_BYTES, which is defined as theraw
CONFIG_POUCH_BLOCK_SIZE. The pool element itself is dimensioned fromMAX_PLAINTEXT_BLOCK_SIZE, which usesCONFIG_POUCH_BLOCK_SIZErounded down toa power of two. For any non-power-of-two
CONFIG_POUCH_BLOCK_SIZE, the writebound exceeds the element capacity and peer-supplied bytes overflow the pool
element. This PR derives the bound from the same constant as the allocation and
adds a build-time power-of-two assertion.
Severity: Medium — peer-controlled pool overflow, config-conditional (requires a
non-power-of-two
CONFIG_POUCH_BLOCK_SIZE; not reachable at the default 512).The defect
src/gateway/uplink.c:This has the correct
MIN-and-loop shape — it just bounds against the wrongconstant. The pool element is sized by
MAX_PLAINTEXT_BLOCK_SIZE(
port/zephyr/blockbuf.c:K_MEM_SLAB_DEFINE(blockbuf, WB_UP(POUCH_BUF_OVERHEAD + MAX_PLAINTEXT_BLOCK_SIZE), …)), and that constant is rounded down (src/block.h):with
LOG2a floor (port/include/pouch/port.h):So the write bound is
CONFIG_POUCH_BLOCK_SIZE, the capacity is3 + 2^floor(log2(CONFIG_POUCH_BLOCK_SIZE)), and the comment's claim that the slot"holds at least
CONFIG_POUCH_BLOCK_SIZEbytes" is false for every non-power-of-twovalue.
Arithmetic
CONFIG_POUCH_BLOCK_SIZEMAX_PLAINTEXT_BLOCK_SIZEGW_BLOCK_MAX_BYTESAt powers of two the bound is exactly 3 bytes under capacity (the block header), so
the code is safe by coincidence of the header size, not by construction.
The overflow needs no large single write:
uplink->wblockpersists across calls andis only submitted at
buf_size_get >= GW_BLOCK_MAX_BYTES, so ordinary small bearerchunks accumulate. At
CONFIG_POUCH_BLOCK_SIZE = 1000, five 128-byte BLE-sizedrecv()chunks put 640 bytes into a 515-byte element.Trust source / reachability
payload/lenare the raw bytes a connected device pushed at the gateway; thegateway forwards node uplinks to the cloud without decrypting them, so there is
no authentication gate on this path — the trust source is the peer device. Content
is fully peer-controlled; the overrun length is set by the configuration, not the
peer. On Zephyr the overrun lands in the next
k_mem_slabelement, whose first wordis the free-list
nextpointer (a write-what-where primitive). Reachability isgated only on a non-power-of-two
CONFIG_POUCH_BLOCK_SIZE, which the Kconfig acceptswith no
rangeand no warning (see below).Root-cause note
Because of the same rounding, a user who sets
CONFIG_POUCH_BLOCK_SIZE = 1000silently gets 512-byte blocks everywhere else (
block_space_get,MAX_CIPHERTEXT_BLOCK_SIZE, the slab element). One Kconfig option is consumed rawin this one place and rounded in every other — that inconsistency, not the
MINitself, is the bug.
CONFIG_POUCH_BLOCK_SIZE(src/Kconfig) carries norangeandno power-of-two wording, and no
BUILD_ASSERTin the tree constrains it (the onepower-of-two assert,
port/zephyr/transport/coap/blockwise.c, constrains thedifferent
CONFIG_POUCH_COAP_BLOCK_SIZE).src/gateway/uplink.cis the only write bound in the tree spelled as a raw Kconfigvalue rather than a
block.hconstant; the siblings get it right(
src/downlink.cbounds againstMAX_CIPHERTEXT_BLOCK_SIZE,src/entry.candsrc/stream.cagainstblock_space_get).Reproduction (AddressSanitizer)
W6c-asan-test.ctranscribesGW_BLOCK_MAX_BYTES,pouch_gateway_uplink_write,buf_claim/buf_write,LOG2, theblock.hmacros, and the slab geometryverbatim, and feeds 128-byte bearer chunks. The discriminator is the compile-time
CONFIG_POUCH_BLOCK_SIZE:At 512 (power of two) it survives; at 1000 (non-power-of-two) ASan faults on the
fifth accumulated chunk:
Rebuilding with
-DFIX(bound =MAX_BLOCK_PAYLOAD_SIZE) atCONFIG_POUCH_BLOCK_SIZE = 1000survives cleanly — the same discriminating test theraw witness describes.
Why the existing tests miss it
Two independent reasons: (1)
tests/pouch/gateway/src/stub_blockbuf.creplaces theslab with a 4096-byte
malloc(8× the real element), the same double that maskedthe gateway-downlink overflow; and (2) the tests only build at the default
power-of-two
CONFIG_POUCH_BLOCK_SIZE, where the bug is unreachable by construction.tests/pouch/gateway/src/uplink.cdoes push 20×200 = 4000 bytes — enough to overflowa real 515-byte element several times — and passes only because of the stub.
The fix
Primary — derive the bound from the same constant as the allocation:
MAX_BLOCK_PAYLOAD_SIZE(=MAX_PLAINTEXT_BLOCK_SIZE − BLOCK_HEADER_SIZE) is therounded payload the pool element is built for, so the bound now follows the
allocation by construction and equals the previous value at every power of two.
Secondary — fail the build on the surprising rounding (independently worth
having, since a user asking for 1000-byte blocks silently getting 512 is itself a
defect):
The secondary fix alone is not sufficient: asserting a side condition makes
this miscalculation impossible but leaves the invariant "write bound == allocation
size" resting on a coincidence between two unrelated files. Only the primary fix
makes the bound follow the allocation by construction. Recommend both.
How this was found
This was found by creating a model of Pouch using a theorem prover, then proving using
Weakest Preconditions the expected properties on the source code. WP cannot discharge
the
buf_writevalid_destobligation for a non-power-of-twoCONFIG_POUCH_BLOCK_SIZEbecause the write bound (
GW_BLOCK_MAX_BYTES) exceeds the proven element capacity(
MAX_PLAINTEXT_BLOCK_SIZE); the contrast with the sibling bounds that useblock.hconstants pinpointed the raw-Kconfig bound. Confirmed independently under
AddressSanitizer (above), config-conditionally, with a faithful transcription of the
shipped
uplink/block/slab geometry.