AICH: rate-limit OP_AICHREQUEST + O(1) LoadHashSet via offset cache - #186
Conversation
The OP_AICHREQUEST handler was the only file-request-shaped op in the TCP dispatch that didn't call CheckForAggressive(). A hostile peer can hammer 16-byte requests at it and each one chains through ProcessAICHRequest -> CreatePartRecoveryData -> LoadHashSet, which historically walks known2.met linearly. On big seeders (~120 MiB of known2.met per TiB shared) the per-request cost is real. Match the OP_STARTUPLOADREQ pattern at ClientTCPSocket.cpp:539: tick the per-client request counter and bail if Ban() already fired this round. The other ungated AICH ops (OP_AICHANSWER inbound, OP_AICHFILEHASHREQ which only does an in-memory map lookup) don't have the same disk-amplification shape and stay as they were. Reported by @danim7 in issue amule-project#166. Refs: amule-project#166
Each incoming OP_AICHREQUEST on the seeder triggers ProcessAICHRequest -> CreatePartRecoveryData -> LoadHashSet, which historically walked known2.met from the start until it found a matching root hash -- O(N) per request, where N is the number of files in the seeder's library. On a busy seeder with a 1 TiB shared set (~120 MiB known2.met) every legitimate AICH response paid for a full sequential scan. The s_rootHashCache already existed for dedup-on-write (issue amule-project#579), storing every root hash in an unordered_set. Extend it from a set into an unordered_map<CAICHHash, uint64> where the value is the byte offset of the entry's root-hash position in known2.met: - LoadRootHashCacheLocked records file.GetPosition() before reading each rootHash during the one-shot walk. - SaveHashSet records the offset of the new entry's root hash immediately after the append succeeds. - LoadHashSet looks up the cached offset under the existing mutex, seeks straight there, and validates -- O(1) on a hit. On a cold cache it pays the one-shot LoadRootHashCacheLocked walk once and then is O(1) for every subsequent call. Two follow-on wins fall out of the same change: - When the cache is loaded and the requested root hash isn't present, LoadHashSet returns false immediately without opening known2.met -- the cache is authoritative once warm. - The dedup-on-write fast path in SaveHashSet keeps its O(1) behaviour unchanged (set vs. map lookup cost is identical). Memory cost: 8 extra bytes per known2.met entry (uint64 offset instead of unordered_set node payload). Negligible -- a 1 TiB library at ~5500 files/TiB averages ~44 KiB of extra RAM. Defensive fallback: if the cached offset's hash doesn't match (stale cache -- e.g. known2.met was externally mutated), LoadHashSet silently falls through to the existing linear-scan loop from the post-header position. False negatives are non-fatal: the caller sends an empty AICH answer and the peer re-requests. Refs: amule-project#166
|
Ran the verification harness end-to-end against a debug-build patched Harness runamuled debug log (excerpt — flood test)The behavior matches |
|
Thanks @got3nks for your prompt reply to implement these mitigations. A few points worth mentioning:
|
|
Thanks @danim7 — all three points are sharp.
Will open follow-ups for 1 and 3. |
… stale PR #186 added an offset cache so LoadHashSet seeks straight to the matching entry instead of linearly walking known2.met. The previous comment claimed that on a first-read mismatch we'd "fall through to the linear scan from the start as defensive recovery" — but the loop actually keeps reading from the cached offset onward, treating whatever bytes are at that position as a root hash + hashCount. If the cache is stale (known2.met modified externally between cache load and the request) the offset can land in the middle of a hash blob; we'd misread CurrentHash, misread nHashCount, and the loop would misalign and ultimately return false even though the entry still exists somewhere in the file. Reported by @danim7 in #186. Add a real defensive rewind: on the first iteration after seeking to the cached offset, if CurrentHash doesn't match our root hash, treat the cache as stale, jump back to just past the version header (byte 1), and continue with a true linear scan from the top. Cap the rewind to one attempt with cacheFallbackTriggered so we can't loop. Also handle the corner case where the cached offset is past EOF (file was truncated externally): skip the seek entirely and fall straight through to the linear scan. In the happy path (cache offset correct), behavior is unchanged: the first read matches and we return true after the existing match handler.
ClientUDPSocket.cpp:194 calls CheckForAggressive() on incoming file-info packets but doesn't check IsBanned() after, unlike the TCP file-request path at ClientTCPSocket.cpp:539 (and the TCP OP_AICHREQUEST path added in #186). CheckForAggressive can call Ban() when m_Aggressiveness >= 10, but the UDP handler then falls through and keeps processing the request anyway -- AddAskedCount(), SetUDPPort(), SetLastUpRequest(), ProcessExtendedInfo(), etc. A freshly-banned client could keep the seeder doing work on each UDP file-info packet it sends. Small leak, same shape as the TCP gap that #186 closed. Mirror the TCP guard: break out of the dispatch case if IsBanned() returns true. Reported by @danim7 in #186 (comment) (point 3).
Summary
Addresses both mitigations identified by @danim7 in #166 (analysis).
Two independent commits — they can be reviewed and bisected separately.
Commit 1 — rate-limit OP_AICHREQUEST
ClientTCPSocket.cpp:1548was the only file-request-shaped opcode in the TCP dispatch that bypassedCheckForAggressive(). A hostile peer could send 16-byteOP_AICHREQUESTpackets at it and each one chained throughProcessAICHRequest→CreatePartRecoveryData→LoadHashSet, forcing an O(N) walk ofknown2.metper packet. On a busy seeder (~120 MiBknown2.metper TiB shared) the amplification is real.Match the
OP_STARTUPLOADREQpattern at:539: tick the per-client request counter and bail ifBan()already fired this round.The other ungated AICH opcodes don't have the same disk-amplification shape (
OP_AICHANSWERis inbound response data;OP_AICHFILEHASHREQonly does an in-memoryGetFileByIDlookup) and stay as-is.Commit 2 — O(1) LoadHashSet via offset cache
s_rootHashCachealready existed for dedup-on-write (introduced in #579) asunordered_set<CAICHHash>. Extend it tounordered_map<CAICHHash, uint64>where the value is the byte offset of the entry's root-hash position inknown2.met.LoadRootHashCacheLockedrecordsfile.GetPosition()before reading eachrootHashduring the one-shot walk.SaveHashSetrecords the offset of the new entry's root hash immediately after the append succeeds.LoadHashSetlooks up the cached offset under the existing mutex, seeks straight there, and validates — O(1) on a hit. On a cold cache it pays the one-shotLoadRootHashCacheLockedwalk once and is O(1) for every subsequent call.Two follow-on wins fall out:
LoadHashSetreturns false immediately without openingknown2.met— the cache is authoritative once warm.SaveHashSetkeeps its O(1) behaviour unchanged (setvs.maplookup cost is identical).Memory cost: ~8 bytes per
known2.metentry beyond the existing set. A 1 TiB library at ~5500 files averages ~44 KiB extra RAM. Negligible.Defensive fallback: if the cached offset's hash doesn't match (stale cache from external
known2.metmutation),LoadHashSetfalls through to the existing linear-scan loop from the post-header position. False negatives are non-fatal — the caller sends an empty AICH answer and the peer re-requests.Test plan
CI build matrix green (9/9: Ubuntu Debug+Release, macOS Debug+Release, mingw-w64 Debug+Release, App catalogs, Manpage catalogs, Translation checks).
Configure + build clean on macOS arm64 (Release and Debug, the latter to exercise the
__DEBUG__-gated log paths).End-to-end verification harness drives both code paths against an isolated patched
amuled(no production touch). Setup boots a throwaway daemon under/tmp/amule-aich-test/with one shared 500 KB test file and enables theED2k Client/Remote Client Protocol/AICH-Transfer/SHAHashSetdebug categories so the dispatch and ban paths are observable in the logfile.Test 1 — offset cache returns valid AICH recovery data. Send a legitimate
OP_AICHREQUESTreferencing the test file's MD4 (computed in-script viahashlib.md4) and AICH master hash (parsed fromknown2_64.metoffset 1, the first entry's 20-byte root). ReceiveOP_AICHANSWERwith 108 bytes of recovery payload — provingLoadHashSetresolved the cached offset, seeked to the right entry, and emitted correct output.Test 2 — flood triggers Ban() at exactly the expected request. Restart amuled (so the per-IP client-tracker state from test 1 doesn't pre-load
m_LastFileRequest), then send 8 rapidOP_AICHREQUESTpackets with 300 ms inter-send delay. Result mirrorsCheckForAggressive's math exactly: requests 1-4 receive a 108-byte answer (score 0→3→6→9, all below the threshold of 10); request 5 silenced because the in-handlerBan()fires at score 12; requests 6-8 hit the newIsBanned()short-circuit beforeProcessAICHRequest. The amuled debug log confirms with "Aggressive client banned (score: 12): amule-bench".Harness output and log excerpt posted as a PR comment for visibility.