Skip to content

user-src code review: missing FD_CLOEXEC + minor cleanup items #23

@cpsource

Description

@cpsource

Code review of user-src/ — findings for discussion

Reviewed the full contents of user-src/ on current master, with the
code-review-cpsource skill's 20-section C checklist. Five novel
findings plus one finding that's already being addressed by open
PR #20. Summary table first, then each finding, then a self-review
correction where PR #20 caught something I didn't.

Upstream status before reviewing

Queried gh on 2026-04-23:

  • 22 PRs total (6 open, 15 merged, 1 closed); 0 issues.
  • PR #20 (open,
    @MarkAtwood) already addresses one of the findings (netlink
    key-length validation).
  • Keyword searches for CLOEXEC, SOCK_CLOEXEC, fopen, memzero,
    base64, fork exec, fd leak, parse_public_key returned no
    relevant PRs or issues. The other five findings below appear novel.

Findings

# Severity File(s) Summary Status
1 Major config.c, setconf.c, ipc-uapi-unix.h, netlink.h, ipc-openbsd.h Missing O_CLOEXEC/SOCK_CLOEXEC on fd-creating calls Novel
2 Major ipc-linux.h No length validation on netlink-returned key attrs Covered by PR #20
3 Minor pubkey.c '\0' termination at wrong buffer offset Novel
4 Minor encoding.c wg_from_base64 silently accepts trailing non-aligned bytes Novel
5 Minor config.c parse_public_key uses memset, siblings use memzero_explicit Novel
6 Minor genkey.c, pubkey.c, others Negative wolfCrypt error code returned as CLI exit status Novel

Finding 1 — Major: missing O_CLOEXEC / SOCK_CLOEXEC on fd-creating calls

Same bug class as wolfSSL issue #9753
(where PR #10162 is
the upstream fix). In multithreaded hosts or library-embedding callers
that fork()+exec(), these descriptors leak to children. Some carry
private-key material.

File Line Call
config.c 145 f = fopen(path, "r"); — private/preshared key file
setconf.c 124 config_input = fopen(argv[2], "r"); — full config
ipc-uapi-unix.h 44, 83 socket(AF_UNIX, SOCK_STREAM, 0) — UAPI socket
netlink.h 505 socket(AF_NETLINK, SOCK_RAW | flags, bus)flags==0 at every mnl_socket_open caller
ipc-openbsd.h 28 socket(AF_INET, SOCK_DGRAM, 0) — OpenBSD ioctl socket

netlink.h:505 is the high-leverage site: every mnlg_socket_open() in
ipc-linux.h (kernel_get_*, kernel_set_*, kernel_generate_privkey,
kernel_derive_pubkey, kernel_generate_psk) routes through it with
flags=0. One edit at the mnl_socket_open(bus) wrapper (or the inner
__mnl_socket_open) covers all five kernel-ops sites.

Fix sketch:

/* config.c:145, setconf.c:124 */
- f = fopen(path, "r");
+ f = fopen(path, "re");   /* "e" = O_CLOEXEC, glibc/Linux/BSD */

/* ipc-uapi-unix.h:44, 83 */
- fd = socket(AF_UNIX, SOCK_STREAM, 0);
+ fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);

/* netlink.h:514 */
- return __mnl_socket_open(bus, 0);
+ return __mnl_socket_open(bus, SOCK_CLOEXEC);

/* ipc-openbsd.h:28 — same SOCK_CLOEXEC bit-or */

Regression test pattern: snapshot /proc/self/fd before/after each
call, assert fcntl(fd, F_GETFD) & FD_CLOEXEC. Model from wolfSSL
issue #9753 reproducer.

Finding 2 — superseded by PR #20

ipc-linux.h's parse_privkey, parse_pubkey, parse_psk
previously used mnl_attr_get_payload_len(attr) as both the allocation
size and the copy length without checking against WG_*_KEY_LEN. PR
#20 adds exactly the symbolic size check I would have recommended. No
further action needed from this review.

Finding 3 — Minor: '\0' termination at wrong buffer offset

pubkey.c:39 and pubkey.c:127:

char privkey_base64[WG_BASE64_LEN(WG_KEY_LEN_MAX)];   /* 89 bytes */
...
fread(privkey_base64, 1, WG_BASE64_LEN(WG_PRIVATE_KEY_LEN) - 1, stdin);
                        /* writes 43 bytes */
privkey_base64[sizeof(privkey_base64) - 1] = '\0';   /* writes at offset 88 */

sizeof(privkey_base64) is WG_BASE64_LEN(WG_KEY_LEN_MAX) = 89, but
the read length is WG_BASE64_LEN(WG_PRIVATE_KEY_LEN) - 1 = 43.
Setting privkey_base64[88] = '\0' leaves indices 43..87 as
uninitialised stack memory.

Not a current bug (downstream uses explicit length), but misleading
and hazardous for future strlen/printf additions. Either delete
the line or write at the right offset:

- privkey_base64[sizeof(privkey_base64) - 1] = '\0';
+ privkey_base64[WG_BASE64_LEN(WG_PRIVATE_KEY_LEN) - 1] = '\0';

Finding 4 — Minor: wg_from_base64 ignores trailing non-aligned bytes

encoding.c:154, 198:

while (inLen > 3) {
    /* consume 4 chars */
    inLen -= 4;
}
return (outLen == i);

If the caller passes inLen % 4 != 0, the remaining 1-3 bytes are
silently discarded. Return success is keyed only on the produced
output matching outLen. Input "AAAA" + "x" decodes to 3 bytes and
returns true for outLen == 3 — the trailing "x" is accepted.

Non-critical in normal config-file use (the tokenizer strips
whitespace first) but a fuzz-surface gap. Adding an
if (inLen != 0 && inLen != 4) return false; tail check — or
rejecting non-multiple-of-4 up front — closes it.

Finding 5 — Minor: parse_public_key uses memset, siblings use memzero_explicit

config.c:122 vs config.c:112, 132:

/* parse_public_key */           memset(key, 0, WG_PUBLIC_KEY_LEN);
/* parse_private_key */    memzero_explicit(key, WG_PRIVATE_KEY_LEN);
/* parse_preshared_key */  memzero_explicit(key, WG_SYMMETRIC_KEY_LEN);

Public keys aren't secret, so memset is functionally fine, but the
inconsistency invites accidental copy-paste from parse_public_key
into a future sensitive-buffer context where the compiler-optimised
memset gets elided. One-line homogenisation:

- memset(key, 0, WG_PUBLIC_KEY_LEN);
+ memzero_explicit(key, WG_PUBLIC_KEY_LEN);

Finding 6 — Minor: negative wolfCrypt error returned as CLI exit status

genkey.c:48, pubkey.c:56, others: ret can be a wolfCrypt error
(e.g. WC_KEY_SIZE_E = -181). Returned via main, the shell
truncates to the low 8 bits: -181 & 0xff = 75. Scripts testing $?
see an unexpected positive value.

Convention elsewhere in the tree is return 0 for success and
return 1 for error. Normalise by returning ret ? 1 : 0; from the
cleanup/out: blocks.

Inherited from upstream wireguard-tools; fix ripples through several
files. Minor.


Self-review correction

PR #20 also fixes a bug this review missed: in
kernel_generate_privkey, kernel_derive_pubkey, and
kernel_generate_psk, the out: cleanup block called
mnlg_socket_close(nlg) but did not set nlg = NULL; afterward.
Because each of these functions also has a goto try_again loop on
EINTR, a second-pass failure where the retry's mnlg_socket_open()
itself fails leaves the stale closed pointer in nlg; any later
cleanup that re-checks if (nlg) would double-close.

I missed this because my pattern library only covered single-pass
cleanup, not the retry-loop variant where the cleanup block is
re-entered after goto try_again. Updated the review methodology to
cover it — see cpsource/code-review-cpsource commit
fae0bf4
:
"c.md: add Pattern 4 — retry loops that don't NULL after close/free",
plus a matching entry in the §3 Resource Management checklist.

Credit to @MarkAtwood / PR #20 for catching it.


Test coverage observation

make check is scan-build --maxloop 100 --view make wg-fips — a
Clang static-analyzer run, not a functional test suite. No
genkey→pubkey round-trip test, no base64 known-answer tests, no
CLOEXEC assertions. Given that the tree handles private-key material
at a kernel/user-space interface, at least a minimal behavioural
harness would pay for itself.

Proposal:

  • test-encoding — round-trip wg_{to,from}_{base64,hex} across all
    key sizes, plus KATs from a reference base64 implementation.
  • test-genkeywg-fips genkey | wg-fips pubkey → assert output is
    well-formed base64 of WG_BASE64_LEN(WG_PUBLIC_KEY_LEN) bytes, 100
    iterations.
  • test-cloexec — the /proc/self/fd snapshot pattern referenced in
    Finding 1.

Asks

  1. Review PR fix: validate key lengths from kernel reply and fix try_again loop cleanup #20 with an eye toward merging — it closes Finding 20251007-RHEL8V10 #2
    and the retry-loop bug above.
  2. If Finding 1 (CLOEXEC sweep) is agreeable, I'm happy to submit it
    as a separate PR with the regression test described.
  3. Findings 3–6 are small; they could be bundled into a single
    cleanup PR if you'd prefer a single commit, or I can leave them
    for the tree's regular cleanup cadence.

Full per-section walk-through (20-section C/C++ checklist output) is
in user-src/README-code-review-user-src.md in my local tree;
happy to push that to a branch if useful.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions