Bound file-controlled node index against _n_nodes in the query tree walk (OOB read on crafted .ann) - #697
Conversation
_get_all_nns pushes a split node's children[0]/children[1] (read from the index file) back onto the search queue and dereferences them on the next hop via _get(i) = _nodes + _s*i, with no check that i is within [0, _n_nodes). A crafted .ann whose split node has an out-of-range child index causes an out-of-bounds read when loaded and queried. Add the bound before the dereference (this is the // TODO: handle OOB noted above).
… pointer
Testing the first commit against a wider set of tampered indexes showed it
did not close the reported out-of-bounds read. It only bounds indices that
travel through the priority queue. A leaf node's children[] array is copied
verbatim into nns and then dereferenced in the dedup loop, which never sees
the loop guard, so a crafted index still faults there:
AddressSanitizer: BUS on unknown address, READ
#0 _get_all_nns annoylib.h:1493
spotify#1 get_nns_by_vector annoylib.h:1235
Three changes:
1. Move the existing guard above _get(i). The index has to be bounded before
it is turned into a pointer, not only before that pointer is read.
2. Reject a negative n_descendants on the leaf branch before using it as the
length of the copy. Zero stays legal, an empty leaf is a normal thing.
3. Bound the values copied out of a leaf's children[] where the dedup loop
dereferences them. This is the site that actually faulted.
Also bounds the item index in get_nns_by_item, which carried a TODO for it.
Verified on a 200 item, 4 tree index: sweeping single 4 byte edits across
every leaf-array node crashed the previous version and crashes nothing here,
over three full sweeps. Results are byte identical to unpatched annoy across
108 legitimate index configurations, dimensions 2 to 128, 1 to 1000 items,
1 to 10 trees, including the single item and two item cases.
|
I kept testing this against a wider set of tampered indexes and found that my first commit did not actually close the bug. Pushed a second commit, and I want to correct two things in the original description as well. The first commit was incomplete. It only bounds indices that travel through the priority queue. A leaf node's } else if (nd->n_descendants <= _K) {
const S* dst = nd->children;
nns.insert(nns.end(), dst, &dst[nd->n_descendants]);and those values are then dereferenced in the dedup loop, which the guard never sees. Sweeping single 4 byte edits across every leaf-array node of a stock 200 item, 4 tree index, the patched build still faults: The second commit bounds that site, moves the existing guard above Correction to the sanitizer output I quoted. I wrote Worth knowing because it affects how you reproduce it: an out-of-range index that still lands inside the last mapped page does not fault at all. The branch is silently dropped and the query quietly returns incomplete results. Whether a given crafted index crashes also depends on address layout, so it is not reliably reproducible on a single run. That is an argument for the bound rather than against it. Not a behaviour change for real indexes. Results are byte identical to unpatched annoy across 108 legitimate configurations: dimensions 2 to 128, 1 to 1000 items, 1 to 10 trees, both |
Summary
Loading a crafted
.annwithAnnoyIndex::loadand then querying it (get_nns_by_vector/get_nns_by_item) reads out of bounds._get(i)computes_nodes + _s*iwith no bound oni, and_get_all_nnspushes a split node'schildren[0]/children[1](read verbatim from the index file) back onto the queue, then dereferences them on the next hop with no check that they are within[0, _n_nodes).load()sets_n_nodes = size/_sbut validates no per-node content, sochildren[0/1](and the leaf-array item ids) can be any value in the file. This matches the// TODO: handle OOBalready noted inget_nns_by_item.Reproduction
A 2-node
.ann(f=2) whose root is a split node (n_descendants > _K) withchildren[0]out of range (e.g.2,50, or0x40000000), loaded and queried, under AddressSanitizer:An adjacent index reads a few bytes past the node array; a far index segfaults. A legitimate index queries cleanly.
Fix
Bound the node index against
_n_nodesbefore it is dereferenced, right after the queue pop. Validated with AddressSanitizer: with this guard, both an adjacent and a far out-of-range child index are handled cleanly with no OOB, and legitimate indexes still query correctly.The same
[0, _n_nodes)bound would also cover the leaf-array_get(j)and theget_nns_by_itementry_get(item)(the existing TODO); this PR does the minimal fix in the tree walk. Found with a structure-aware model/index parser fuzzer.