Actions
Bug #8820
open
SB
VJ
mpm/ac: detection bypass while loading over 64k case-sensitive patterns
Bug #8820:
mpm/ac: detection bypass while loading over 64k case-sensitive patterns
Affected Versions:
Effort:
Difficulty:
Label:
Description
Reported by Communications Security Establishment (CSE):
## Summary
The Aho-Corasick multi-pattern matcher (`SCACSearch`) stores case-sensitive pattern IDs
with only bit 31 used as the case flag, leaving bits 0–30 for the PID. The u16 search
branch correctly recovers the PID with `AC_PID_MASK` (0x7FFFFFFF), but the u32 branch —
taken whenever the automaton has ≥ 32767 states — recovers it with the literal
`0x0000FFFF`, silently discarding PID bits 16–30. When a deployment loads more than
65536 distinct case-sensitive fast-patterns into a single AC context, every pattern with
internal id ≥ 65536 is looked up in the wrong `pid_pat_list[]` slot at match time. The
result is a deterministic detection bypass for those signatures (and possible
false-positive firing of the aliased signature). The truncated index stays in-bounds and
the offset/length arithmetic is still bounded, so no memory-safety violation occurs.
## Affected Piece of Code
- **File:** `src/util-mpm-ac.c`
- **Function / Location:** `SCACSearch()` u32 branch, ~L913–960 (specifically L923)
- **Subsystem:** util-mpm-spm — Multi-pattern and single-pattern matchers (Aho-Corasick,
Hyperscan, Boyer-Moore)
```c
/* /home/omuser/claude/suricata/src/util-mpm-ac.c */
913 } else {
914 register SC_AC_STATE_TYPE_U32 state = 0;
915 const SC_AC_STATE_TYPE_U32(*state_table_u32)[256] = ctx->state_table_u32;
916 for (uint32_t i = 0; i < buflen; i++) {
917 state = state_table_u32[state & 0x00FFFFFF][u8_tolower(buf[i])];
918 if (state & 0xFF000000) {
919 const uint32_t no_of_entries = ctx->output_table[state & 0x00FFFFFF].no_of_entries;
920 const uint32_t *pids = ctx->output_table[state & 0x00FFFFFF].pids;
921 for (uint32_t k = 0; k < no_of_entries; k++) {
922 if (pids[k] & AC_CASE_MASK) {
923 const uint32_t lower_pid = pids[k] & 0x0000FFFF; /* BUG: should be AC_PID_MASK (0x7FFFFFFF) */
924 const SCACPatternList *pat = &pid_pat_list[lower_pid];
925 const int offset = i - pat->patlen + 1;
926
927 if (offset < (int)pat->offset || (pat->depth && i > pat->depth))
928 continue;
929 if (pat->endswith && (uint32_t)offset + pat->patlen != buflen)
930 continue;
931
932 if (SCMemcmp(pat->cs, buf + offset,
933 pat->patlen) != 0) {
```
For comparison, the construction code that encodes the PID and the sibling u16 branch
that decodes it correctly:
```c
/* construction — src/util-mpm-ac.c:574-578 */
for (k = 0; k < ctx->output_table[state].no_of_entries; k++) {
if (ctx->pid_pat_list[ctx->output_table[state].pids[k]].cs != NULL) {
ctx->output_table[state].pids[k] &= AC_PID_MASK; /* 0x7FFFFFFF */
ctx->output_table[state].pids[k] |= ((uint32_t)1 << AC_CASE_BIT);
}
}
/* u16 search branch — src/util-mpm-ac.c:876-878 */
if (pids[k] & AC_CASE_MASK) {
const uint32_t lower_pid = pids[k] & AC_PID_MASK; /* correct */
const SCACPatternList *pat = &pid_pat_list[lower_pid];
```
## The Bug
### Encoding vs. decoding mismatch
During automaton construction (`SCACInsertCaseSensitiveEntriesForPatterns`, L570–580),
every case-sensitive pattern ID stored in `output_table[].pids[]` is encoded as:
```
pids[k] = (original_pid & AC_PID_MASK) | (1u << AC_CASE_BIT);
= (original_pid & 0x7FFFFFFF) | 0x80000000;
```
Only bit 31 is the flag; bits 0–30 carry the PID. `max_pat_id` is a plain `uint32_t`
that is incremented unbounded for every unique pattern added (`util-mpm.c:463`,
`p->id = mpm_ctx->max_pat_id++`). Nothing in `SCACPreparePatterns()` or
`MpmAddPattern()` caps it at 16 bits.
At search time, the u16-state branch (taken when `ctx->state_count < 32767`) correctly
inverts this encoding with `pids[k] & AC_PID_MASK` (L877). The u32-state branch —
entered at L913 precisely when the ruleset is large enough that PID > 65535 becomes
plausible — instead uses the hard-coded literal `0x0000FFFF` (L923). For any
case-sensitive pattern whose internally assigned `id ≥ 65536`, bits 16–30 are dropped
and `lower_pid` becomes `id mod 65536`.
### Consequences of the wrong index
`pid_pat_list[lower_pid]` then resolves to a *different, valid* `SCACPatternList` entry
(the array is `calloc`'d with `max_pat_id + 1` elements at L683, so
`lower_pid ≤ 65535 < max_pat_id + 1` is always in-bounds). The aliased entry's `patlen`,
`cs`, `offset`, `depth`, `endswith`, and `sids` are used in place of the real ones:
1. **Detection bypass.** `SCMemcmp(pat->cs, buf + offset, pat->patlen)` at L932 compares
the *wrong* literal against the haystack. In the overwhelmingly common case the bytes
differ, the loop `continue`s, and the intended signature's `sids` are never pushed
into the `PrefilterRuleStore`. The rule never reaches the candidate list in
`DetectRun()` and produces no alert.
2. **False positive / cross-fire.** If the aliased slot's `cs`/`patlen` happen to match
the bytes at `buf + offset` (e.g. one pattern is a suffix of the other), the *wrong*
`pat->sids` are pushed via `PrefilterAddSids()` (L939), and the de-dup `bitarray` bit
is set/tested for the wrong PID (L937–938), which can additionally suppress a later
legitimate hit on the aliased pattern.
3. **No OOB read.** Although the title mentions OOB read as the apparent risk, analysis
shows the access stays bounded:
- `lower_pid` is always a valid index into `pid_pat_list` (see above).
- A negative `offset = i - pat->patlen + 1` from a *longer* aliased `patlen` is
rejected by `offset < (int)pat->offset` at L927 (`pat->offset` is `uint16_t`, so
the cast yields a value ≥ 0 and any negative `offset` fails the check).
- The upper bound holds because `offset + pat->patlen == i + 1 ≤ buflen`, so
`SCMemcmp` never reads past the haystack end.
- If the aliased slot is a nocase pattern (`cs == NULL`), its `patlen` field is 0, so
`SCMemcmp(NULL, _, 0)` is a no-op.
### Call chain from the wire to L923
```
capture-thread slot
→ FlowWorker() src/flow-worker.c:562
→ Detect() src/flow-worker.c:403/457 → src/detect.c:2846/2860
→ DetectRun() src/detect.c:106
→ DetectRunPrefilterPkt() src/detect.c:622
→ Prefilter() src/detect-engine-prefilter.c:219
→ engine->cb.Prefilter == PrefilterPktPayload()
src/detect-engine-payload.c:116
→ mpm_table[MPM_AC].Search == SCACSearch()
src/util-mpm-ac.c:854
buf = p->payload
buflen = p->payload_len
ctx->state_count >= 32767 → u32 branch at L913
pids[k] has bit31 set (set at L576-577) → L922 true
lower_pid = pids[k] & 0x0000FFFF → L923 truncates
```
### Required field values / preconditions
- `mpm-algo: ac` (the pure-software Aho-Corasick matcher; non-default when Hyperscan is
compiled in).
- `detect.sgh-mpm-context: single`, so that all fast-patterns are concentrated into one
`MpmCtx`.
- ≥ 65537 *unique* case-sensitive fast-pattern strings registered into that `MpmCtx`, so
that at least one receives internal `id ≥ 65536`. This many distinct strings also
guarantees `state_count ≥ 32767`, forcing the u32 table and the buggy branch.
- Attacker sends a packet whose payload contains the literal bytes of one of those
high-PID patterns.
**Vulnerability class:** network-reachable logic bypass (mis-indexed lookup); originally
suspected OOB read ruled out on closer analysis.
## Reproduction Results
**Precondition (operator-side, not attacker-controllable):**
1. `suricata.yaml`:
```yaml
mpm-algo: ac
detect:
sgh-mpm-context: single
```
2. Generate a rule file with > 65536 rules, each carrying a unique case-sensitive
`content` that is selected as the fast-pattern and lands in the same payload MPM
context:
```sh
for i in $(seq 1 70000); do
printf 'alert udp any any -> any 9999 (msg:"r%d"; content:"UNIQPATTERN%07d"; fast_pattern; sid:%d; rev:1;)\n' $i $i $i
done > big.rules
```
All 70000 contents are 18-byte unique case-sensitive strings. With
`sgh-mpm-context: single` they are all added to one `MpmCtx` via `MpmAddPattern()`,
where each new unique pattern gets `p->id = mpm_ctx->max_pat_id++`
(`util-mpm.c:463`). The 65537th distinct pattern therefore receives internal
pid 65536. The 70000 distinct strings also push `state_count` well past 32767, so
`SCACPrepareStateTable()` builds the u32 table.
3. Start Suricata with this rules file. The build is memory-heavy but loads;
`SCACPreparePatterns()` imposes no 16-bit cap on `max_pat_id`.
**Attacker step (network):**
4. Send one UDP datagram to `<sensor-monitored-host>:9999` whose payload is exactly the
18 ASCII bytes of the pattern that received internal pid 65536. Assuming
`MpmAddPattern()` is invoked in rule-load order, that is the literal for sid 65537:
```
payload = 55 4e 49 51 50 41 54 54 45 52 4e 30 30 36 35 35 33 37
= "UNIQPATTERN0065537"
```
Full frame: any L2/L3 that Suricata decodes — e.g. Ethernet / IPv4 / UDP dport 9999,
UDP payload = the 18 bytes above.
**Expected vs. actual:**
5. *Expected:* alert for sid 65537.
*Actual:* `SCACSearch()` walks the automaton to the accepting state;
`pids[k] = 0x80010000`; L923 computes `lower_pid = 0x80010000 & 0x0000FFFF = 0`;
`pid_pat_list[0].cs = "UNIQPATTERN0000001"`;
`SCMemcmp("UNIQPATTERN0000001", "UNIQPATTERN0065537", 18) != 0` → `continue`. sid
65537's `sids` never enter `det_ctx->pmq`, `DetectRun()` never evaluates the rule,
and no alert is emitted. (Conversely, if the aliased slot's `cs`/`patlen` happened to
match the bytes at `buf + offset`, the *wrong* `sids` would be pushed — a false
positive.)
**Reproduction status:** *Analytical only.* The trigger was not exercised live because
the precondition requires building and loading a > 64k-pattern AC context whose u32
state table is multi-GB, which is outside the scope of this static-analysis audit.
However, every step in the chain is deterministic source-level arithmetic, and an
exhaustive search of the AC code path found no guard that caps `max_pat_id` at 16 bits
or otherwise prevents `pid ≥ 65536` from reaching L923.
## Severity
**LOW.**
The defect causes a deterministic detection bypass — and in edge cases a false-positive
cross-fire — for every case-sensitive fast-pattern whose internally assigned pattern-id
is ≥ 65536 while the AC matcher is in its u32-state mode. There is no crash and no
out-of-bounds read or write: the truncated index is always inside the `calloc`'d
`pid_pat_list` (sized `max_pat_id + 1`, L683), negative offsets are filtered at L927,
and the upper bound `offset + patlen == i + 1 ≤ buflen` holds for the `SCMemcmp`.
Exploitability is gated entirely by operator configuration: the deployment must (a)
explicitly select `mpm-algo: ac` (non-default when Hyperscan is available) **and** (b)
concentrate > 65536 unique fast-patterns into a single MPM context (e.g.
`sgh-mpm-context: single` with an extremely large ruleset). This combination is very
uncommon in production. The attacker contributes only a single packet containing a known
signature literal.
## Suggested Fix
Use the same mask that the construction code (L576–577) and the u16 search branch (L877)
already use. One-line patch:
```diff
--- a/src/util-mpm-ac.c
+++ b/src/util-mpm-ac.c
@@ -920,7 +920,7 @@ uint32_t SCACSearch(const MpmCtx *mpm_ctx, MpmThreadCtx *mpm_thread_ctx,
const uint32_t *pids = ctx->output_table[state & 0x00FFFFFF].pids;
for (uint32_t k = 0; k < no_of_entries; k++) {
if (pids[k] & AC_CASE_MASK) {
- const uint32_t lower_pid = pids[k] & 0x0000FFFF;
+ const uint32_t lower_pid = pids[k] & AC_PID_MASK;
const SCACPatternList *pat = &pid_pat_list[lower_pid];
const int offset = i - pat->patlen + 1;
```
Optionally, add a defensive assertion in `SCACPreparePatterns()` that
`mpm_ctx->max_pat_id < AC_CASE_MASK` to document the invariant that the PID never
collides with the case-flag bit.
VJ Updated by Victor Julien 23 days ago
- Subject changed from SCACSearch u32 path masks pattern-id with 0x0000FFFF instead of AC_PID_MASK → wrong pid_pat_list index / OOB read to mpm: SCACSearch u32 path masks pattern-id with 0x0000FFFF instead of AC_PID_MASK → wrong pid_pat_list index / OOB read
- Status changed from New to Assigned
- Assignee changed from OISF Dev to Victor Julien
VJ Updated by Victor Julien 22 days ago
- Status changed from Assigned to In Review
SB Updated by Shivani Bhardwaj 22 days ago
- Subject changed from mpm: SCACSearch u32 path masks pattern-id with 0x0000FFFF instead of AC_PID_MASK → wrong pid_pat_list index / OOB read to mpm/ac: detection bypass while loading over 64k case-sensitive patterns
PA Updated by Philippe Antoine 2 days ago
I would say this is just a bug when we have too many rules which is not attacker-controlled...
VJ Updated by Victor Julien 1 day ago
- Tracker changed from Security to Bug
- Private changed from Yes to No
VJ Updated by Victor Julien about 14 hours ago
Actions