found a remotely triggerable out-of-bounds read in the Linux kernel's H.323 connection tracking parser (CVE-2026-23455, CVSS 9.1 Critical). no authentication. no privileges. send a packet and the kernel reads memory until it hits an unmapped page. this code has been reachable from the network since 2007.
H.323 is a VoIP signaling protocol from the 1990s that refuses to die. it predates SIP. it's the protocol your office phone system was probably running before someone migrated to Teams, and in a lot of places nobody migrated. telecom carriers still use it for trunk signaling. hospitals run it for nurse call systems and overhead paging. hotels use it for room phones. building intercoms, elevator emergency phones, courtroom recording systems, prison phone networks, air traffic control voice switches. I keep finding it in places where I'm like, who is maintaining this, and the answer is always nobody, it just works. scariest sentence in infra.
every Linux firewall or NAT gateway that handles this traffic loads nf_conntrack_h323. full ASN.1 PER decoder. inline in the kernel. parsing untrusted packets from the network in kernel context. the module exists because H.323 uses dynamic port allocation for media streams so the firewall needs to understand the signaling to open the right ports. it auto-loads when H.323 traffic hits a conntrack rule. on many distributions it's loaded by default. you don't configure this or opt into this. someone behind your gateway plugs in a phone and now you have an ASN.1 parser in your kernel processing packets from the internet. the attack surface is a UDP packet to port 1719 or a TCP connection to port 1720 on any Linux machine doing NAT for a network that might have a phone on it somewhere. the parser runs before the packet reaches userspace, and there is no firewall rule that helps because the firewall itself is the thing running the vulnerable code.
DecodeQ931() processes Q.931 signaling messages. the UserUserIE code path reads a 16-bit length from the packet and decrements it by 1 to skip the protocol discriminator byte:
len = (buf[p] << 8) | buf[p+1] p++; len-- return DecodeH323_UserInformation(buf, p, len, ...)
if the encoded length is 0, len-- wraps to -1. len is a signed int. -1 gets passed to the decoder, which interprets it as a very large positive value and walks through memory far past the end of the packet buffer. the decoder iterates through ASN.1 fields, following structure, dereferencing pointers, until it hits an unmapped page or a parse error. one malformed packet, unbounded kernel memory disclosure. depending on slab layout the adjacent memory could contain kernel pointers that defeat KASLR, crypto keys, credentials from other processes, fragments of network packets belonging to other users.
so the programmer who wrote this in 2007 was implementing RFC 2225. the RFC says the UserUserIE field contains a protocol discriminator byte followed by a variable-length body. the discriminator is always present. so len-- is always safe because len is always at least 1. the RFC says so. the protocol guarantees it. the wire does not care about your protocol guarantees. the wire is untrusted input. but once you've internalized "there's always a discriminator byte" the decrement looks like bookkeeping, and every reviewer after you internalizes the same thing because the code reads naturally and the RFC agrees and nobody is going to stop and ask "but what if len is zero" because the protocol says it can't be.
the code is wrong because the programmer understood the protocol. someone who understood H.323 less might have added a defensive check, but someone who understood it perfectly trusted the wire to be well-formed. a reviewer who also understands the protocol reads len-- and sees bookkeeping. the bug is invisible to expertise. you can only see it by distrusting the wire unconditionally, which means ignoring what you know about the protocol. I don't have a clean answer for how you systematize that.
static analyzers can't flag it because every individual operation is valid. the length read is bounds-checked. the decrement is legal C. the function call is type-correct. fuzzers might hit it if they generate a zero-length UserUserIE but protocol-aware fuzzers tend to generate valid protocol structures, which means len >= 1, which means they systematically avoid the one input that triggers the bug. the tool that finds this is a human reading code and asking "what if this value is zero" at every arithmetic operation on untrusted input. boring question. answer is almost always nothing. the 1% where it isn't nothing is a 9.1 Critical and you have no way to know which 1% until you've asked everywhere.
protocol-specific conntrack helpers get written once for a deployment need. they work. they ship. then they sit in the kernel for two decades because the protocol still exists somewhere and removing the module would break someone's phone system in a hospital basement. nobody is reading the code for bugs. they're keeping it compiling. eighteen years. 9.1. if (len <= 0) break;.
originally reported by @VidocSecurity (Klaudia Kloc and Dawid Moczadło). I confirmed, reproduced, and patched it. patched in stable 5.10–6.19.
found a remotely triggerable out-of-bounds read in the Linux kernel's H.323 connection tracking parser (CVE-2026-23456, CVSS 8.2). no authentication. no privileges. no user interaction. send a malformed packet to port 1720 on any Linux firewall or NAT gateway running nf_conntrack_h323 and you're reading kernel slab memory.
some context on why this matters more than a typical kernel OOB. H.323 is a VoIP signaling protocol from the 1990s. it is everywhere you don't think to look. telecom carriers, enterprise PBXs, session border controllers, hospital phone systems, building intercoms, elevator emergency phones, legacy videoconferencing. every Linux-based firewall or NAT device that needs to track H.323 connections for dynamic port allocation loads nf_conntrack_h323, which contains a full ASN.1 PER decoder running inline in the kernel, parsing untrusted data from the network, at wire speed, with direct access to kernel memory. this module auto-loads when H.323 traffic hits a conntrack rule. on many distributions it's loaded by default. the attack surface is: send a packet from the internet to a machine that might be doing NAT for a phone system somewhere behind it.
the bug. in decode_int(), the CONS case:
nf_h323_error_boundary(bs, 0, 2) len = get_bits(bs, 2) + 1 BYTE_ALIGN(bs) v = get_uint(bs, len)
the boundary check validates 2 bits for get_bits(). it does not validate len bytes for get_uint(). the length field is bounds-checked. the data described by the length field is not. craft a H.323/RAS packet where the bitstream is truncated after the length field. get_uint() walks 1–4 bytes off the end of a slab allocation. the attacker controls which allocation this is and can potentially influence what's adjacent in the slab cache. 1–4 bytes doesn't sound like much until you remember that kernel pointers, ASLR secrets, and crypto material all live in slab memory and a single leaked pointer can defeat KASLR.
now the interesting part. after the patch landed, Jakub Kicinski's AI code reviewer flagged five other locations in the same file as having the same bug: UNCO in decode_int, SEMI in decode_bitstr, SEMI and default in decode_octstr, BYTE in decode_bmpstr. all five advance bs->cur without checking that enough bytes remain. Florian went through each one and found a post-advance boundary check after every single one. "this LLM response is bunk."
he was right. but the reason he was right is the reason the CONS case is a real bug and the other five are not, and I think this is where current AI code review genuinely cannot tell the difference.
the other cases do this: advance bs->cur past the data without dereferencing, then check nf_h323_error_boundary(bs, 0, 0) after the switch block. the pointer overshoots. nothing reads through it. the boundary check fires. the function returns an error. pointer arithmetic past the end of a buffer is not a memory safety violation, only pointer dereference is. the pointer moved into illegal territory but nobody looked through the window.
the CONS case is different. get_uint(bs, len) dereferences *bs->cur++ inline. it reads 1–4 bytes from memory as part of advancing. the dereference and the advance are the same operation. there is no "temporary overshoot" because the bytes are physically read from memory during the overshoot. a post-advance boundary check cannot un-read memory. the AI saw "pointer advances without pre-check" six times and pattern-matched all of them as the same bug. five of them advance a pointer. one of them reads through a pointer while advancing. pointer arithmetic vs pointer dereference is the entire vulnerability, and current AI review can't see the difference because it's matching on control flow shape, not on what the CPU actually does when the instruction executes.
the fix is two lines. one call to nf_h323_error_boundary(bs, len, 0) between get_bits() and get_uint(). the original commit is 5e35941d9901, "[NETFILTER]: Add H.323 conntrack/NAT helper", from 2007. twenty years of a full ASN.1 decoder running in kernel space, parsing untrusted packets from the network, with a missing bounds check on a length-prefixed read. loaded by default on most distributions. reachable without authentication. the fix is two lines.
reported by Klaudia Kloc and Dawid Moczadło from @VidocSecurity. I verified the bug, wrote the PoC, and submitted the patch. patched in stable 5.10–6.19.
no, the value is dead. can't leak it. kernel CVE team assigns to bugfixes proactively without requiring exploitation. their docs say "overly cautious" and they mean it. the 7.1 is NVD's scoring, not mine.
but "harmless" is kind of the point. the bug survived five years of review specifically because the value is dead. that's the selection pressure, the ones where the callee uses the argument get caught and the ones where it doesn't live forever. harmlessness is the camouflage.
would love to see the for-loop variant you mentioned.
found a stack out-of-bounds read in the Linux kernel's nftables pipapo set backend (CVE-2026-43453, CVSS 7.1). I found it by looking for a specific pattern that I think is underhunted, so I want to talk about the methodology as much as the bug.
the pattern: function calls where one argument is a boundary-dependent expression and another argument is a flag that makes the callee skip using it. in C, this is a trap. the callee's early return makes every reviewer think the dangerous argument is inert. it is not. C evaluates all arguments at the call site before the function is invoked. the callee's control flow has no jurisdiction over argument evaluation. so you get these call sites that look safe, that have been reviewed and re-reviewed and look safe every time, because the question everyone asks is "is this value used?" and the answer is no. the question that matters is "is this value evaluated?" and nobody asks it because in most languages it's the same question.
so I started grepping function calls where an argument indexes an array, and a separate argument is a boolean that triggers an early return in the callee. the kind of code where someone wrote a guard clause and everyone downstream trusted it to cover the arguments too. it doesn't. it can't. the arguments are already computed.
pipapo_drop() in nft_set_pipapo.c:
pipapo_unmap(f->mt, f->rules, rulemap[i].to, rulemap[i].n, rulemap[i + 1].n, i == m->field_count - 1)
on the last iteration, i == field_count - 1. rulemap[i + 1].n reads past the end of a stack-allocated array of 16 entries. pipapo_unmap() checks is_last, returns immediately, never touches the value. the value is already read. the OOB is in the caller's scope. five years of this code in production and every review pass concluded "the function doesn't use it" which is true and also completely beside the point.
the reason I think this pattern is underhunted: static analyzers flag unused variables and unchecked return values but I haven't seen one that asks "is this argument expression legal in the caller's scope given that the callee might not use it?" the safety of the expression depends on the callee's behavior, but the evaluation of the expression doesn't. that gap is where bugs live for years. maybe decades. the callee being careful is what makes the bug invisible. the better the function handles its arguments, the longer the OOB at the call site survives review. that's perverse. the code's own correctness is camouflaging the bug.
when field_count is 16 (NFT_PIPAPO_MAX_FIELDS, the max), rulemap[16].n is real stack OOB. you're reading whatever the kernel left on the stack before your frame. smaller field counts get you uninitialized entries in your own array instead, which is a different flavor of wrong but still wrong. and this isn't some exotic race you trigger with three threads and a prayer. it's the normal path. every element expiration, every deletion. the kernel's own GC walks into it on a timer.
KASAN on 7.0.0-rc2 aarch64 confirmed it: Read of size 4 at addr ffff8000810e71a4. one stack object, [32, 160) 'rulemap', buggy address at offset 164. array is 128 bytes. read is 4 bytes past the end. rulemap[16].n. worked the offset math on paper beforehand.
PoC: pipapo set with NFT_SET_INTERVAL | NFT_SET_CONCAT | NFT_SET_TIMEOUT, 16 concatenated 4-byte fields. insert element, 1-second timeout. wait. insert another to trigger nft_pipapo_commit() → pipapo_gc() → pipapo_drop() → OOB. no heap shaping. no race. the kernel GC walks into it on a schedule.
reported to [email protected]. Willy Tarreau forwarded to netfilter maintainers. Florian Westphal reviewed, confirmed, asked for a readability tweak. the fix:
last ? 0 : rulemap[i + 1].n, last
I think there are more of these in the kernel. any function that takes a flag argument and an expression argument where the flag makes the expression unnecessary. every one of those call sites is a candidate for an OOB or an uninitialized read that no reviewer will catch because the callee's guard clause is too convincing. the code review feedback loop is broken for this pattern. the only reliable way to find them is to stop reading the callee entirely and ask whether every argument is legal to evaluate in the caller's scope, regardless of what the function plans to do with it.
patched in stable 5.10–6.19.
@bl4sty you're not missing anything. the value is dead. pipapo_unmap() checks is_last, returns, to_offset is never read. not exploitable as-is. the writeup is about the pattern, not this bug's impact.
CVE-2026-23456 | CVSS 8.2
patch: https://t.co/5XFQeejJNb
review thread: https://t.co/LFf3jBd9ln
mainline: https://t.co/zfg6LrRGL0
buggy commit: https://t.co/94v0RN3hXi
originally reported by @VidocSecurity (Klaudia Kloc and Dawid Moczadło). I confirmed, reproduced, and patched it.
found a verifier/interpreter mismatch in the Linux BPF subsystem (CVE-2026-31525, CVSS 7.8). arbitrary kernel read/write; become root, escape containers, disable SELinux, read TLS keys out of other processes' memory.
anyway, it starts with the math bars, the absolute value.
computers store negative numbers in two's complement. the smallest 32-bit signed integer is -2,147,483,648, and the largest positive is +2,147,483,647. there is no +2,147,483,648, since it simply does not fit. so when you call abs(-2,147,483,648), the C specification thinks about it for a moment, says "undefined," and leaves the room. on x86 and arm64, what you actually get back is -2,147,483,648. you asked for the absolute value of a negative number, you got back the same negative number. thank you computer :D
the BPF interpreter implements signed 32-bit division (BPF_ALU | BPF_DIV/MOD, off == 1, added in ec0e2da95f72) by decomposing it into unsigned division: take abs() of both operands, divide via do_div(), reapply the sign. the handler in ___bpf_prog_run (kernel/bpf/core.c):
AX = abs((s32)DST);
AX = do_div(AX, abs((s32)SRC));
and look, the kernel even documents this. include/linux/math.h: "the return value is undefined when the input is the minimum value of the type." when DST = 0x80000000 (S32_MIN), abs() tries to negate it. -(-2,147,483,648) overflows s32, the C spec calls it undefined behavior, and the CPU hands back 0x80000000 unchanged. still negative. abs() had one job.
this s32 then gets assigned into AX, a u64 BPF register. s32 → u64 sign-extends: 0x80000000 becomes 0xFFFFFFFF80000000. that's 18,446,744,071,562,067,968. you wanted 2,147,483,648, you got 18.4 quintillion; a rounding error of about 18.4 quintillion. do_div() is a 64-by-32-bit unsigned division macro and it operates on this full u64 numerator. the quotient is off by a factor of 2³². the smod path has the same problem since do_div() modifies the dividend in place and returns the remainder, both wrong. 8 call sites across sdiv32/smod32 src/imm handlers, all quietly producing nonsense whenever S32_MIN shows up.
the BPF verifier is the safety system that statically analyzes every BPF program before allowing it to run. it exists specifically to guarantee that nothing bad can happen. scalar32_min_max_sdiv() in kernel/bpf/verifier.c tracks value ranges through abstract interpretation. it handles signed division correctly, including S32_MIN. computes tight, mathematically correct bounds. the interpreter, as we've established, computes whatever it feels like. so the verifier thinks register R0 is in range X. the interpreter puts value Y in R0. the safety system and the execution engine disagree about what a program does. in BPF security research, this is where you set down your coffee.
concretely: load S32_MIN into R1, load 2 into R2, execute SDIV32 R1 R2. verifier determines R1 ∈ [-1,073,741,824, -1,073,741,824]. interpreter computes do_div(0xFFFFFFFF80000000, 2) = 0x7FFFFFFFC0000000, reapplies the sign, produces a completely unrelated value. use R1 as an index into a BPF map. verifier approves the access, bounds check passes against its calculated range. interpreter uses the actual value. out-of-bounds read/write on a kernel data structure. on every Linux machine running the BPF interpreter.
the root cause of all of this: the absolute value function doesn't handle one number. one specific number, out of 4.2 billion possible inputs, and it's the one that gives you kernel read/write. the fix is:
c
static u32 abs_s32(s32 x)
{
return x >= 0 ? (u32)x : -(u32)x;
}
cast to u32 before negating. -(u32)0x80000000 = 0x80000000 unsigned. correct absolute value, no overflow, no undefined behavior. the kind of function you'd assume already exists somewhere in 30 million lines of kernel code. it did not. I got to write it. :D
I reported this, wrote the patch, got it through 5 revisions of review. acked by Yonghong Song and Mykyta Yatsenko. now patched in stable 6.6, 6.12, 6.18, 6.19. if you haven't updated your kernel: maybe do that.
signal desktop ignores your system proxy, your SOCKS proxy, your TUN mode, your proxychains, your proxifier, and your feelings. it does however check one undocumented env var:
export SIGNAL_ENABLE_HTTP=1