Skip to content

Instantly share code, notes, and snippets.

@secdev02
Last active June 23, 2026 20:59
Show Gist options
  • Select an option

  • Save secdev02/ac6bc3af4d025d816247d10d62183f4e to your computer and use it in GitHub Desktop.

Select an option

Save secdev02/ac6bc3af4d025d816247d10d62183f4e to your computer and use it in GitHub Desktop.
WireGuard - Audit

WireGuard Linux — Length & Offset Vulnerability Audit

Scope: drivers/net/wireguard/ (wireguard-linux-compat, GitHub mirror)
Focus: Integer overflows, attacker-controllable length fields, offset arithmetic bugs
Date: 2026-06-23


Summary

# File Location Severity Status
F1 receive.c decrypt_packet()offset arithmetic Low No underflow in practice; lacks explicit guard
F2 receive.c prepare_skb_header()skb->len - data_offset Low Safe due to check ordering; fragile
F3 receive.c counter_validate()++their_counter after bounds check Informational Safe by design
F4 send.c encrypt_packet()plaintext_len overflow Informational No practical path to overflow
F5 send.c skb_to_sgvec length argument Informational Correct after analysis
F6 netlink.c WGPEER_A_ENDPOINT policy vs memcpy Minor Safe; policy could be tightened
F7 netlink.c WGALLOWEDIP_A_IPADDR policy vs exact check Informational Safe
F8 receive.c IPv6 payload_len + sizeof(ipv6hdr) width Informational Safe on 32/64-bit

No critical or high severity bugs were found. The codebase shows deliberate, careful length handling. The most notable patterns are structural risks — fragile check ordering and missing explicit guards — which are not exploitable in their current form but warrant attention for future refactoring.


Findings


F1 — decrypt_packet(): Missing explicit guard on offset underflow

File: receive.c, function decrypt_packet()
Severity: Low (not exploitable in current kernel path, but lacks a defensive guard)

Code:

offset = skb->data - skb_network_header(skb);
skb_push(skb, offset);
num_frags = skb_cow_data(skb, 0, &trailer);
offset += sizeof(struct message_data);
skb_pull(skb, offset);

Analysis:

offset is computed as a pointer subtraction between two u8 * values. If skb->data < skb_network_header(skb), the result silently wraps to a huge size_t. That value then gets passed to skb_push(skb, offset), which performs:

skb->data -= len;
skb->len  += len;

A wrapped offset here would produce a massively corrupted skb->data pointer and skb->len.

Why it's not exploitable today: By the time decrypt_packet() is called, skb->data points into the WireGuard UDP payload — which is always at or after the IP header, so data >= network_header is guaranteed by the kernel's IP receive path. There is no known way for a remote attacker to invert this relationship via a crafted packet.

Why it still matters: There is no WARN_ON or explicit if (offset > MAX_SANE_OFFSET) return false guard. If this code is ever called from a different entry point, or if skb_reset_network_header is called at an unexpected point upstream, the check silently disappears.

Recommended fix:

if (unlikely(skb->data < skb_network_header(skb)))
    return false;
offset = skb->data - skb_network_header(skb);

F2 — prepare_skb_header(): Fragile unsigned subtraction ordering

File: receive.c, function prepare_skb_header()
Severity: Low (currently safe; correctness depends on check ordering)

Code:

data_len = ntohs(udp->len);            // u16, attacker-controlled
if (unlikely(data_len < sizeof(struct udphdr) ||
             data_len > skb->len - data_offset))   // (*)
    return -EINVAL;

At (*), skb->len - data_offset is an unsigned subtraction. If data_offset > skb->len, this wraps to a large value, making the data_len > comparison silently false — the check would pass with wrong data.

Why it's safe today: The preceding check ensures data_offset + sizeof(struct udphdr) <= skb->len, so data_offset < skb->len is guaranteed before the subtraction is reached. The check ordering saves it.

Risk: This is a classic "safety by ordering" pattern. If a future refactoring reorders the checks or introduces an early-return path between them, the unsigned underflow becomes live.

Recommended fix: Replace the implicit ordering dependency with an explicit precondition:

if (unlikely(skb->len < data_offset + sizeof(struct udphdr)))
    return -EINVAL;
data_len = ntohs(udp->len);
if (unlikely(data_len < sizeof(struct udphdr) ||
             data_len > skb->len - data_offset))
    return -EINVAL;

F3 — counter_validate(): ++their_counter post-bounds-check

File: receive.c, function counter_validate()
Severity: Informational (safe, but worth documenting)

Code:

if (unlikely(/* ... */ their_counter >= REJECT_AFTER_MESSAGES))
    goto out;
++their_counter;

their_counter is u64. REJECT_AFTER_MESSAGES = U64_MAX - COUNTER_WINDOW_SIZE - 1. The bounds check ensures their_counter < REJECT_AFTER_MESSAGES before increment, so the post-increment reaches at most REJECT_AFTER_MESSAGES — which is well below U64_MAX. No overflow.

The subsequent array index computation is also safe:

index = their_counter >> ilog2(BITS_PER_LONG);   // >> 6 on 64-bit
index &= (COUNTER_BITS_TOTAL / BITS_PER_LONG) - 1;  // mask to [0..127]

The backtrack[] array has exactly 128 entries. The mask guarantees in-bounds access.

Note: The ++their_counter before index computation is intentional — it shifts the window so counter=0 maps to index=1, not 0. This is correct RFC 6479 behavior. It reads surprising without a comment.


F4 — encrypt_packet(): plaintext_len lacks overflow guard

File: send.c, function encrypt_packet()
Severity: Informational (no realistic attack path)

Code:

padding_len  = calculate_skb_padding(skb);   // unsigned int, max 15
plaintext_len = skb->len + padding_len;       // unsigned int + unsigned int
trailer_len  = padding_len + noise_encrypted_len(0);  // max 31

skb->len + padding_len can theoretically overflow if skb->len is near UINT_MAX. The kernel enforces per-interface MTU limits that prevent skb->len from reaching anywhere near UINT_MAX in practice. However, there is no explicit check_add_overflow() guard.

Note on calculate_skb_padding: padding_len is bounded to [0, MESSAGE_PADDING_MULTIPLE - 1] = [0, 15] because ALIGN(x, 16) - x ∈ [0, 15] always. This keeps trailer_len to at most 31 bytes, making the auth tag addition safe.


F5 — encrypt_packet(): skb_to_sgvec length argument

File: send.c
Severity: Informational (correct)

skb_to_sgvec(skb, sg,
    sizeof(struct message_data),
    noise_encrypted_len(plaintext_len));

At this point skb->len = sizeof(message_data) + noise_encrypted_len(plaintext_len). The call maps exactly the bytes from offset 16 to the end of the skb — the ciphertext region. Length and offset are consistent. Safe.


F6 — netlink.c: WGPEER_A_ENDPOINT policy is unnecessarily permissive

File: netlink.c
Severity: Minor (defence-in-depth)

Policy:

[WGPEER_A_ENDPOINT] = NLA_POLICY_MIN_LEN(sizeof(struct sockaddr)),
// sizeof(struct sockaddr) = 16 bytes

Usage:

size_t len = nla_len(attrs[WGPEER_A_ENDPOINT]);
if ((len == sizeof(struct sockaddr_in)  && addr->sa_family == AF_INET) ||
    (len == sizeof(struct sockaddr_in6) && addr->sa_family == AF_INET6)) {
    memcpy(&endpoint.addr, addr, len);
}

The policy accepts any NLA payload ≥ 16 bytes. A caller could send a 1024-byte endpoint NLA; the code would silently ignore it (no len == 16 or len == 28 match). No overflow, no crash — the == checks on len prevent any misuse.

However, accepting and silently ignoring oversized attributes wastes parse resources and increases the grammar of valid inputs unnecessarily. An NLA_POLICY_RANGE or NLA_POLICY_EXACT_LEN for both valid sizes would be cleaner, though genetlink doesn't natively support "one of two exact lengths."

Suggested tightening:

// Accept up to the larger valid size; reject anything bigger
[WGPEER_A_ENDPOINT] = NLA_POLICY_MIN_LEN(sizeof(struct sockaddr_in6)),

Then add a nla_len > sizeof(struct sockaddr_in6) reject in set_peer().


F7 — netlink.c: WGALLOWEDIP_A_IPADDR min-len policy with exact check

File: netlink.c
Severity: Informational (safe)

Policy uses NLA_POLICY_MIN_LEN(sizeof(struct in_addr)) = 4 bytes minimum, but set_allowedip() uses exact nla_len() == sizeof(in_addr) and == sizeof(in6_addr) checks before any use of nla_data(). This is correct and safe. The min-len policy serves as a quick early reject for trivially short attributes.


F8 — receive.c: IPv6 payload_len + sizeof(ipv6hdr) addition width

File: receive.c, function wg_packet_consume_data_done()
Severity: Informational (safe)

len = ntohs(ipv6_hdr(skb)->payload_len) + sizeof(struct ipv6hdr);

ntohs() returns u16 (max 65535). sizeof(struct ipv6hdr) = 40. Sum is at most 65575, which fits comfortably in unsigned int. No overflow on 32-bit or 64-bit platforms. The subsequent len > skb->len check prevents any overread. Safe.

Note: This data comes from the decrypted, authenticated inner packet. A remote attacker without a valid session key cannot reach this path.


General Observations

Strengths of the WireGuard codebase:

  • Handshake message types use exact-length (==) comparisons in validate_header_len(), not >=, so there is no over-read from oversized handshake packets.
  • Netlink attributes for fixed-size crypto keys use NLA_POLICY_EXACT_LEN, which is the correct and tightest possible policy.
  • The counter_validate() replay window uses bitmask indexing rather than shift-based indexing, avoiding shift-amount UB.
  • Error paths throughout prepare_skb_header() use early returns, keeping the "safe region" of each subsequent check well-defined.
  • pskb_trim() return values are always checked in decrypt_packet().

Structural concerns to track:

  • Implicit check-ordering dependencies (F2): Two checks must appear in a specific order for the second to be safe. This is correct but non-obvious. A comment or assertion would improve auditability.
  • Missing pointer-comparison guard (F1): The offset = skb->data - skb_network_header(skb) pattern is common in kernel networking code and is generally safe, but an explicit WARN_ON would make the invariant visible.
  • Silent policy mismatches (F6, F7): Using NLA_POLICY_MIN_LEN where a more specific policy is possible means malformed attributes are rejected later (in application logic) rather than earlier (in netlink parsing). This increases the amount of code that needs to handle bad inputs.

Suggested Next Audit Areas

  • noise.cmix_key() and hkdf() length parameters, particularly the first_len/second_len/third_len guards
  • cookie.cwg_cookie_message_create() buffer sizing for the xchacha encrypted cookie
  • allowedips.cpush_rcu() stack depth: the WARN_ON(*len >= 128) in push_rcu suggests a fixed-size stack that could theoretically overflow if trie depth exceeds 128 (maximum trie depth for IPv6 is 128 levels, exactly matching the limit — worth verifying that the stack is always sized to 129+1)

WireGuard ECC & Encryption Deep Audit

Scope: crypto/zinc/curve25519/, crypto/zinc/poly1305/, crypto/zinc/chacha20poly1305.c, noise.c
Focus: Curve parameter injection (CurveBall class), field arithmetic, key validation, AEAD correctness
Date: 2026-06-23


Executive Summary

# File Finding Severity
ECC-1 curve25519.c CurveBall class — NOT applicable (positive finding) N/A
ECC-2 curve25519.c Torsion/low-order input points — caught by output check Informational
ECC-3 noise.c Peer public key stored without upfront validation Low
ECC-4 chacha20poly1305.c In-place decryption before MAC verification (RFC 8439 non-conformance) Low/Medium
ECC-5 chacha20poly1305.c sg_inplace MAC tag pointer: ssize_t + size_t mixing Low
ECC-6 curve25519-hacl64.c fdifference adds 8p: correct but undocumented Informational
ECC-7 noise.c static_private dead variable with memzero_explicit Informational
ECC-8 curve25519-hacl64.c format_fcontract_trim single-pass reduction Informational (correct)
ECC-9 poly1305-donna64.c Poly1305 r-clamping verified correct Informational (correct)

No critical bugs found. The cryptographic primitives are formally verified implementations. The most significant finding is the in-place decrypt-before-MAC pattern (ECC-4), which is architecturally non-standard but safely contained within WireGuard's queue model. The CurveBall attack class is architecturally impossible against this codebase.


Finding ECC-1: CurveBall (CVE-2020-0601) Class — Not Applicable

Verdict: Immune by design.

CVE-2020-0601 exploited Windows CryptoAPI accepting attacker-specified generator points for named ECDSA curves, allowing signature forgery by anyone who could substitute their own G. The attack requires that curve parameters — specifically the base point — be configurable or network-supplied.

WireGuard's architecture eliminates this class entirely.

The basepoint for key generation is a compile-time constant in curve25519.c:

bool curve25519_generate_public(u8 pub[CURVE25519_KEY_SIZE],
                                const u8 secret[CURVE25519_KEY_SIZE])
{
    static const u8 basepoint[CURVE25519_KEY_SIZE] __aligned(32) = { 9 };
    ...
}

This is the RFC 7748 section 6.1 standard u = 9 generator for Curve25519. It cannot be changed at runtime.

The curve arithmetic itself (curve25519-hacl64.c, curve25519-fiat32.c) has all parameters embedded as numeric literals in the field operations. The prime p = 2^255 - 19 appears as:

  • 0x7ffffffffffedLLU — limb 0 of p in 51-bit representation (verified: p & (2^51-1) = 2^51-19)
  • 0x7ffffffffffffLLU — limbs 1 through 4 of p (all equal to 2^51-1)
  • The Montgomery ladder constant scalar = 121665 — this is (A-2)/4 where A = 486662, the Bernstein optimization for Curve25519 doubling

None of these are read from configuration, netlink attributes, or incoming packets. There is no surface for curve parameter injection.


Finding ECC-2: Torsion Point Inputs (Small Subgroup Attack)

Severity: Informational (correctly mitigated)

Curve25519 has cofactor 8. Its torsion subgroup has 8 elements. An attacker could submit one of these low-order u-coordinates as their peer public key:

u = 0
u = 1
u = 325606250916557431795983626356110631294008115727848805560023387167927233504
u = 39382357235489614581723060781553021112529911719440698176882885853963445705823
u = p-1, p, ...

Multiplying any of these by a clamped scalar (a multiple of 8) always yields the identity point, whose u-coordinate is 0.

WireGuard's defence — the null-point return check in curve25519.c:

bool curve25519(u8 mypublic[CURVE25519_KEY_SIZE],
                const u8 secret[CURVE25519_KEY_SIZE],
                const u8 basepoint[CURVE25519_KEY_SIZE])
{
    if (!curve25519_arch(mypublic, secret, basepoint))
        curve25519_generic(mypublic, secret, basepoint);
    return crypto_memneq(mypublic, null_point, CURVE25519_KEY_SIZE);
}

curve25519() returns false if the output is all-zero. mix_dh() in noise.c propagates this correctly:

static bool __must_check mix_dh(...)
{
    if (unlikely(!curve25519(dh_calculation, private, public)))
        return false;  // handshake aborted
    ...
}

The check is on the output, not the input. This is correct per RFC 7748, which explicitly states that checking the output for the all-zero string is the correct mitigation. Checking the input would require expensive point validation that provides no additional security for X25519.

Clamping provides structural defence: curve25519_clamp_secret clears the 3 low bits of the scalar (ensuring a multiple of 8). Since any torsion-group point has order dividing 8, clamp(s) * torsion_point = 8k * torsion_point = identity. The null-point check catches the result.


Finding ECC-3: Peer Public Key Stored Without Immediate Validation

Severity: Low

In noise.c, wg_noise_handshake_init():

void wg_noise_handshake_init(..., const u8 peer_public_key[NOISE_PUBLIC_KEY_LEN], ...)
{
    memset(handshake, 0, sizeof(*handshake));
    memcpy(handshake->remote_static, peer_public_key, NOISE_PUBLIC_KEY_LEN);
    ...
    wg_noise_precompute_static_static(peer);  // validation is deferred here
}

The key is stored first, then validated indirectly by wg_noise_precompute_static_static:

void wg_noise_precompute_static_static(struct wg_peer *peer)
{
    if (!peer->handshake.static_identity->has_identity ||
        !curve25519(peer->handshake.precomputed_static_static,
                    peer->handshake.static_identity->static_private,
                    peer->handshake.remote_static))
        memset(peer->handshake.precomputed_static_static, 0, NOISE_PUBLIC_KEY_LEN);
}

If the public key is a torsion point (DH output = 0), curve25519() returns false and precomputed_static_static is zeroed. Then during the handshake, mix_precomputed_dh() rejects it:

static bool __must_check mix_precomputed_dh(...)
{
    static u8 zero_point[NOISE_PUBLIC_KEY_LEN];
    if (unlikely(!crypto_memneq(precomputed, zero_point, NOISE_PUBLIC_KEY_LEN)))
        return false;
    ...
}

The chain is correct but indirect. The raw bytes of any 32-byte value can be stored in remote_static — including keys that produce a zero DH output only with specific private keys. Rejection is deferred to precompute_static_static (peer creation) and mix_precomputed_dh (handshake time).

Timing concern: wg_noise_precompute_static_static is also called after local key rotation. During the window between storing the new peer key and the precompute completing, remote_static holds the new (unvalidated) key while precomputed_static_static may still hold a stale value from the previous computation.

Recommended pattern: Validate the DH output at the netlink layer before accepting a new peer key, and return an error to userspace if it produces zero.


Finding ECC-4: In-Place Decryption Before MAC Verification

Severity: Low/Medium — RFC 8439 non-conformant; contained by WireGuard's queue model

RFC 8439 section 2.8 states: receivers MUST verify the Poly1305 tag before acting on any decrypted data. The rationale is that an attacker who can submit chosen ciphertexts and observe partial decryption results can, in some contexts, extract key material.

WireGuard's chacha20poly1305_decrypt_sg_inplace() does the opposite:

sg_miter_start(&miter, src, sg_nents(src), SG_MITER_TO_SG | SG_MITER_ATOMIC);
for (sl = src_len; sl > 0 && sg_miter_next(&miter); sl -= miter.length) {
    u8 *addr = miter.addr;
    size_t length = min_t(size_t, sl, miter.length);

    poly1305_update(&poly1305_state, addr, length, ...);   // 1. MAC over ciphertext

    // 2. Decrypt IN PLACE — overwrites buffer before MAC is checked
    chacha20(&chacha20_state, addr, addr, l, simd_context);
    ...
}
// 3. MAC only checked AFTER all decryption has already occurred
poly1305_final(&poly1305_state, b.computed_mac, simd_context);
ret = !crypto_memneq(b.computed_mac, ...);

Plaintext is written to the skb's backing memory on every loop iteration, before poly1305_final confirms the tag is valid.

Why WireGuard's model contains this:

The skb follows this path after decryption:

  1. decrypt_packet() decrypts in place, returns true/false
  2. wg_packet_decrypt_worker() sets PACKET_STATE_CRYPTED (success) or PACKET_STATE_DEAD (failure) via atomic_set_release()
  3. wg_packet_rx_poll() reads the state with atomic_read_acquire() — a full acquire barrier
  4. Only PACKET_STATE_CRYPTED packets reach wg_packet_consume_data_done() and the networking stack

The acquire/release pair provides happens-before ordering: any thread observing PACKET_STATE_CRYPTED is guaranteed to see the completed, authenticated decryption. No unauthenticated plaintext escapes to userspace.

Residual risk: The plaintext bytes live in skb->data before authentication completes. If a kernel panic, debugging facility (e.g., kcore, KGDB), or future code path reads skb->data in that window, it would observe unauthenticated plaintext. Not a current exploit path, but a robustness concern.

Why the design is this way: The encrypt variant must write ciphertext in a single pass for performance. The decrypt variant mirrors the structure. A separate scratch buffer would eliminate the issue but requires an extra allocation per packet — unacceptable for kernel networking at line rate.


Finding ECC-5: sg_inplace MAC Tag Pointer — ssize_t + size_t Mixing

Severity: Low (no underflow in practice; type mixing is unsafe-looking)

In the fast path of chacha20poly1305_decrypt_sg_inplace():

// sl is ssize_t (signed), miter.length is size_t (unsigned)
if (likely(sl <= -POLY1305_MAC_SIZE)) {
    poly1305_final(&poly1305_state, b.computed_mac, simd_context);
    ret = !crypto_memneq(b.computed_mac,
                         miter.addr + miter.length + sl,   // mixed arithmetic
                         POLY1305_MAC_SIZE);
}

When sl is negative and miter.length + sl is evaluated with implicit unsigned conversion, if |sl| > miter.length the result wraps to a huge positive number. That would send the pointer far outside the buffer.

Why it does not wrap in practice: The condition sl <= -POLY1305_MAC_SIZE means the loop consumed all ciphertext AND the current segment extended at least 16 bytes past the ciphertext end into the auth tag. Therefore miter.length >= |sl| >= 16, so miter.length + sl >= 0. No underflow.

The guarantee is implicit in the loop invariant, not in the type system.

Safer form:

u8 *tag_ptr = miter.addr + miter.length - (size_t)(-(sl));
ret = !crypto_memneq(b.computed_mac, tag_ptr, POLY1305_MAC_SIZE);

Finding ECC-6: fdifference Adds 8p, Not 2p (Undocumented)

Severity: Informational (correct, but undocumented)

In curve25519-hacl64.c, fdifference() computes b - a by first adding a large multiple of the prime to b:

tmp[0] = b0 + 0x3fffffffffff68LLU;
tmp[1] = b1 + 0x3ffffffffffff8LLU;
...
a[i] = tmp[i] - a[i];  // = (b + correction) - a

The correction constant is exactly 8p (verified: 8 * (2^255 - 19) reconstructed from the 51-bit limbs matches). The reason for 8p rather than the intuitively expected 2p is that intermediate 51-bit limbs in the HACL* representation can carry slightly beyond their nominal bounds after fsum and fmul, requiring a larger correction to guarantee non-negative results.

No bug here, but the comment is absent. An auditor who attempts to verify this by computing 2p or 4p will fail to match, waste significant time, or incorrectly flag it. A comment is needed:

/* Add 8p before subtracting a to ensure a non-negative result.
 * 8p in 51-bit limb form: [0x3fffffffffff68, 0x3ffffffffffff8, ...] */

Finding ECC-7: Dead Variable static_private in consume_response

Severity: Informational (misleading to auditors)

In wg_noise_handshake_consume_response():

u8 static_private[NOISE_PUBLIC_KEY_LEN];   // declared, never written
// ... (never assigned) ...
memzero_explicit(static_private, NOISE_PUBLIC_KEY_LEN);   // zeroes garbage stack bytes

static_private is never populated. The memzero_explicit zeroes uninitialized stack memory. This is a refactor artifact — an earlier version of the responder path used a local copy of the static private key for a se DH step that was later replaced by the precomputed value path.

Security impact: zero. Auditor impact: a reviewer seeing memzero_explicit will assume the variable held a live private key and look for where it was populated. That time is wasted. The variable and its cleanup call should be removed.


Finding ECC-8: format_fcontract_trim Single-Pass Reduction (Verified Correct)

Severity: Informational

format_fcontract_trim performs a single conditional subtraction of p to canonicalize the output:

u64 mask0 = u64_gte_mask(a0, 0x7ffffffffffedLLU);   // a[0] >= p[0]?
u64 mask1 = u64_eq_mask(a1, 0x7ffffffffffffLLU);    // a[1] == p[1]?
...
u64 mask = mask0 & mask1 & mask2 & mask3 & mask4;   // value >= p?
// subtract p once, conditionally

One reduction is sufficient: the two carry passes (format_fcontract_first_carry_full and format_fcontract_second_carry_full) fold the top bit back via modulo_carry_top (multiply by 19), leaving the value in [0, 2^255). Since p = 2^255 - 19, this range is [0, p + 18]. One subtraction of p reduces to [0, p-1]. Correct.

The constant-time comparisons u64_gte_mask and u64_eq_mask were both verified. u64_gte_mask(a, b) uses the standard carry-bit extraction trick: (a ^ q) >> 63 - 1 where q encodes whether borrow occurred. No branches. Correct.


Finding ECC-9: Poly1305 r-Clamping Verified Correct

Severity: Informational

poly1305-donna64.c clamps the Poly1305 accumulation key r at initialization per RFC 8439 section 2.5:

st->r[0] = t0 & 0xffc0fffffffULL;
st->r[1] = ((t0 >> 44) | (t1 << 20)) & 0xfffffc0ffffULL;
st->r[2] = ((t1 >> 24)) & 0x00ffffffc0fULL;

The key is represented as three 44-bit limbs. The masks clear the bits that RFC 8439 requires to be zero. Verified against the RFC table. Correct.


Constant-Time Analysis

Operation Mechanism Status
Montgomery ladder bit swap XOR: x = swap & (ai ^ bi) Constant-time
Field element comparison (u64_eq_mask, u64_gte_mask) Arithmetic, no branches Constant-time
Poly1305 tag comparison crypto_memneq (kernel) Constant-time
Field multiplication (hacl64) __uint128_t wide multiplies, no data-dependent branches Constant-time
Canonical reduction (format_fcontract_trim) Masked conditional subtract Constant-time
Scalar clamping Bitwise AND/OR Constant-time
Ladder iteration count Fixed 256 iterations (32 bytes x 8 bits, 4 steps/bit) Constant-time

Not audited here: curve25519-x86_64.c and curve25519-arm.S — these require separate assembly-level review for data-dependent branches or cache-timing patterns.


Key Call Chain

netlink: set_peer()
  └─ wg_peer_create() → wg_noise_handshake_init()
       └─ wg_noise_precompute_static_static()
            └─ curve25519(ss, local_priv, remote_pub)    [validates output only]

noise: consume_initiation()
  ├─ message_ephemeral(e, src->unencrypted_ephemeral)    [no explicit point check on e]
  ├─ mix_dh(ck, key, local_priv, e)                     [curve25519(); zero output check]
  ├─ message_decrypt(s, src->encrypted_static)           [AEAD authenticated]
  ├─ mix_precomputed_dh(ck, key, precomputed_ss)         [zero-check on precomputed]
  └─ message_decrypt(t, src->encrypted_timestamp)        [AEAD authenticated]

receive: wg_packet_decrypt_worker()
  └─ decrypt_packet()
       └─ chacha20poly1305_decrypt_sg_inplace()          [decrypt THEN verify — ECC-4]

Suggested Next Audit Areas

  • curve25519-x86_64.c / curve25519-arm.S — assembly paths for timing side-channels and branch-on-secret-bit patterns
  • blake2s.c — HKDF parameter size arithmetic in kdf(), particularly the first_len/second_len/third_len bounds checked by WARN_ON
  • cookie.cxchacha20poly1305_encrypt nonce derivation; confirm the XChaCha HChaCha20 subkey extraction is correct and the 192-bit nonce provides adequate birthday-bound security
  • peerlookup.cwg_pubkey_hashtable_lookup() timing: lookup time proportional to peer count could leak peer existence via timing oracle
/*
* PoC: decrypt_packet() missing offset underflow guard
* WireGuard receive.c — security audit finding F1
*
* This userspace simulation reproduces the arithmetic pattern from
* decrypt_packet() to show engineers what happens when skb->data
* ends up behind skb_network_header(skb) without an explicit guard.
*
* ACTUAL WIREGUARD CODE (receive.c):
*
* offset = skb->data - skb_network_header(skb);
* skb_push(skb, offset); // skb->data -= offset; skb->len += offset;
* num_frags = skb_cow_data(skb, 0, &trailer);
* offset += sizeof(struct message_data);
* skb_pull(skb, offset); // skb->data += offset; skb->len -= offset;
* ...
* skb_push(skb, offset); // ← called again post-decrypt
* pskb_trim(skb, skb->len - noise_encrypted_len(0));
* skb_pull(skb, offset);
*
* The bug: no check that skb->data >= skb_network_header(skb) before subtraction.
*
* Build: gcc -Wall -Wextra -o poc poc_offset_underflow.c
* Run: ./poc
*/
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <assert.h>
#include <stddef.h>
/* ── Minimal kernel type aliases ─────────────────────────────────────── */
typedef uint8_t u8;
typedef uint32_t u32;
typedef uint64_t u64;
typedef size_t size_type;
#define U16_MAX 0xFFFF
#define UINT_MAX 0xFFFFFFFF
/* ── Simulated skb ───────────────────────────────────────────────────── */
#define BACKING_BUF_SIZE 4096
typedef struct {
u8 *head; /* start of backing buffer */
u8 *data; /* current data pointer */
u32 len; /* logical length from data */
u32 network_header;/* offset from head → IP header */
} fake_skb;
/* skb_network_header equivalent */
static inline u8 *skb_net_hdr(const fake_skb *skb)
{
return skb->head + skb->network_header;
}
/* skb_push equivalent — moves data pointer backwards */
static void skb_push(fake_skb *skb, size_type len)
{
skb->data -= len;
skb->len += (u32)len;
}
/* skb_pull equivalent — moves data pointer forwards */
static void skb_pull(fake_skb *skb, size_type len)
{
skb->data += len;
skb->len -= (u32)len;
}
/* ── Constants mirroring wireguard messages.h ────────────────────────── */
#define NOISE_AUTHTAG_LEN 16
#define MESSAGE_DATA_HDR_LEN 16 /* sizeof(struct message_data) */
/* ── Helpers ─────────────────────────────────────────────────────────── */
static void print_skb(const char *label, const fake_skb *skb, const u8 *backing)
{
ptrdiff_t data_off = skb->data - backing;
ptrdiff_t net_off = (ptrdiff_t)skb->network_header;
printf(" %-40s data_offset=%+4td net_header_offset=%+4td len=%u\n",
label, data_off, net_off, skb->len);
}
static void separator(void) { puts("\n" "─────────────────────────────────────────────────────────────"); }
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* CASE 1 — Normal path (safe): data > network_header
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
static void case_normal(void)
{
puts("\n[CASE 1] Normal path — skb->data correctly sits after network header\n");
u8 backing[BACKING_BUF_SIZE];
memset(backing, 0xAB, sizeof(backing));
fake_skb skb;
skb.head = backing;
skb.network_header = 20; /* IP header lives at offset 20 from head */
skb.data = backing + 20 + 8 + 16; /* data → past IP(20)+UDP(8)+WG_hdr(16) */
skb.len = 64; /* 64 bytes of ciphertext remain */
print_skb("initial state", &skb, backing);
/* ── Reproduce decrypt_packet() arithmetic ─────────────────────── */
size_type offset = (size_type)(skb.data - skb_net_hdr(&skb));
printf(" offset = skb->data - skb_network_header = %zu\n", offset);
skb_push(&skb, offset);
print_skb("after skb_push(offset)", &skb, backing);
offset += MESSAGE_DATA_HDR_LEN;
skb_pull(&skb, offset);
print_skb("after skb_pull(offset + msg_hdr)", &skb, backing);
printf("\n [OK] data pointer landed at backing+%td — sane.\n",
skb.data - backing);
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* CASE 2 — Inverted pointers (the bug): data < network_header
* Demonstrates the silent wrap to ~0 and its consequences.
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
static void case_inverted_no_guard(void)
{
puts("\n[CASE 2] Inverted pointers — NO guard — demonstrating the underflow\n");
u8 backing[BACKING_BUF_SIZE];
memset(backing, 0xAB, sizeof(backing));
fake_skb skb;
skb.head = backing;
skb.network_header = 200; /* network header at byte 200 */
skb.data = backing + 50; /* data is BEHIND the network header! */
skb.len = 64;
print_skb("initial state (data < net_hdr)", &skb, backing);
/* ── Exact arithmetic from decrypt_packet() — no guard ─────────── */
size_type offset = (size_type)(skb.data - skb_net_hdr(&skb));
/*
* skb->data = backing + 50
* skb_network_header = backing + 200
* difference = -150
*
* In C, (u8*) - (u8*) is ptrdiff_t, but WireGuard stores the result
* in an `unsigned int`. The signed negative wraps to SIZE_MAX - 149
* on a 64-bit platform (or UINT_MAX - 149 on 32-bit).
*/
printf(" offset (raw size_t) = 0x%016zx\n", offset);
printf(" offset as signed = %td (negative = underflow!)\n", (ptrdiff_t)offset);
/*
* skb_push(skb, offset) does:
* skb->data -= offset; // wraps data pointer into unmapped memory
* skb->len += offset; // len becomes ~0 (4GB+ on 32-bit)
*
* We cannot safely call skb_push with a wrapped offset, so we just
* show the arithmetic.
*/
u8 *corrupted_data = skb.data - offset; /* would be done by skb_push */
u32 corrupted_len = skb.len + (u32)offset;
printf("\n [BUG] After skb_push(offset):\n");
printf(" skb->data would be: %p (was %p, delta = %+td)\n",
(void *)corrupted_data, (void *)skb.data,
(ptrdiff_t)(corrupted_data - skb.data));
printf(" skb->len would be: 0x%08x (%u) ← near UINT_MAX\n",
corrupted_len, corrupted_len);
printf("\n [!] skb->data now points outside the backing buffer.\n");
printf(" skb_cow_data(), skb_to_sgvec(), and the AEAD call\n");
printf(" would operate on kernel memory they do not own.\n");
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* CASE 3 — Same inverted pointers, WITH the one-line guard
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
static void case_inverted_with_guard(void)
{
puts("\n[CASE 3] Same inverted pointers — WITH proposed guard — correctly rejected\n");
u8 backing[BACKING_BUF_SIZE];
memset(backing, 0xAB, sizeof(backing));
fake_skb skb;
skb.head = backing;
skb.network_header = 200;
skb.data = backing + 50;
skb.len = 64;
print_skb("initial state (data < net_hdr)", &skb, backing);
/* ── Proposed one-line fix ──────────────────────────────────────── */
if (skb.data < skb_net_hdr(&skb)) {
printf("\n [GUARD] skb->data < skb_network_header — returning false.\n");
printf(" Packet is silently dropped. No memory corruption.\n");
return;
}
/* unreachable with these inputs */
size_type offset = (size_type)(skb.data - skb_net_hdr(&skb));
skb_push(&skb, offset);
(void)offset;
}
/* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* Entrypoint
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ */
int main(void)
{
puts("════════════════════════════════════════════════════════════════");
puts(" WireGuard decrypt_packet() offset underflow — PoC");
puts(" Audit finding F1 — receive.c");
puts("════════════════════════════════════════════════════════════════");
case_normal(); separator();
case_inverted_no_guard();separator();
case_inverted_with_guard();
separator();
puts("\nSUMMARY");
puts(" Without the guard, a skb whose data pointer sits behind the");
puts(" network header causes a size_t underflow. The wrapped offset");
puts(" is then passed to skb_push(), producing:");
puts(" - skb->data pointing ~4GB before the buffer");
puts(" - skb->len inflated to near UINT_MAX");
puts(" All subsequent skb operations (skb_cow_data, skb_to_sgvec,");
puts(" the Poly1305 AEAD) would work on out-of-bounds kernel memory.");
puts("");
puts(" PROPOSED FIX (one line before the subtraction):");
puts(" if (unlikely(skb->data < skb_network_header(skb)))");
puts(" return false;");
puts("");
puts(" The current kernel receive path guarantees data >= net_hdr,");
puts(" so this is NOT exploitable today. The guard makes the invariant");
puts(" explicit and hardens against future refactoring regressions.");
return 0;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment