Skip to content

libclamav: front-code the virus names of hash signatures - #1815

Open
netchild wants to merge 2 commits into
Cisco-Talos:mainfrom
netchild:submit/hash-names
Open

netchild wants to merge 2 commits into
Cisco-Talos:mainfrom
netchild:submit/hash-names

Conversation

@netchild

Copy link
Copy Markdown

A hash signature keeps its name as a pooled string plus an 8-byte entry in a
parallel virusnames[] array. On a 424 MB signature set that is 80.7 MB of name
bytes and 26.4 MB of pointers against 53.2 MB of hash payload — the names cost
more than twice what they name.

Interning them is worthless: 3,290,969 of 3,292,090 are distinct. But they share
long prefixes — sorted neighbours agree on 21.7 bytes of a 23.5 byte average — so
sorting them and front coding each entry as [lcp][len][tail] stores the same
names in 18.0 MB. A restart point every 16 entries gives a decode somewhere to
begin, and name_idx[] replaces the pointer array with one 32-bit entry number
per signature.

Names are decoded on match into a per-root cache that lives as long as the
engine, which is the lifetime evidence_get_last_alert() documents for the
pointer it hands out. hm_addhash_str() and hm_addhash_bin() copy the name
they are given rather than taking ownership, so cli_loadhash() and
openioc_parse() pass an ordinary cli_virname() and free it.

The sort is preceded by a counting sort on the name arena offset. Entry order
arrives from a hash-table walk, so comparing names in that order makes every
merge pass a random walk over a 77 MB arena; on this set that pre-pass is
1.6 s of the load time below.

Measured

424 MB signature set (3,849,249 signatures), RSS after cl_engine_compile(),
3 runs per arm, spread under 0.01 %:

Build before after delta
mpool (as shipped) 1,135,465 KB 1,036,364 KB -99,101 (-8.7 %)
DISABLE_MPOOL=ON 1,392,657 KB 1,122,487 KB -270,170 (-19.4 %)

Peak RSS falls with it, 1,135,465 → 1,129,584 KB, even though the table is built
while the load-time name arena is still resident.

Engine load time rises 10.21 s → 11.87 s (+16 %) on that set, from sorting
3.3 M names; both arms' run-to-run spread is under 1.5 %. Scan time over a
2,401-file tree is unchanged within the spread of the measurement, -0.6 ± 1.1 s
on 127 s.

Behaviour

Scan output is byte-identical over that tree, EICAR control included. The one new
failure mode is an out-of-memory while decoding a name, which makes
cli_hm_scan() return CL_EMEM rather than report a match it cannot name;
hm_flush() returns cl_error_t for the same reason, so a failed allocation
while building the table is not swallowed.

Verified with the test suite (same failure set as the base), a
byte-for-byte scan-output comparison over 2,401 files, an OpenIOC database
load, an 8-thread concurrent-decode run, the escaped-length paths at names up
to 608 bytes, and AddressSanitizer with a positive control.

A hash signature keeps its name as a pooled string plus an 8-byte entry
in a parallel virusnames[] array. On a 424 MB signature set that is
80.7 MB of name bytes and 26.4 MB of pointers against 53.2 MB of hash
payload, so the names cost more than twice what they name.

Interning them is worthless - 3,290,969 of 3,292,090 are distinct - but
they share long prefixes: sorted neighbours agree on 21.7 bytes of a
23.5 byte average. Sorting them and front coding each entry as
[lcp][len][tail], with a restart point every 16 entries so a decode has
somewhere to begin, stores the same names in 18.0 MB. name_idx[] then
replaces the pointer array with one 32-bit entry number per signature.

Names are decoded on match into a per-root cache that lives as long as
the engine, which is the lifetime evidence_get_last_alert() documents
for the pointer it hands out. hm_addhash_str() and hm_addhash_bin() copy
the name they are given rather than taking ownership, so cli_loadhash()
and openioc_parse() pass an ordinary cli_virname() and free it.

The sort is preceded by a counting sort on the name arena offset. Entry
order arrives from a hash-table walk, so comparing names in that order
makes every merge pass a random walk over the arena; on this set that
alone is 1.6 s of the load time below.

hm_addhash_bin() still grows both arrays one entry at a time. Growing
them geometrically and shrinking to size at flush - what issue Cisco-Talos#1118
asks for - was tried first and gave up most of the saving: mpool does
not return an over-allocated tail to the OS, and mpool_realloc()
shrinking across a size class allocates the smaller block before freeing
the larger one into a free list, so the shrink grows the arena. On the
build pair that was measured, per-entry growth saved 99,630 KB of
compiled RSS where geometric growth saved 5,918 KB.

RSS after cl_engine_compile(), 3,849,249 signatures, 3 runs per arm,
spread under 0.01 %:

    mpool (as shipped)   1,135,465 KB -> 1,036,364 KB   -99,101 (-8.7%)
    DISABLE_MPOOL=ON     1,392,657 KB -> 1,122,487 KB  -270,170 (-19.4%)

Peak RSS falls with it, 1,135,465 -> 1,129,584 KB, although the table is
built while the load-time name arena is still resident.

Engine load time rises 10.21 s -> 11.87 s (+16 %) on that set, from
sorting 3.3 M names. Scan time over a 2,401 file tree is unchanged
within the spread of the measurement, -0.6 +/- 1.1 s on 127 s.

hm_flush() returns cl_error_t so that a failed allocation while building
the table is not swallowed.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a94a1b4a87

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread libclamav/matcher-hash.c
Materialising a hash signature's name can fail now that the name is
decoded on demand, so cli_hm_scan() and cli_hm_scan_wild() can return
CL_EMEM where before they returned only CL_CLEAN or CL_VIRUS.

Their callers were written against the old contract and test for
CL_VIRUS alone, so that error is discarded. In cli_scan_fmap() the
size-based result is overwritten by the wildcard scan two lines later,
and the section and import-hash paths in pe.c fall through to the next
hash type. A signature that did match is then reported clean.

Each caller now separates the three outcomes. cli_check_fp() treats a
failed lookup the way it already treats a failed hash calculation, by
keeping the alert rather than trusting a file it could not check
against the false positive signatures.

The authenticode path in pe.c is unchanged: it passes a NULL virname,
so it never materialises a name.

Also assigns the return value of the import-hash wildcard
cli_append_virus() call, which was dropped while the line below it
tested the previous value.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant