Project

General

Profile

Actions

Bug #9126

open
JI OD

detect: byte_extract/byte_math values silently miscomputed via unchecked 64-to-32-bit truncation, enabling signature evasion

Bug #9126: detect: byte_extract/byte_math values silently miscomputed via unchecked 64-to-32-bit truncation, enabling signature evasion

Added by Jason Ish 1 day ago. Updated about 11 hours ago.

Status:
New
Priority:
Normal
Assignee:
Target version:
Affected Versions:
Effort:
Difficulty:
Label:

Description

DISCLOSURE UP FRONT

I want to be transparent before describing the issue. The truncation behavior itself is already documented in your source tree. The file src/detect-engine-content-inspection.c contains the developer comment "This cast is wrong if a 64-bit value was extracted" in exactly 9 places (lines 160, 174, 216, 228, 248, 511, 518, 545, 551). Only the depth VAR branch (line 201) validates before casting and is marked "ok to cast as we checked the byte value fits in a u32"; the other 8 sites remain intentionally unchecked. The commit that made these casts explicit is 7b492bc83 ("detect/engine: fix -Wshorten-64-to-32 warnings", 2025-05-20), referencing OISF Redmine #6186 ("Integer overflows 64 to 32 bytes").

However, #6186 is a pure compiler-warning cleanup ticket (clearing clang -Wshorten-64-to-32 warnings), not a security triage or an accepted-risk decision. That commit only turned a pre-existing implicit narrowing into an explicit cast and left the "this cast is wrong" comment behind; the ticket contains no security analysis and was closed in 8.0.0-rc1. What this report raises is NOT "the cast is wrong". It is that an attacker-controlled network payload alone can invert the signature-matching result of a normal length-delimited rule that your own official documentation teaches, and that this security impact has never been captured by any ticket, CVE, or GHSA. I searched NVD, OSV.dev, GitHub Security Advisories, and the repo (repo:OISF/suricata "cast is wrong" returns 0 issues); I found no duplicate for this security impact.

I do NOT claim that any specific deployed ruleset (for example ET Open) rule is bypassed. The claim is at the engine level: Suricata silently miscomputes the standard length-delimited rule pattern (byte_extract -> within/distance/offset) that the official docs teach in "Byte_Extract Example Using within/distance". This is the natural shape of rules for length-prefixed binary protocols such as SMB and DNS.

SUMMARY

Suricata rule language lets byte_extract/byte_math store a value pulled from the packet into a 64-bit variable (det_ctx->byte_values[], uint64_t, src/detect.h:1328), and that value can be used in the following content keyword's distance/within/offset/depth and in byte_test/byte_jump offset/nbytes. But when the detection engine actually USES this value it truncates it with (uint32_t)/(int32_t) WITHOUT any upper-bound check, in 9 places in src/detect-engine-content-inspection.c. As a result, depending on the attacker-controlled payload value, the match window (within) collapses (missed detection) or the scan position (distance/offset) flips negative (false positive / relocation), so a rule written exactly as documented fails to detect the traffic. There is no memory-safety impact - downstream clamps prevent OOB. It is purely a detection-logic integrity flaw. Your SECURITY.md explicitly includes evasion in scope.

HOW THE VALUE REACHES 64 BITS (all three paths lack an upper-bound check)

The storage is 64-bit: src/detect.h:1328 declares "uint64_t *byte_values;". Three paths can exceed 2^32:

  1. Non-string mode byte_extract:5..8,... - the limit is NO_STRING_MAX_BYTES_TO_EXTRACT 8 (src/detect-byte-extract.c:58); 8 bytes are read directly by ByteExtractUint64().
  1. String mode byte_extract:N,...,string,dec - the limit is 20 digits (STRING_MAX_BYTES_TO_EXTRACT_FOR_DEC 20); the attacker just writes a value like 4294967300 into an ASCII numeric field.
  1. multiplier - "val *= data->multiplier_value;" (src/detect-byte-extract.c:153); multiplier_value is a u16 (up to 65535) and there is NO multiplication-overflow check. A common 4-byte length field times a multiplier already exceeds 2^32.

THE UNCHECKED TRUNCATION (9 sites)

/* src/detect-engine-content-inspection.c:159-162 (distance VAR) */
if (cd->flags & DETECT_CONTENT_DISTANCE_VAR) {
// This cast is wrong if a 64-bit value was extracted
distance = (uint32_t)det_ctx->byte_values[cd->distance];
}

8 of the 9 sites carry that comment and truncate without validation; only the depth VAR branch (lines 199-203) validates first.

HOW IT WORKS (detailed enough to reproduce the PoC)

In one sentence: byte_extract extracts and stores a 64-bit value correctly, and then the moment that value is used in the match-window computation it is truncated with (uint32_t), so the match window the rule author intended collapses.

Step by step for a "within:len" rule (all in src/detect-engine-content-inspection.c):

  1. When the preceding content (e.g. content:"bin=" or content:"len=") matches, the engine records the match end position into det_ctx->buffer_offset (the prev_buffer_offset below).
  1. On the next content check, within:len leads to a depth computation of the form
depth = prev_buffer_offset + (uint32_t)byte_values[len] + distance;

where byte_values[len] is truncated from 64 to 32 bits.

  1. If the low 32 bits of byte_values[len] are small (e.g. 4), OR the value is near u32max so that the u32 addition prev_buffer_offset + within wraps around, then depth becomes smaller than the scan start offset.
  1. Then "offset > depth" holds (or the effective window after clamping is 0) and the engine does goto no_match - a content:"EVIL" that is actually present in the payload is pushed outside the inspection range, the match fails, and the alert disappears.

Three trigger values, each confirmed empirically below:

  • (A, string/dec) a 10-byte ASCII field set to 4294967300 (= 0x1_00000004) -> low 32 bits = 4 -> window collapses to 4 bytes.
  • (B, 4-byte binary, no multiplier, no string mode) a 4-byte length field set to 0xFFFFFFFF -> prev_buffer_offset(8) + 0xFFFFFFFF wraps to 7 in u32 -> offset(8) > depth(7) -> no_match. It does not even need to exceed 2^32.
  • (C, multiplier) a 4-byte field 0x40000001 times "multiplier 4" = 0x1_00000004 -> low 32 bits = 4.

The distance:var sign flip is the symmetric mechanism: "int distance = (uint32_t)byte_values[...];" makes any value >= 0x80000000 negative, which resets offset to 0.

PROOF OF CONCEPT

Reproduced only inside an isolated container of the real jasonish/suricata:8.0.7 image ("Suricata version 8.0.7 RELEASE", GCC 11.5.0, rustc 1.92.0). No traffic was sent to any live instance. All payloads are UDP. Each experiment loads a "vulnerable rule" and a "raw content control rule" together. The strongest causal evidence is experiment A (positive control) and experiment B (works with just a 4-byte field).

pcap generator (all pcaps share one UDP frame, only the payload differs), from mk.py:

# A (string/dec positive control): EVIL placed inside the collapsed 4-byte window [14,18)
a_trunc4 = b"len=4294967300EVIL" + b"P"*16    # length field 4294967300 -> low 32 bits 4
a_normal = b"len=0000000090EVIL" + b"P"*16    # length field 90 (benign, fits u32)
# B (4-byte binary, no multiplier, no string mode): EVIL placed outside the window (18 bytes later)
b_ffff   = b"bin=" + b"\xff\xff\xff\xff" + b"P"*18 + b"EVIL"   # 0xFFFFFFFF
b_benign = b"bin=" + b"\x00\x00\x00\x64" + b"P"*18 + b"EVIL"   # 100
# C (multiplier): 4-byte 0x40000001 * 4 = 0x1_00000004 -> low 32 bits 4
c_mult   = b"bin=" + b"\x40\x00\x00\x01" + b"P"*18 + b"EVIL" 
c_benign = b"bin=" + b"\x00\x00\x00\x19" + b"P"*18 + b"EVIL"   # 25

rules:

# a.rules
alert udp any any -> any any (msg:"A vuln within-var"; content:"len="; byte_extract:10,0,len,relative,string,dec; content:"EVIL"; within:len; sid:2000001; rev:1;)
alert udp any any -> any any (msg:"A raw EVIL control"; content:"EVIL"; sid:2000003; rev:1;)
# b.rules
alert udp any any -> any any (msg:"B vuln 4byte-binary within-var"; content:"bin="; byte_extract:4,0,len,relative; content:"EVIL"; within:len; sid:2100001; rev:1;)
alert udp any any -> any any (msg:"B raw EVIL control"; content:"EVIL"; sid:2100003; rev:1;)
# c.rules
alert udp any any -> any any (msg:"C vuln 4byte-binary multiplier within-var"; content:"bin="; byte_extract:4,0,len,relative,multiplier 4; content:"EVIL"; within:len; sid:2200001; rev:1;)
alert udp any any -> any any (msg:"C raw EVIL control"; content:"EVIL"; sid:2200003; rev:1;)

run each pcap against the real 8.0.7 binary:

suricata -r <pcap> -S <rules> -l <outdir> --runmode single

measured results (only the sids that fired in fast.log):

Experiment A, a_normal (len=90):        vuln 2000001 FIRED,  raw 2000003 FIRED
Experiment A, a_trunc4 (len=4294967300, EVIL inside the 4-byte window):
vuln 2000001 FIRED,  raw 2000003 FIRED
Experiment B, b_benign (0x64):          vuln 2100001 FIRED,  raw 2100003 FIRED
Experiment B, b_ffff (0xFFFFFFFF):      vuln 2100001 GONE,   raw 2100003 FIRED
Experiment C, c_benign (0x19):          vuln 2200001 FIRED,  raw 2200003 FIRED
Experiment C, c_mult (0x40000001 * 4):  vuln 2200001 GONE,   raw 2200003 FIRED

Two key conclusions:

  • Experiment A (positive control): when EVIL sits inside the collapsed 4-byte window (offsets 14-18), the vulnerable rule fires normally. This proves the alert presence/absence is NOT "byte_extract errored out and matching was skipped" but that the value is extracted, truncated, and used to completion, with the match window tied exactly to the truncated value.
  • Experiment B (attack difficulty is lower than first assumed): a common 4-byte binary length field set to just 0xFFFFFFFF is enough to evade, because the u32 addition prev_buffer_offset + within wraps around. No value exceeding 2^32 (no 20-digit ASCII, no multiplier) is required.

In every experiment the raw content:"EVIL" control rule fires on both the attack and benign pcaps, which rules out "EVIL was unreachable" or "the packet was not inspected" - only the rule that uses the attacker-controlled byte_extract value is neutralized.

IMPACT

An attacker only has to send network traffic (no privileges, no user interaction) to evade a signature. If the target rule uses a byte_extract/byte_math result in within/distance/offset - the length-delimited pattern the docs teach as an official example - the attacker can fully neutralize that signature's detection (false negative) just by setting a length field, or on the distance path flip the scan position to cause false positives. This directly undermines detection accuracy, the core value of an IDS/IPS. Your SECURITY.md explicitly lists evasion in scope.

Honest limitation on scope/severity: I could not confirm within this investigation whether a real deployed ruleset (e.g. ET Open) actually ships a rule using this pattern (I did not fetch live rulesets). So the real-world impact size (the AC:H in the CVSS below) depends on the premise that such a rule is deployed. That said, (1) your docs teach this combination as an official example, (2) the engine loads and runs it without any warning (rules_failed:0), and (3) experiment B shows it works with just a common 4-byte length field - so the pattern is not unlikely to exist. The trigger complexity of the mechanism itself is low; the remaining uncertainty is about the existence of a deployed rule (impact size), not about the existence of the flaw.

SUGGESTED FIX

  • At the 8 unchecked truncation sites, validate the upper bound before use and, when the value exceeds UINT32_MAX (or the valid range of the computation), take a defined safe action (clamp the window to the buffer end, or warn at rule-load) rather than silently producing a broken window. The depth VAR branch (line 201) already validates before casting - aligning the other 8 sites to that pattern would fix it.
  • More fundamentally, compute the match window in 64 bits and clamp to the buffer length only at the end, which removes the truncation-induced wraparound entirely.
  • Add an overflow check to the byte_extract multiplier multiplication (val *= multiplier_value).
  • (Optional hardening, NOT a separate vulnerability) src/detect-base64-decode.c:76-80 and src/detect-pcre.c:230,236-241 perform "payload_len = det_ctx>buffer_offset" without the underflow guard their sibling keywords have (int32_t len plus a "len <= 0" check in byte_extract/bytemath/bytetest). Today the invariant buffer_offset <= payload_len is enforced upstream so this is unreachable, but it is worth hardening against a future refactor that breaks the invariant. I am not submitting this as a standalone issue.

SEVERITY (please decide before submitting)

  • Primary: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:H/A:N = 5.9 (Medium)
  • Conservative: CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:N = 3.6 (Low)
  • AV:N traffic-only. AC:H requires a deployed rule using the vulnerable pattern (outside attacker control). PR:N, UI:N, S:U. C:N/A:N (no info leak, no crash, no resource exhaustion). I:H a targeted signature is fully neutralized. Under your SECURITY.md this is not "evasion with a wide scope", so I would expect a LOW-to-MODERATE rating.

AFFECTED VERSIONS

Confirmed on suricata-8.0.7 (current 8.0.x, commit d681600f3) and on origin/main (9.0.0-dev), unpatched. Presumed to affect the whole 7.x/8.x line since byte_values became uint64_t (same behavior as an implicit narrowing). Not verified on 7.0.x tags.

SUPPORTING MATERIAL

  • src/detect-engine-content-inspection.c truncation sites: 160, 174, 216, 228, 248, 511, 518, 545, 551 (the "This cast is wrong" comment); the only validated branch 199-203 (depth VAR); downstream clamps at 271 and 275.
  • src/detect.h:1328 uint64_t *byte_values;
  • src/detect-byte-extract.c: NO_STRING_MAX_BYTES_TO_EXTRACT 8 (line 58), STRING_MAX_BYTES_TO_EXTRACT_FOR_DEC 20, multiplier multiplication at line 153 (no overflow check).
  • Official docs doc/userguide/rules/payload-keywords.rst "Byte_Extract Example Using within/distance".
  • Related cleanup commit 7b492bc83 referencing OISF Redmine #6186 (compiler-warning cleanup, not a security triage, closed in 8.0.0-rc1).
  • Duplicate check: NVD/CVE.org (existing suricata evasion CVEs are all decoder/stream layer, unrelated to rule-keyword integer truncation), OSV.dev, GitHub Security Advisories, repo search (0 hits) - no duplicate for this security impact.

REPORTER NOTES (please read)

  • AI disclosure: this finding was initially surfaced with the help of an AI agent pipeline (static analysis, then verification, then adversarial review), but every reproduction result above was independently confirmed against the real jasonish/suricata:8.0.7 image and real compiler/binary output - it is not an unverified AI guess.
  • CVE ID: I have not requested a CVE ID myself. Per your security policy, please request one if you determine this warrants it.
  • Credit: if this is accepted, I would like public credit in the release notes / announcement / GHSA. My GitHub handle for credit is: SimJongMin
  • Public CI/QA use of the PoC data (pcaps/rules/logs): yes, you may use these in your public CI/QA test set. I reproduced this myself against the real jasonish/suricata:8.0.7 image (same result: both control signatures fire on both pcaps, the vulnerable signature only fires on the normal pcap and disappears on the attack pcap) and there is no sensitive data in the PoC.
  • Independent reproduction: I personally ran attack.pcap and normal.pcap against jasonish/suricata:8.0.7 myself and confirmed the results above - this was not just the AI pipeline's output, I verified it by hand.

Subtasks 1 (1 open — 0 closed)

Bug #9132: detect: byte_extract/byte_math values silently miscomputed via unchecked 64-to-32-bit truncation, enabling signature evasion (8.0.x backport)AssignedOISF DevActions

VJ Updated by Victor Julien about 22 hours ago Actions #1

  • Subject changed from Suricata detection engine silently miscomputes byte_extract/byte_math values via unchecked 64-to-32-bit truncation, enabling signature evasion (8.0.7 and main) to detect: byte_extract/byte_math values silently miscomputed via unchecked 64-to-32-bit truncation, enabling signature evasion

JI Updated by Jason Ish about 11 hours ago Actions #2

  • Target version changed from TBD to 9.0.0-beta1
  • Private changed from Yes to No
  • Label Needs backport to 8.0 added

OT Updated by OISF Ticketbot about 11 hours ago Actions #3

  • Subtask #9132 added

OT Updated by OISF Ticketbot about 11 hours ago Actions #4

  • Label deleted (Needs backport to 8.0)

JI Updated by Jason Ish about 11 hours ago Actions #5

Actions

Also available in: PDF Atom