Fix memory-safety and hang bugs in native packet engine - #751
Open
kasnder wants to merge 1 commit into
Open
Conversation
All found in an audit of the C engine and inherited from the NetGuard
import unless noted:
- dhcp.c: the DHCP reply options were written ~57KB past the 500-byte
response allocation ("response + sizeof(struct dhcp_packet)" advances
in struct-sized units). Reachable by any co-installed app with a single
UDP datagram to port 67 -- no special permission needed. This is the
headline fix.
- ip.c, tcp.c: validate the TCP data offset before deriving the payload
pointer/length from it. A doff below 5 (or option bytes past the
segment) previously wrapped datalen to ~65K; the read this enabled was
out of bounds past the end of the get_mtu() tun read buffer, not an
overflow of the (already length-checked) SNI reassembly buffer. Only
reachable via a packet the kernel writes into the tun for a local app,
so it needs CAP_NET_RAW/root -- defensive hardening, not exploitable
today.
- ip.c: the IPv4 header-length underflow this doff fix mirrors --
"(ihl - 5) * 4" wraps to 236 when ihl == 0, and the length checks
further down are size_t so they wrap too and pass -- gets the same
ihl < 5 guard next to the existing tot_len check. Same reachability
caveat as above.
- tcp.c: a SYN option with length 0 could spin the event-loop thread
forever; option walking now always progresses and stays inside the
declared options region. (optlen is signed, so an over-long option
already exited via the negative-length check; only the len == 0 case
actually hung.) Same reachability caveat as above.
- icmp.c: ICMPv6 echo replies computed a bogus pseudo-header length
(bytes - sizeof(ip6_hdr)) in host order; use htonl(bytes), matching
the query path.
- pcap.c: rolling over the PCAP file skipped fflush before ftruncate,
and re-seeked with lseek(fileno(...)) afterwards, which never syncs
the FILE*'s own buffered offset -- ftell() kept reporting the
pre-rollover size and the file re-truncated on every later write.
fflush before ftruncate, then fseek() (not lseek on the fd) on the
FILE* itself.
- Hardening: vsnprintf for log/exit formatting, bounded SOCKS5 string
copies from Java, seeded rand() for ISNs.
Not included: a walk of IPv6 extension headers was dropped from this
change after review found it unsafe (wrong RFC 8200 unit, ESP/AH have no
walkable next-header field, and non-first fragments would be misparsed
as L4 data) -- tracked separately for a correct implementation.
No behavior change for well-formed packets: every new check only rejects
input that was previously misparsed or impossible.
kasnder
force-pushed
the
agent/native-c-safety-fixes
branch
from
August 22, 2026 09:59
32bb744 to
7c07b0f
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found during a correctness audit of
app/src/main/jni/netguard/(all inherited from the NetGuard import unless noted). 50 insertions, 18 deletions across 7 C files; no behavior change for well-formed packets.Memory safety
(uint8_t *)(response + sizeof(struct dhcp_packet))advances in struct-sized units (~240 B each). Reachable by any co-installed app with a single UDP datagram to port 67 (sport 68 / dport 67 →check_dhcp) — no special permission needed. This is the headline fix in this PR.doff) was never validated before deriving payload pointer/length from it: doff < 5 underflowstcpoptlento e.g. 236, and an oversized doff wraps the derived length. Both SNI memcpys into the reassembly buffer were already bounded (datalen < TLS_SNI_MAX_BUFFER,copy = min(datalen, space)), so this was never a 65 KB write into the 16 KB buffer. The actual defect is an out-of-bounds read: the bad offset letsdata/datalenpoint past the end of theget_mtu()-sized tun read buffer, soparse_tls_header(and, intcp.c, the queued bytes) can walk off the end of that buffer. Both sites now reject impossible data offsets.ihl:(uint8_t)((ihl - 5) * 4)underflows to 236 whenihl == 0, pushingpayloadpast the packet; the downstream length checks aresize_tso they wrap too and pass. Now rejected next to the existingtot_lencheck.Hangs / correctness
optlenisint, so an over-long option already madelen > optlengo negative and exit the loop on its own — the hang was real only forlen == 0.) Loop always terminates now and stays inside the declared options region.bytes - sizeof(ip6_hdr)(underflow, host order); nowhtonl(bytes), matching the query path.FILE*and truncated the underlying fd, then re-seeked withlseek(fileno(pcap_file), ...), which never syncs theFILE*'s own buffered offset —ftell()kept reporting the pre-rollover size and the file re-truncated on every later write. Nowfseek(pcap_file, ...)on theFILE*itself.Hardening
vsnprintfinstead ofvsprintfinto fixed 1024-byte stack buffers (log/exit formatting).rand()once at init so TCP ISNs aren't identical across boots.Reachability
Other than the DHCP heap overflow above, everything in this PR is only reachable from packets the kernel writes into the tun device on behalf of a local app (a malformed
doff, TCP option, orihlhas to come from a real socket send). That needsCAP_NET_RAW/root to forge directly, so these are defensive hardening against a corrupted/malicious kernel path or future refactors, not exploitable by an unprivileged co-installed app today. The DHCP overflow is the one item here that is reachable right now, by any co-installed app, with a single UDP datagram — treat it as the headline fix.Verification
-fsyntax-onlyover all engine TUs: clean (only pre-existing const-qualifier warnings)../gradlew assembleGithubDebug(native C + Rust + Kotlin): BUILD SUCCESSFUL.git diff --check: clean.Known follow-ups (deliberately not in this PR)
handle_events()run outsidectx->lockwhilejni_get_stats()iterates under it (narrow use-after-free window) — needs a small locking design, not a drive-by fix.