Conversation
datum_stratum_add_new_dupe returns a pointer to the entry it has just written, and when that write fills the table it calls datum_stratum_dupes_cleanup before returning. Both things the cleanup can do invalidate that pointer: the prune path qsorts the array so every entry moves, and the expand path reallocs it. datum_stratum_check_for_dupe stores what it gets back straight into dupes->index[nonce_index], so from the first cleanup onwards the bucket index holds pointers to entries that have moved, or into a freed array, and the next share on that nonce follows one. Under ASan the expand path is a heap-use-after-free at the read of i->nonce in datum_stratum_check_for_dupe. The prune path frees nothing and so is quieter, and it is reached whenever anything in the table can be aged out: it leaves buckets pointing at slots past current_items, which are handed out again to later entries. In a 256 share test against a small table, 235 of them left the index pointing outside the live entries. The table is allocated once per stratum thread in datum_stratum_dupes_init and never re-initialised, so nothing clears this short of restarting the gateway. Reaching it is a function of accumulated shares rather than of anything unusual: with the shipped defaults the table is 32768 entries per thread. Move the capacity check to the top of datum_stratum_check_for_dupe, which is the last point in the call where nothing is holding a pointer into the array or an insertion point in a bucket. Each call inserts at most once, so checking there is enough to guarantee the insert has room, and the insert now refuses rather than writing past the end if it ever does not. Two further things in the same file: datum_stratum_dupes_cleanup's full_wipe branch zeroed the entries and left the index pointing at them, which is the reuse case above set up deliberately. Nothing calls it with full_wipe today, which is the only reason it has not bitten. stratum.max_clients_per_thread is range checked for an upper bound and not a lower one, and the table size is the product of three configured values, so a zero or a negative sized the table at nothing. Nothing is not a table that merely overflows quickly: the expand grows it by 25%, 25% of zero is zero, and the gateway takes a share it then has nowhere to put. Size it once, floor it, then allocate, so the array and max_items cannot disagree. The tests cover both cleanup paths, because they fail differently, and assert that every pointer reachable from the index names a live entry and that no chain loops.
921602d to
6ccfbe5
Compare
jasonsopko
left a comment
There was a problem hiding this comment.
Tested ACK 6ccfbe5.
What I ran. Three AddressSanitizer builds, all through datum_gateway --test: master b9ea7dc with its own tests passes; master's datum_stratum_dupes.c with only the PR's test file dropped in trips the heap-use-after-free at the read in datum_stratum_check_for_dupe, freed by the realloc in datum_stratum_dupes_expand, the trace in the description; the PR head passes with no sanitizer report. Logs: https://gist.github.com/jasonsopko/55a0b55cc28a88beb369a75aa31e7b4c.
What I read. datum_stratum_add_new_dupe takes &dupes->ptr[current_items], links it, and on the fill runs the cleanup before returning that pointer; the sort moves it, the expand frees it, and the two paths in datum_stratum_check_for_dupe that write the return value into dupes->index[] store it after that. The other two insert sites discard the return value, and datum_stratum_dupes_reorganize rebuilds the buckets from the array, so the damage is the stale head pointer the caller writes back on top of the rebuilt index. The four insert sites are mutually exclusive and each returns, so the one check at the top of check_for_dupe is enough; after a prune current_items is below 95 percent of max_items, after an expand it is the old max_items against the new, so the insert has room either way. The index clear in the full_wipe branch and the floor on the table size read right.
Exposure. With the shipped defaults the table is 32768 entries per stratum thread, and at the vardiff target of eight shares a minute one client reaches the first cleanup in about 68 hours of uptime. A gateway with one machine on it gets there in three days; a gateway with a rack gets there the first day. Every gateway on this chain runs this code.
The description's separation of what is demonstrated from what is suspected is the part I would point other people at.
|
What do you think of OCEAN-xyz#195 ? Does it address this? |
|
No, OCEAN-xyz#195 fixes the sizing and types but not this. Master (ac9b70c) with OCEAN-xyz#195's dupes changes fails this PR's tests under ASan (use-after-free at dupes.c:317). Master with this PR merged passes clean. It still merges cleanly, and the two fit together. |
datum_stratum_add_new_dupereturns a pointer to the entry it has just written, and when that write fills the table it callsdatum_stratum_dupes_cleanupbefore returning. Both things the cleanup can do invalidate the pointer it is about to hand back:qsorts the array, so every entry moves;reallocs the array, so the old one is freed.datum_stratum_check_for_dupestores that returned pointer straight intodupes->index[nonce_index]at line 301. So from the first cleanup onwards the bucket index holds pointers to entries that have moved, or into a freed array, and the next share on that nonce follows one.Reproducing
Build with
-fsanitize=addressand rundatum_gateway --testwith the tests in this PR:Line 309 is
if (i->nonce > nonce), reading the entry the bucket points at.That is the expand path. The prune path frees nothing, so there is no use-after-free to trap on, and it is reached whenever anything in the table can be aged out. It is not harmless: it leaves buckets pointing at slots past
current_items, which are handed out again to later entries. In a 256 share test against a small table, 235 of them left the bucket index pointing outside the live entries.Once a bucket points at a slot that is then reused for a new entry, that entry can be linked to itself, and the walk in
datum_stratum_check_for_dupefollowsnextuntil it is NULL. The tests check for that and it did not occur in these runs; what is demonstrated is the stale pointer and the reuse, not a hang.Why it is worth fixing rather than rare
The table is allocated once per stratum thread in
datum_stratum_dupes_initand never re-initialised, so nothing clears this short of restarting the gateway. Reaching it is a function of accumulated shares rather than of anything unusual: with the shipped defaults (max_clients_per_thread128,vardiff_target_shares_min8,share_stale_seconds120) the table is 32768 entries per thread. After the first cleanup the corruption is not occasional.We found this while looking into an operator reporting that local share rejections went from near zero to total after several hours of mining, recurring, and cleared each time only by restarting the gateway. I want to be straight that we have not proven that symptom is this bug: past the corruption the behaviour is undefined, and a spurious duplicate, a missed duplicate and a crash are all reachable from here. The defect itself is deterministic and does not depend on that diagnosis being right.
The fix
Move the capacity check to the top of
datum_stratum_check_for_dupe. That is the last point in the call where nothing is holding a pointer into the array or an insertion point in a bucket. Each call inserts at most once — the fourdatum_stratum_add_new_dupesites are mutually exclusive and each returns immediately — so checking there is enough to guarantee the insert has room, and the insert now refuses rather than writing past the end if it ever does not.Two further things in the same file, both small:
datum_stratum_dupes_cleanup'sfull_wipebranch zeroes the entries and leaves the index pointing at them, which is the reuse case above set up deliberately. Nothing calls it withfull_wipetoday, which is the only reason it has not bitten.stratum.max_clients_per_threadis range checked for an upper bound and not a lower one, and the table size is the product of three configured values, so a zero or a negative sizes the table at nothing. Nothing is not a table that merely overflows quickly: the expand grows it by 25%, 25% of zero is zero, and the gateway takes a share it then has nowhere to put. Size it once, floor it, then allocate, so the array andmax_itemscannot disagree.Tests
Added to the existing
src/datum_stratum_dupes_tests.c. They cover both cleanup paths, because they fail differently, and assert that every pointer reachable from the index names a live entry and that no chain loops. They fail on currentmasterand pass with the fix.This function predates the 64-bit nonce work and the same defect is present in OCEAN's tree, so the equivalent fix is going to OCEAN-xyz/datum_gateway against
0.3.x.