Skip to content

nstun/tcp: validate guest ACK numbers against the send window#280

Open
g0w6y wants to merge 1 commit into
google:masterfrom
g0w6y:nstun_tcp_ack_window_validation
Open

nstun/tcp: validate guest ACK numbers against the send window#280
g0w6y wants to merge 1 commit into
google:masterfrom
g0w6y:nstun_tcp_ack_window_validation

Conversation

@g0w6y

@g0w6y g0w6y commented Jul 15, 2026

Copy link
Copy Markdown

Summary

nstun treats packets coming from the jailed process as untrusted and validates
them at the IP and L4 layers. The TCP acknowledgement path is missing one of
those checks: tcp_process_data() accepts any forward ACK without verifying it
stays within the send window, which lets the guest acknowledge data that was
never sent and drive an out of bounds heap read in push_to_guest().

Root cause

In nstun/tcp.cc, the ACK is processed as:

int32_t acked_bytes = ack - flow->ack_from_guest;
if (acked_bytes > 0) {
    flow->ack_from_guest = ack;
    ...
    size_t advance = (acked_bytes > 0) ? (size_t)acked_bytes : 0;
    flow->tx_acked_offset += advance;

    size_t erase_len = flow->tx_acked_offset;
    if (erase_len > flow->tx_buffer.size())
        erase_len = flow->tx_buffer.size();
    if (erase_len > 65536 || erase_len == flow->tx_buffer.size()) {
        flow->tx_buffer.erase(flow->tx_buffer.begin(), flow->tx_buffer.begin() + erase_len);
        flow->tx_acked_offset -= erase_len;
    }
}

ack is fully controlled by the guest. The code checks that the ACK moves
forward (acked_bytes > 0) but never that it stays at or below what was
actually sent (SEG.ACK <= SND.NXT in RFC 793 terms). Each accepted ACK
advances tx_acked_offset by up to 2^31 - 1. While tx_buffer is empty the
erase step is a no op, so a few pure ACK segments push tx_acked_offset past
2^32.

The forwarding path then trusts the invariant tx_acked_offset <= tx_buffer.size():

int32_t available = flow->tx_buffer.size() - flow->tx_acked_offset;
...
if (in_flight >= available) return;
const uint8_t* data = flow->tx_buffer.data() + flow->tx_acked_offset + in_flight;
tcp_send_packet(ctx, flow, flags, data, to_send);

available is an int32_t truncation of a size_t subtraction, so an
oversized tx_acked_offset wraps it back to a positive value and the
in_flight >= available guard passes. data is then formed from the full
64 bit tx_acked_offset and points at least 2 GB past the buffer, and
tcp_send_packet() reads up to NSTUN_MTU bytes from it.

Impact

The nstun network loop runs in the nsjail parent (supervisor) process
(initParent -> nstun_init_parent), and SIGSEGV is not handled. A jailed
process can therefore crash its own supervisor by opening a TCP flow and
sending a few crafted ACK segments. The minimum out of bounds distance is 2 GB,
so under ASLR the access reliably faults, which makes this a denial of service
of the supervisor rather than a stable disclosure primitive. The only write
that uses the offset (the erase above) is clamped, so there is no out of
bounds write.

Fix

Add the missing RFC 793 receive check so an ACK beyond seq_to_guest is
rejected. This keeps tx_acked_offset within tx_buffer.size(), which the
framing math in push_to_guest() depends on. The change is confined to the
single ACK acceptance condition; duplicate ACK handling and the FIN paths are
unaffected, and the same tcp_process_data() function serves both IPv4 and
IPv6, so both are covered.

Testing

The accounting and the pointer arithmetic were reproduced in isolation using
the exact variable types from the code. Before the change, three pure ACK
segments drive tx_acked_offset to 0x100000000; a subsequent 512 byte host
write makes available wrap to a positive 512, the guard passes, and the
read pointer lands 0xFFFFFE00 bytes past a 512 byte buffer. After the change
the out of window ACKs are rejected, tx_acked_offset stays 0, and the read
pointer remains inside the buffer. A search over the reachable offsets confirms
the minimum out of bounds distance is 0x80000001 bytes.

The guest is untrusted and fully controls the acknowledgement number of
every TCP segment it emits. tcp_process_data() accepted any forward ACK
(acked_bytes > 0) without checking that it stayed within the send window,
so the guest could acknowledge data that was never sent.

Each such ACK advances tx_acked_offset by up to 2^31-1. When tx_buffer is
empty the erase step below is a no-op, so tx_acked_offset accumulates and
can be driven past 2^32 with a few pure ACK segments. push_to_guest() then
computes:

    int32_t available = tx_buffer.size() - tx_acked_offset;
    const uint8_t* data = tx_buffer.data() + tx_acked_offset + in_flight;

available is an int32_t truncation of a size_t subtraction, so an oversized
tx_acked_offset wraps it back positive and defeats the in_flight >= available
guard. data then points at least 2 GB past the heap buffer and
tcp_send_packet() reads up to NSTUN_MTU bytes from it.

The nstun network loop runs in the nsjail parent (supervisor) process, and
SIGSEGV is not handled, so the out of bounds read lets an untrusted jailed
process crash its own supervisor. The minimum out of bounds distance is 2 GB,
so under ASLR the access reliably faults rather than returning data.

Add the RFC 793 receive check (SEG.ACK <= SND.NXT): reject ACKs whose
sequence is beyond seq_to_guest. This keeps tx_acked_offset within
tx_buffer.size(), which the framing in push_to_guest() relies on.
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