diff --git a/CMakeLists.txt b/CMakeLists.txt index 9ba5a31..13f570e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1234,6 +1234,11 @@ if(WOLFRAM_BUILD_TESTS) PRIVATE WF_TEST_FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/test/fixtures") add_test(NAME mst_interop COMMAND test_mst_interop) + add_executable(test_mst_depth test/test_mst_depth.c) + target_link_libraries(test_mst_depth PRIVATE wolfram) + target_include_directories(test_mst_depth PRIVATE test) + add_test(NAME mst_depth COMMAND test_mst_depth) + add_executable(test_session test/test_session.c) target_link_libraries(test_session PRIVATE wolfram) target_include_directories(test_session PRIVATE test) diff --git a/include/wolfram/repo/mst.h b/include/wolfram/repo/mst.h index 8968339..0b08cc2 100644 --- a/include/wolfram/repo/mst.h +++ b/include/wolfram/repo/mst.h @@ -10,6 +10,24 @@ extern "C" { #endif +/** + * Maximum MST nesting depth accepted when walking / counting / proving / + * loading an MST from untrusted CAR data. Every recursive MST descent in + * this SDK checks depth against it. + * + * A legitimate tree cannot nest anywhere near this deep: atproto stores a + * node's height in a single uint8 (max 255), and wolfram's own key-layer + * derivation caps at 128 (wf_mst_key_layer counts at most 4 leading-zero + * steps per hash byte). A pure-left chain of distinct blocks, on the other + * hand, can be arbitrarily deep in a well-formed CAR -- one block per + * level, each a few dozen bytes -- so a cap keyed to block count (e.g. + * `depth > car->block_count`) is no bound on stack usage at all: it only + * fires on cycles. This constant is what actually limits how deep the + * recursion can go before rejecting the tree as malformed, independent of + * how many tiny blocks an attacker stuffs into the CAR. + */ +#define WF_MST_MAX_DEPTH 1024 + /** A decompressed entry (leaf) in an MST node. */ typedef struct wf_mst_entry { unsigned char *key; diff --git a/src/repo/diff.c b/src/repo/diff.c index 657a835..4df9762 100644 --- a/src/repo/diff.c +++ b/src/repo/diff.c @@ -367,7 +367,8 @@ static wf_status commit_unsigned_bytes(const wf_commit *commit, static wf_status verify_mst_links(const wf_car *car, const wf_cid *cid, size_t depth) { - if (depth > car->block_count) return WF_ERR_PARSE; + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; wf_car_block *block = wf_car_find_block((wf_car *)car, cid); if (!block) return WF_ERR_NOT_FOUND; wf_mst_node node; diff --git a/src/repo/mst.c b/src/repo/mst.c index 622d4b2..ed5377f 100644 --- a/src/repo/mst.c +++ b/src/repo/mst.c @@ -441,7 +441,8 @@ static size_t mst_find_ge(const wf_mst_node *node, const unsigned char *key, static wf_status mst_load_node_depth(wf_car *car, const wf_cid *cid, wf_mst_node *out, size_t depth) { - if (depth > car->block_count) return WF_ERR_PARSE; + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; wf_car_block *block = wf_car_find_block(car, cid); if (!block) return WF_ERR_PARSE; wf_status s = wf_mst_node_parse(block->data, block->data_len, cid, out); @@ -1507,10 +1508,28 @@ static wf_status mst_leaf_list_push(wf_mst_leaf **list, size_t *count, * left, then for each entry: the entry leaf, then its subtree. When * `has_from` is set, leaves with key < from_key are skipped (parents are * still descended so the tree structure is preserved). */ +/* + * `depth` bounds recursion on two fronts. `WF_MST_MAX_DEPTH` is the real + * stack-safety bound (see its comment in mst.h): an attacker can shape a + * chain of DISTINCT blocks -- each individually valid, linked purely + * through `left`, a few dozen bytes each -- that nests as deep as they + * like inside a well-formed CAR, and every level must land on a distinct + * block, so a cap keyed to car->block_count would never fire on it even + * tens of thousands of frames deep. WF_MST_MAX_DEPTH (1024, ~8x the + * maximum a legitimate tree can reach) rejects it long before that. The + * additional `depth > car->block_count` check is unchanged cycle + * detection, matching verify_mst_links (diff.c) and the load helpers. + * Every other recursive helper in this file (mst_all_cids_node, + * mst_count_in_range, mst_collect_proof, mst_load_node_depth, below) is + * guarded the same way; this one is the hot path every public + * list/walk/proof entry point in this file goes through. + */ static wf_status mst_walk_node(wf_car *car, const wf_cid *cid, const unsigned char *from_key, size_t from_key_len, int has_from, - wf_mst_walk_cb cb, void *ctx) { + wf_mst_walk_cb cb, void *ctx, size_t depth) { + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; wf_car_block *block = wf_car_find_block(car, cid); if (!block) return WF_ERR_PARSE; wf_mst_node node; @@ -1519,7 +1538,7 @@ static wf_status mst_walk_node(wf_car *car, const wf_cid *cid, if (node.left.len > 0) { s = mst_walk_node(car, &node.left, from_key, from_key_len, has_from, cb, - ctx); + ctx, depth + 1); if (s != WF_OK) { wf_mst_node_free(&node); return s; @@ -1538,7 +1557,7 @@ static wf_status mst_walk_node(wf_car *car, const wf_cid *cid, } if (node.entries[i].subtree.len > 0) { s = mst_walk_node(car, &node.entries[i].subtree, from_key, - from_key_len, has_from, cb, ctx); + from_key_len, has_from, cb, ctx, depth + 1); if (s != WF_OK) { wf_mst_node_free(&node); return s; @@ -1555,7 +1574,7 @@ wf_status wf_mst_walk_from(wf_car *car, const wf_cid *root, if (!car || !root || !cb) return WF_ERR_INVALID_ARG; if (root->len == 0) return WF_OK; return mst_walk_node(car, root, from_key, from_key_len, from_key != NULL, - cb, ctx); + cb, ctx, 0); } typedef struct mst_collect_ctx { @@ -1585,7 +1604,7 @@ wf_status wf_mst_list(wf_car *car, const wf_cid *root, wf_mst_leaf **out, *out_count = 0; if (root->len == 0) return WF_OK; mst_collect_ctx c = {0}; - wf_status s = mst_walk_node(car, root, NULL, 0, 0, mst_collect_cb, &c); + wf_status s = mst_walk_node(car, root, NULL, 0, 0, mst_collect_cb, &c, 0); if (s != WF_OK) { wf_mst_leaf_list_free(c.acc, c.acc_count); return s; @@ -1608,7 +1627,7 @@ wf_status wf_mst_paths(wf_car *car, const wf_cid *root, c.coll = collection; c.coll_len = collection_len; c.want_coll = 1; - wf_status s = mst_walk_node(car, root, NULL, 0, 0, mst_collect_cb, &c); + wf_status s = mst_walk_node(car, root, NULL, 0, 0, mst_collect_cb, &c, 0); if (s != WF_OK) { wf_mst_leaf_list_free(c.acc, c.acc_count); return s; @@ -1630,8 +1649,14 @@ void wf_mst_cid_list_free(wf_cid *list, size_t count) { } static wf_status mst_all_cids_node(wf_car *car, const wf_cid *cid, wf_cid **out, - size_t *count, size_t *cap) { + size_t *count, size_t *cap, size_t depth) { if (!cid || cid->len == 0) return WF_OK; + /* The containment check below guards against cycles (a CID revisited + * through a different path), but not against a long chain of DISTINCT + * blocks with no cycle at all -- see mst_walk_node's comment for why + * that's exploitable on its own. */ + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; if (mst_cid_list_contains(*out, *count, cid)) return WF_OK; wf_status s = mst_cid_list_push(out, count, cap, cid); if (s != WF_OK) return s; @@ -1644,12 +1669,12 @@ static wf_status mst_all_cids_node(wf_car *car, const wf_cid *cid, wf_cid **out, * Pushing before recursing would make the containment guard below * short-circuit and skip descending into shared subtrees. */ if (node.left.len > 0) - s = mst_all_cids_node(car, &node.left, out, count, cap); + s = mst_all_cids_node(car, &node.left, out, count, cap, depth + 1); for (size_t i = 0; s == WF_OK && i < node.count; i++) { s = mst_cid_list_push(out, count, cap, &node.entries[i].value); if (s == WF_OK && node.entries[i].subtree.len > 0) s = mst_all_cids_node(car, &node.entries[i].subtree, out, count, - cap); + cap, depth + 1); } wf_mst_node_free(&node); return s; @@ -1663,7 +1688,7 @@ wf_status wf_mst_get_all_cids(wf_car *car, const wf_cid *root, wf_cid **out, if (root->len == 0) return WF_OK; wf_cid *list = NULL; size_t count = 0, cap = 0; - wf_status s = mst_all_cids_node(car, root, &list, &count, &cap); + wf_status s = mst_all_cids_node(car, root, &list, &count, &cap, 0); if (s != WF_OK) { free(list); return s; @@ -1678,10 +1703,12 @@ static wf_status mst_count_in_range(wf_car *car, const wf_cid *cid, const unsigned char *from_key, size_t from_key_len, const unsigned char *to_key, - size_t to_key_len, int has_to, - size_t *out) { + size_t to_key_len, int has_to, size_t *out, + size_t depth) { *out = 0; if (!cid || cid->len == 0) return WF_OK; + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; wf_car_block *block = wf_car_find_block(car, cid); if (!block) return WF_ERR_PARSE; wf_mst_node node; @@ -1690,15 +1717,15 @@ static wf_status mst_count_in_range(wf_car *car, const wf_cid *cid, if (node.left.len > 0) { size_t c; s = mst_count_in_range(car, &node.left, from_key, from_key_len, to_key, - to_key_len, has_to, &c); + to_key_len, has_to, &c, depth + 1); if (s == WF_OK) *out += c; } for (size_t i = 0; s == WF_OK && i < node.count; i++) { if (node.entries[i].subtree.len > 0) { size_t c; s = mst_count_in_range(car, &node.entries[i].subtree, from_key, - from_key_len, to_key, to_key_len, has_to, - &c); + from_key_len, to_key, to_key_len, has_to, &c, + depth + 1); if (s == WF_OK) *out += c; } int ge = wf_mst_key_cmp(node.entries[i].key, node.entries[i].key_len, @@ -1719,11 +1746,17 @@ static wf_status mst_collect_proof(wf_car *car, const wf_cid *cid, size_t from_key_len, const unsigned char *to_key, size_t to_key_len, int has_to, wf_cid **out, - size_t *count, size_t *cap) { + size_t *count, size_t *cap, size_t depth) { if (!cid || cid->len == 0) return WF_OK; + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; size_t n; + /* A fresh top-down count from `cid`, not a continuation of this + * function's own depth -- its own recursion is independently bounded + * by WF_MST_MAX_DEPTH regardless of how deep mst_collect_proof itself + * has already descended. */ wf_status s = mst_count_in_range(car, cid, from_key, from_key_len, to_key, - to_key_len, has_to, &n); + to_key_len, has_to, &n, 0); if (s != WF_OK) return s; if (n == 0) return WF_OK; s = mst_cid_list_push(out, count, cap, cid); @@ -1735,13 +1768,13 @@ static wf_status mst_collect_proof(wf_car *car, const wf_cid *cid, if (s != WF_OK) return s; if (node.left.len > 0) { s = mst_collect_proof(car, &node.left, from_key, from_key_len, to_key, - to_key_len, has_to, out, count, cap); + to_key_len, has_to, out, count, cap, depth + 1); } for (size_t i = 0; s == WF_OK && i < node.count; i++) { if (node.entries[i].subtree.len > 0) { s = mst_collect_proof(car, &node.entries[i].subtree, from_key, from_key_len, to_key, to_key_len, has_to, out, - count, cap); + count, cap, depth + 1); } } wf_mst_node_free(&node); @@ -1763,7 +1796,7 @@ wf_status wf_mst_get_covering_proof(wf_car *car, const wf_cid *root, wf_cid *list = NULL; size_t count = 0, cap = 0; wf_status s = mst_collect_proof(car, root, from_key, from_key_len, to_key, - to_key_len, has_to, &list, &count, &cap); + to_key_len, has_to, &list, &count, &cap, 0); if (s != WF_OK) { free(list); return s; diff --git a/src/repo/repo.c b/src/repo/repo.c index 608d76a..37f75cc 100644 --- a/src/repo/repo.c +++ b/src/repo/repo.c @@ -36,7 +36,8 @@ static void repo_free_entries(wf_mst_entry *entries, size_t count) { static wf_status repo_mst_load_node_depth(wf_car *car, const wf_cid *cid, wf_mst_node *out, size_t depth) { - if (depth > car->block_count) return WF_ERR_PARSE; + if (depth > WF_MST_MAX_DEPTH || depth > car->block_count) + return WF_ERR_PARSE; wf_car_block *block = wf_car_find_block(car, cid); if (!block) return WF_ERR_PARSE; wf_status s = wf_mst_node_parse(block->data, block->data_len, cid, out); diff --git a/test/test_mst_depth.c b/test/test_mst_depth.c new file mode 100644 index 0000000..623ca24 --- /dev/null +++ b/test/test_mst_depth.c @@ -0,0 +1,226 @@ +/** + * test_mst_depth.c — regression coverage for unbounded MST recursion in + * wf_mst_walk_node, wf_mst_all_cids_node, wf_mst_count_in_range, and + * wf_mst_collect_proof (src/repo/mst.c), plus the load/verify helpers. + * + * An attacker can shape a CAR whose MST is a chain of DISTINCT blocks + * linked purely through `left` -- each block an individually valid node + * (`{"l": , "e": []}`), each a few dozen bytes -- and the chain can + * be as deep as the CAR is long: every level lands on a distinct real + * block, so the older `depth > car->block_count` guard never fires on it. + * That left the recursion bounded only by the attacker's block count, so a + * CAR of tens of thousands of tiny blocks drove the C stack that many + * frames deep and exhausted it. The containment check in + * wf_mst_get_all_cids catches cycles, not chains of distinct blocks. + * + * Fixed by capping depth at WF_MST_MAX_DEPTH (1024), ~8x deeper than any + * legitimate tree can reach (atproto serializes a node's height in a + * single uint8, and wf_mst_key_layer itself caps at 128). This test builds + * a 1026-block chain -- one deeper than the cap -- and asserts every MST + * entry point rejects it with WF_ERR_PARSE instead of recursing to the + * bottom, plus a positive control: a single-node tree still walks fine. + */ + +#include "wolfram/repo/car.h" +#include "wolfram/repo/mst.h" + +#include "test.h" + +#include +#include + +#define NBLOCKS (WF_MST_MAX_DEPTH + 2) + +/* A CBOR link (CID tag 42) wrapping a 37-byte bytestring: 0x00 prefix plus + * a valid 36-byte CID (version 1, dag-cbor, sha2-256). Returns bytes + * written. */ +static size_t emit_link(unsigned char *out, const unsigned char cid[36]) { + out[0] = 0xd8; + out[1] = 0x2a; /* tag 42 */ + out[2] = 0x58; + out[3] = 0x25; /* bytes(37) */ + out[4] = 0x00; /* CIDv1 version byte */ + memcpy(out + 5, cid, 36); + return 5 + 36; +} + +/* Node with a left pointer and no entries: {"e": [], "l": }. Keys in + * canonical CBOR order (length, then bytewise): "e" sorts before "l", and + * the decoder rejects out-of-order keys. */ +static size_t emit_left_node(unsigned char *out, const unsigned char left[36]) { + size_t n = 0; + out[n++] = 0xa2; + out[n++] = 0x61; + out[n++] = 'e'; + out[n++] = 0x80; + out[n++] = 0x61; + out[n++] = 'l'; + n += emit_link(out + n, left); + return n; +} + +/* Empty node: {"e": []}. */ +static size_t emit_empty_node(unsigned char *out) { + out[0] = 0xa1; + out[1] = 0x61; + out[2] = 'e'; + out[3] = 0x80; + return 4; +} + +/* Fill cid[36] with a valid distinct CID for chain position i: + * 0x01 0x71 0x12 0x20 (version/dag-cbor/sha2-256) plus the index. */ +static void cid_for_index(unsigned char cid[36], size_t i) { + static const unsigned char prefix[4] = {0x01, 0x71, 0x12, 0x20}; + memset(cid, 0, 36); + memcpy(cid, prefix, sizeof(prefix)); + cid[35] = (unsigned char)i; + cid[34] = (unsigned char)(i >> 8); + cid[33] = (unsigned char)(i >> 16); +} + +/* Build a CAR containing `nblocks` chain blocks (B0..B(n-1)) where Bi's + * node has left = B(i+1) and the final block is empty. *buf is malloc'd; + * *len holds its length. */ +static void build_chain_car(unsigned char **buf, size_t *len, size_t nblocks) { + unsigned char cids[NBLOCKS][36]; + for (size_t i = 0; i < nblocks; i++) cid_for_index(cids[i], i); + + /* Header: a2 65"roots" 81 67"version" 01. Keys in + * canonical CBOR order: "roots" (len 5) sorts before "version" (len 7). */ + unsigned char header[64]; + size_t hn = 0; + header[hn++] = 0xa2; + header[hn++] = 0x65; + memcpy(header + hn, "roots", 5); + hn += 5; + header[hn++] = 0x81; + hn += emit_link(header + hn, cids[0]); + header[hn++] = 0x67; + memcpy(header + hn, "version", 7); + hn += 7; + header[hn++] = 0x01; + + /* 1 varint byte for the header length, plus, per chain block, a 1-byte + * section varint + 36-byte CID + node body. */ + size_t cap = 1 + hn + nblocks * (1 + 36 + 48); + unsigned char *car = malloc(cap); + if (!car) { + *buf = NULL; + *len = 0; + return; + } + size_t p = 0; + car[p++] = (unsigned char)hn; /* LEB128, hn <= 127 */ + memcpy(car + p, header, hn); + p += hn; + + for (size_t i = 0; i < nblocks; i++) { + unsigned char body[64]; + size_t bn = (i + 1 < nblocks) ? emit_left_node(body, cids[i + 1]) + : emit_empty_node(body); + car[p++] = (unsigned char)(36 + bn); /* LEB128, <= 127 */ + memcpy(car + p, cids[i], 36); + p += 36; + memcpy(car + p, body, bn); + p += bn; + } + + *buf = car; + *len = p; +} + +static wf_status walk_cb(void *ctx, const unsigned char *key, size_t key_len, + const wf_cid *value) { + (void)ctx; + (void)key; + (void)key_len; + (void)value; + return WF_OK; +} + +static void test_chain_too_deep(void) { + unsigned char *car_bytes = NULL; + size_t car_len = 0; + build_chain_car(&car_bytes, &car_len, NBLOCKS); + WF_CHECK(car_bytes != NULL); + + wf_car car; + WF_CHECK(wf_car_parse(car_bytes, car_len, &car) == WF_OK); + free(car_bytes); + + unsigned char root_cid_bytes[36]; + cid_for_index(root_cid_bytes, 0); + wf_cid root; + memcpy(root.bytes, root_cid_bytes, 36); + root.len = 36; + + /* Every MST entry point over this CAR must reject it as malformed at + * the depth cap rather than recurse the full chain. Pre-fix these all + * walked to the bottom and returned WF_OK. */ + wf_mst_leaf *leaves = NULL; + size_t count = 0; + WF_CHECK(wf_mst_list(&car, &root, &leaves, &count) == WF_ERR_PARSE); + + WF_CHECK(wf_mst_walk_from(&car, &root, NULL, 0, walk_cb, NULL) == + WF_ERR_PARSE); + + static const unsigned char coll[] = "x"; + WF_CHECK(wf_mst_paths(&car, &root, coll, 1, &leaves, &count) == + WF_ERR_PARSE); + + wf_cid *cids = NULL; + WF_CHECK(wf_mst_get_all_cids(&car, &root, &cids, &count) == WF_ERR_PARSE); + + static const unsigned char from[] = "a"; + WF_CHECK(wf_mst_get_covering_proof(&car, &root, from, 1, NULL, 0, &cids, + &count) == WF_ERR_PARSE); + + wf_car_free(&car); +} + +static void test_single_node_still_works(void) { + /* A CAR with one empty node must keep working: the cap must not reject + * anything a legitimate tree can reach. */ + unsigned char *car_bytes = NULL; + size_t car_len = 0; + build_chain_car(&car_bytes, &car_len, 1); + WF_CHECK(car_bytes != NULL); + + wf_car car; + WF_CHECK(wf_car_parse(car_bytes, car_len, &car) == WF_OK); + free(car_bytes); + + unsigned char root_cid_bytes[36]; + cid_for_index(root_cid_bytes, 0); + wf_cid root; + memcpy(root.bytes, root_cid_bytes, 36); + root.len = 36; + + WF_CHECK(wf_mst_walk_from(&car, &root, NULL, 0, walk_cb, NULL) == WF_OK); + + wf_mst_leaf *leaves = NULL; + size_t count = 0; + WF_CHECK(wf_mst_list(&car, &root, &leaves, &count) == WF_OK); + WF_CHECK(count == 0); + wf_mst_leaf_list_free(leaves, count); + + wf_cid *cids = NULL; + WF_CHECK(wf_mst_get_all_cids(&car, &root, &cids, &count) == WF_OK); + WF_CHECK(count == 1); /* the root node itself */ + wf_mst_cid_list_free(cids, count); + + static const unsigned char from[] = "a"; + WF_CHECK(wf_mst_get_covering_proof(&car, &root, from, 1, NULL, 0, &cids, + &count) == WF_OK); + WF_CHECK(count == 0); + wf_mst_cid_list_free(cids, count); + + wf_car_free(&car); +} + +int main(void) { + test_chain_too_deep(); + test_single_node_still_works(); + WF_TEST_SUMMARY(); +}