Zion Boggan

In-depth vulnerability research, detection engineering & applied cryptography.

● Open to security-research & detection roles
GitHub · LinkedIn · Email
← Research notebook
Methodology

Live audit log, started 2026-04-17 00:10 UTC

Pass 1: coredump/ subsystem (3.8k LoC across ~10 files)

Files read

  • coredump.c (44 LoC), main(), dispatches to kernel-helper or receive.
  • coredump-kernel-helper.c (75 LoC), receives from kernel core_pattern, forwards to container or submits locally.
  • coredump-receive.c (145 LoC), reads the /run/systemd/coredump socket.
  • coredump-send.c (336 LoC), forwards crash to container’s coredump service.
  • coredump-submit.c (813 LoC), writes ELF to disk, parses metadata, sends to journal.
  • coredump-context.c (599 LoC), parses /proc/$pid metadata, builds iovw.
  • coredump-vacuum.c (230 LoC), directory cleanup, LRU by UID.
  • elf-util.c parse_elf_object (read the fork/sandbox wrapper), forks to parse ELF with FORK_NEW_USERNS + FORK_MOUNTNS_SLAVE. Sandboxing is strong.

Trust boundaries mapped

  1. Kernel → outer coredump (root). Input via argv[] + stdin. argv kernel-controlled; stdin is core ELF.
  2. Outer → inner coredump via /run/systemd/coredump Unix socket, SocketMode=0600. Only root can connect.
  3. Host outer → container inner via forwarding path. Gated by pidfd OR dumpable==SUID_DUMP_USER, plus Delegate=yes + CoredumpReceive=yes on target cgroup.
  4. Inner coredump as root: saves ELF to /var/lib/systemd/coredump/, attaches mount tree, drops priv.
  5. Inner coredump as user: parse_elf_object in sandboxed child.

Observations, non-findings (yet)

  1. make_filename() at coredump-submit.c:58 uses xescape(comm, "./ "), escapes dot, slash, space. Dot dot attacks blocked. Comm comes from /proc/$pid/comm, kernel-controlled, no NUL bytes. Safe.

  2. startswith(context->comm, "systemd-coredum") at coredump-submit.c:696, only 15 of 16 TASK_COMM_LEN bytes checked. Any comm starting with “systemd-coredum” matches. Attacker can set their comm to “systemd-coredumX” via prctl(PR_SET_NAME). But that only sets fork_disable_dump=true which is the SAFE path (prevents parse crash recursion). Not a finding. However: the inverse, an attacker process named “not-matching” that crashes the parse_elf sandbox child could cause a systemd-coredump recursion storm. Mitigated by RuntimeMaxSec=5min and MaxConnectionsPerSource=8 on the socket, plus fork_disable_dump=true for truly-named sd-parse-elf children. Edge case, DoS only.

  3. Mount tree attach at coredump-submit.c:562 deliberately omits MOUNT_ATTR_NOSYMFOLLOW with the justification that libdwfl uses openat2(RESOLVE_IN_ROOT). This is a trust claim about elfutils. If any path opened inside the attacker-controlled mount tree by elfutils does NOT use RESOLVE_IN_ROOT, it is a symlink escape. But: the escape happens AFTER change_uid_gid drops to the crashing user’s UID, so the exposure is limited to files the user already has access to. Combined with FORK_NEW_USERNS in parse_elf_object, even if elfutils has a bug, the sandbox should contain it. Worth a deeper elfutils audit only if we need to fall back.

  4. can_forward_coredump() at coredump-send.c:98 requires both Delegate=yes and CoredumpReceive=yes on the container’s cgroup. A misconfigured host combined with a compromised container could allow coredump forwarding, but the cgroup props are root-set. Not exploitable by an unprivileged user.

  5. In coredump_send_to_container() at coredump-send.c:226, the pidns check + dumpable check closes the PID-reuse race. With pidfd, reliable. Without pidfd (ancient kernels <5.3), the dumpable != SUID_DUMP_USER fallback catches SUID processes. Attack surface: kernel <5.3 + no SUID. Vanishingly rare.

Interim verdict for coredump/

Surface is small, boundaries are well-designed, sandbox is strong (userns + mountns slave in parse_elf child). Novel critical bug here is a high bar.

Pivot decision

Moving to resolve/ (52k LoC) next. DNS/mDNS/LLMNR parsers have a richer CVE history: - CVE-2022-2526 (resolved UAF in DnsTransaction) - CVE-2023-7008 (resolved TXT RR spoofing via mDNS) - CVE-2017-9445 (DHCPv6 OOB write in networkd)

Resolve’s attack surface includes: - DNS wire parsing (dns-packet.c) - mDNS/LLMNR parsing - DNS-over-TLS cert handling - DNSSEC validation - Cache management (UAF territory)

Payout for RCE in resolved = Critical = €10k. Priv-less remote (single-packet mDNS/LLMNR on same LAN) would be sexy.

Pass 2: resolve/ DNS wire parsers

Files read

  • src/shared/dns-packet.c, 3069 LoC. Read primitive readers (1390-1660) and full parse switch (1858-2400).
  • src/resolve/resolved-mdns.c, 701 LoC. mDNS listener on-network entry point.

Findings, still non-exploitable

  1. dns_packet_read_name at dns-packet.c:1572, DNS name decompression. jump_barrier descends strictly on each pointer jump, bounded m against DNS_HOSTNAME_MAX. No infinite loop, no OOB, no obvious off-by-one. Cleanly coded.

  2. dns_packet_read_rr at dns-packet.c:1858, the RR parse switch is gated upfront by rdlength > p->size - p->rindex → -EBADMSG (line 1898) and post-gated by p->rindex - offset != rdlength → -EBADMSG (line 2388). So an RR parser that reads fewer or more bytes than rdlength is caught. RR parsers can read beyond rdlength into the next RR area (because individual reads only check against p->size not rdlength), but the post-gate catches every such overshoot. Defense in depth works here.

  3. DNS_TYPE_SSHFP at dns-packet.c:2097 is missing the if (r < 0) return r; after dns_packet_read_memdup. This is cosmetically concerning but not exploitable: when read_memdup fails, fingerprint_size stays at its zero-init value, and the next line if (rr->sshfp.fingerprint_size <= 0) return -EBADMSG; rejects the packet. Same pattern as DS/DNSKEY which DO have the explicit check. Would be a valid CLEANUP patch but not a bounty-worthy bug.

  4. DNS_TYPE_CAA at dns-packet.c:2338 lacks the explicit error check after read_memdup too, but the trailing if (r < 0) return r; at line 2386 catches it. Safe.

  5. mDNS reply handler at resolved-mdns.c:461 filters to .local/.in-addr.arpa/.ip6.arpa only, BUT only AFTER dns_packet_extract ran on the full packet. If a parser bug existed, the filter wouldn’t help. This is the reachability model: attacker on-link sends mDNS reply with crafted RR of any name, parser runs, then domain filter drops the packet. Parser bugs would fire before the filter.

  6. mDNS query handler at resolved-mdns.c:262 calls dns_packet_extract at line 275. No domain filter on queries. Crafted RR in additional section = direct parser hit.

Verdict for dns-packet parse path

Dense and well-audited. I did not find a smoking gun. Will try fuzzing with the existing harness (fuzz-dns-packet.c) to let the tool surface edge cases in parallel.

Next targets if fuzzing is dry

  • resolved-dns-dnssec.c (2245 LoC), RRSIG/DNSKEY/NSEC3/DS validation. Crypto edge cases + iteration counters + digest algorithm selection. High complexity.
  • resolved-dns-stream.c, DoT/DoH stream reassembly. Framing bugs on TCP.
  • resolved-dns-cache.c (1528 LoC), cache eviction + TTL handling + cross-tenant key reuse.

Pass 3: newer/less-scrutinized daemons

resolved-dns-dnssec.c crypto paths (partial)

  • dnssec_rsa_verify at resolved-dns-dnssec.c:135 parses RFC 3110 DNSKEY format (exponent length variable, two forms). Arithmetic checks on 3 + exponent_size >= key_size and 1 + exponent_size >= key_size are correct, no wrap (exponent_size max 65535 via uint8/uint16 promotion). Modulus subtraction guaranteed positive by the guards.

  • dnssec_nsec3_hash at resolved-dns-dnssec.c:1193 uses a fixed buffer result[EVP_MAX_MD_SIZE] (64 bytes). EVP_MD_size return is checked against next_hashed_name_size. Iteration count bounded by NSEC3_ITERATIONS_MAX. Clean.

nsresourced/ varlink surface

  • vl_method_add_mount_to_user_namespace at nsresourcework.c:1615 gates on varlink_check_privileged_peer (root-only). Not an unpriv boundary.
  • vl_method_register_user_namespace at nsresourcework.c:1472 has POLKIT_DEFAULT_ALLOW so unpriv users CAN register. But it just adds them to a BPF map with an empty allowlist. No privileged action exposed.
  • Did NOT audit vl_method_allocate_user_range or vl_method_add_netif_to_user_namespace yet, queued for next pass.

mountfsd/ varlink surface (reviewed vl_method_mount_image)

  • Image trust is anchored at inode identity, not path. verify_trusted_image_fd_by_path at mountwork.c:186 gets the fd’s /proc/self/fd path, re-resolves the filename inside the trusted dir via chase(CHASE_SAFE) + chaseat(CHASE_SAFE), then compares (st_dev, st_ino) via stat_inode_same. A user-writable entry in the trusted dir would be needed to spoof trust, and trusted dirs are root-owned by default.
  • Untrusted images default to image_policy_untrusted which blocks unsigned partitions.
  • Mount options route into polkit_untrusted_action which requires elevated polkit.
  • Surface is real and interesting but defended in depth. Would need a missing check inside dissect_loop_device or image_policy_untrusted permissiveness to matter. Audit queued, not completed.

Pass 3 verdict

The newer daemons (nsresourced, mountfsd, homed) are the softest targets topologically because they’re newer. They’re also topologically well- designed: every one I looked at has either a varlink_check_privileged_peer gate or an inode-identity anchor or a polkit gate. Getting to a concrete finding requires spending hours inside dissect_loop_device, image policy enforcement, or userns_registry_*. Holding off until after the fuzzer run finishes.

Pass 4, journal-remote (2026-04-17 01:10 UTC)

Target: src/shared/journal-importer.c + src/journal-remote/journal-remote-main.c HTTP upload path.

Audit coverage: - State machine LINE / DATA_START / DATA / DATA_FINISH, verified pointer math through memmove reconstruction path. field = data - 8 - field_len arithmetic correct relative to buf base after realloc. - journal_importer_push_data, called only from two sites in journal-remote-main.c, both bounded: decompressed blob size capped by decompress_blob(..., DATA_SIZE_MAX); libmicrohttpd chunk size bounded by HTTP protocol. No user-reachable integer overflow in imp->filled + size arithmetic. - journal_field_valid, max 64 chars, A-Z/0-9/_ only, rejects empty names. Binary-field field_len therefore capped at 65 bytes (64 + replaced \n=). - Data_size = 0 case is handled but silently drops the field (quality bug, not security). - 32-bit size_t concern on imp->data_size = unaligned_read_le64(...), low impact, systemd predominantly 64-bit. - lz4 decompression size clamped by dst_max=DATA_SIZE_MAX in caller path; LZ4_decompress_safe bounds-checks output.

Fuzz harness gap identified

src/journal-remote/fuzz-journal-remote.c caps input at 65536 bytes (outside_size_range(size, 3, 65536)). Real parser accepts up to ENTRY_SIZE_MAX = 770MB (64-bit build). The harness also uses a non-passive fd (memfd), so the journal_importer_push_data path used by HTTP uploads is entirely unfuzzed. Compressed HTTP upload (xz/lz4/zstd) is also untested by fuzz.

Gap matters if there’s an interaction bug between: (a) realloc_buffer shrinking in journal_importer_drop_iovw (line 461-466 memcpy compaction) (b) iovw rebase after a push_data-triggered realloc (c) large multi-field entries exceeding the current 64KB cap

Not a finding by itself. Noted for a future extended fuzz session.

Verdict

Journal-remote parser is defensively written. Not a productive surface for this hunt. Pivoting.

Pass 5, ndisc option parsers + encrypted DNS (2026-04-17 01:20 UTC)

Target: src/libsystemd-network/ndisc-option.c + src/libsystemd-network/sd-dns-resolver.c (DNR / SvcParams) + src/shared/dns-domain.c (wire format parser)

Why: RA/DHCPv6 parsers run as root on every systemd-networkd host. Attacker on same L2 segment can send crafted options. Encrypted DNS (RFC 9463 DNR) is newer and less audited.

Audit coverage: - ndisc_option_parse (core TLV reader), bounds checks tight, len = opt_len*8 with prior offset <= raw_size assertion. - Fixed-size option parsers (prefix, MTU, redirected-header), length-exact checks; safe. - ndisc_option_parse_rdnss, len%16==8 alignment check + n_addrs = len/16 arithmetic; in-bounds. - ndisc_option_parse_dnssl, label parser checks c > 63 (DNS_LABEL_MAX) so rejects compression pointers. Bounds check pos + c >= len is off-by-one in the safe direction (rejects valid labels ending exactly at len). Not a bug. - ndisc_option_parse_encrypted_dns (DNR, RFC 9463), all sub-length checks against len, aligned with off advance. Clean. - dnr_parse_svc_params, SvcParam iteration bounds checks against overall option len, not the SvcParam value boundary. ALPN sub-parser can read attacker bytes past the individual SvcParam’s declared length but within the overall option bytes (attacker-supplied). Not a memory-safety issue, just a logic imprecision. Result: parser returns EBADMSG via final poff != pend check. - dns_name_from_wire_format, standard DNS wire parser, 63-byte label cap, 255-byte total cap, no compression pointer handling (correct for this use). - Unreachable path: DNS_SVC_PARAM_KEY_MANDATORY (key=0) case at line 210 cannot be reached because lastkey >= key check (initial lastkey=0) rejects key=0 as the first/only param. Minor dead code, not a security issue.

Verdict: sd-ndisc + sd-dns-resolver + dns-domain wire parser are well-hardened. No exploitable issue found. Moving on.

Pass 5.5, storagetm (2026-04-17 01:25 UTC)

src/storagetm/storagetm.c, confirmed this is a configfs-based nvmet configurator, NOT a userspace NVMe protocol parser. All actual NVMe-over-TCP protocol handling is in the kernel (drivers/nvme/target/). systemd-storagetm userspace only writes to /sys/kernel/config/nvmet/…, no remote attacker surface in its own code.

Running tally, what’s been checked this hunt

Pass 1: src/coredump/ (all files), clean Pass 2: src/resolve/resolved-dns-dnssec.c RSA + NSEC3, clean Pass 3: src/mountfsd/, src/nsresourced/ varlink methods, architecturally defended Pass 4: src/shared/journal-importer.c + HTTP upload path, clean (fuzz gap noted) Pass 5: src/libsystemd-network/ndisc-option.c + DNR, clean Pass 5.5: src/storagetm/, not a userspace parser surface

Fuzzers: 10M+ combined executions on dns-packet, resource-record, etc-hosts, 0 crashes.

Realistic next steps (ranked)

  1. src/libsystemd-network/sd-dhcp6-client.c + dhcp6-option parsers DHCPv6 client options from rogue DHCPv6 server on LAN. Less audited than DHCPv4 (which has had CVEs historically). Option parsing with length+type TLVs.

  2. Fuzz the uncovered paths:, Extend fuzz-journal-remote to call journal_importer_push_data with multi-chunk inputs and compressed HTTP bodies., Write a fuzz-ndisc-ra harness that feeds raw ICMP6 packets into the option parser (existing fuzz-ndisc-rs covers router solicitations but not router advertisements with all option types).

  3. src/sysusers/sysusers.c, privileged user/group creation tool. TOCTOU or path-based bugs when creating homes / writing /etc/passwd.

  4. src/tmpfiles/tmpfiles.c, classic CVE territory. File creation races. Path-based vs fd-based checks. Extended attributes.

  5. D-Bus method handlers in systemd-logind, systemd-hostnamed - unprivileged-to-root transition boundary; method argument validation.

The fuzzer’s 10M-execution 0-crash result on parsers we tested strongly suggests parser attack surface is not where a P1 lives this hunt. The productive targets from here are logic bugs in privileged helpers (#3, #4) or extended fuzzing of uncovered network parsers (#1, #2).

Pass 6, tmpfiles dir_cleanup race (2026-04-17 01:45 UTC)

Desktop Claude senior review: [path redacted]

Finding candidate: TOCTOU race between xstatx_full (tmpfiles.c ~line 694) and unlinkat (~line 857) in dir_cleanup. Window: skip-decisions and xopenat bracketed by advisory flock.

Prior-art anchor: CVE-2026-3888 (snapd LPE, 2026-03-17), verified in knowledge DB. Same code path.

Exploit reality check (where I disagree with Desktop’s severity): - Rename within a single attacker-owned dir: no new capability vs. direct unlink. - Cross-dir via hardlink (fs.protected_hardlinks=0 kernels): unlink decrements refcount, doesn’t reach original path. Target file at /etc/X survives. - Subdir recursion race: unlinkat(AT_REMOVEDIR) returns ENOTEMPTY which is silently swallowed. - CVE-2026-3888 class (tmpfiles deletes, privileged service re-creates, attacker wins race): requires external service (snapd) with the bug. Systemd’s own R! entries (/tmp/systemd-private-*) are boot-time only and pre-sysinit, no concurrent re-creator.

Severity verdict: DoS / integrity, not privesc. Probably Medium (€3k), not Critical (€10k). Recommendation: note for possible future low-severity writeup, not a P1 submission.

Moving on to src/sysusers/sysusers.c.

Pass 7, sysusers.c (2026-04-17 02:05 UTC)

Target: src/sysusers/sysusers.c (2393 LoC)

Audit coverage: - write_files / write_temporary_* / make_backup, all operate on /etc/{passwd,shadow,group,gshadow}. Paths are constant, /etc is root-owned. Temp files created in same dir via fopen_temporary_label → atomic rename. No attacker-controllable path or symlink window. - make_backup at line 316 opens src without O_NOFOLLOW but src is /etc/passwd etc (root-only-writable dir), non-issue. - read_id_from_file uses chase_and_stat w/ CHASE_PREFIX_ROOT, safe path resolution. - Config parsing (parse_line) reads from /etc/sysusers.d/ which is root-owned.

Clean. No attacker surface outside of –root= mode (which requires root to invoke).

Moving on. Candidate targets ranked: 1. src/cryptenroll/ (FIDO2/PKCS11 paths, newer, less audited) 2. src/pcrlock/ (JSON policy parser + TPM2 events) 3. Dispatching one more strategic question to Desktop Claude for “remaining high-value non-parser surface”.

Pass 4, mountfsd full re-read (2026-04-17 ~07:00 UTC)

Full read of mountwork.c (1599 LoC). All three varlink methods audited:

vl_method_mount_image (line 362)

  • Trust check verify_trusted_image_fd_by_path (186): inode-anchored via stat_inode_same(&sta, &stb) at 252. fd→inode binding stable across rename(2), so path-string trust cannot be smuggled by post-open rename.
  • Content TOCTOU between trust check (444) and loop_device_make (516) exists in theory, but trusted image dirs are conventionally root:root 0644 in distro packaging, exploitation would require a config bug, not a systemd bug. Out of scope for the bounty.
  • userns_fd >= 0 flips POLKIT_DEFAULT_ALLOW (494), but only after validate_userns (296) confirms it’s a real user namespace via fd_is_namespace. is_our_namespace check zeroes the fd if it’s the host ns, so DEFAULT_ALLOW cannot be triggered by passing /proc/self/ns/user.
  • Image-policy retry loop (546-615) and verity-decrypt retry loop (630-699) both gate the second escalation through varlink_verify_polkit_async_full with flags=0, no DEFAULT_ALLOW on the escalation path.

vl_method_mount_directory (line 1067)

  • validate_directory_fd (837) traverses up to 16 parents looking for a peer-owned ancestor when the directory itself is foreign-UID-owned. STATX_ATTR_MOUNT_ROOT defends against following bind-mount escapes.
  • unmapped_st.st_uid != current_owner_uid post-open_tree check (1197) catches the case where dropping idmap reveals different underlying owner. Tight.
  • userns range structural check (1230-1263) requires transient or foreign UID base, 64K alignment, 1:1 inside/outside. No off-by-one observed.

vl_method_make_directory (line 1321)

  • Created with tempfn_random + atomic rename_noreplace → no symlink race.
  • mode masked to 0775 (1349), never world-writable.
  • Owner forced to FOREIGN_UID_BASE (1420), directory cannot be created owned by root from this method.

Conclusion

mountfsd is the most carefully written privilege boundary I’ve read in systemd so far. No finding. Backup target nsresourced still on the list, but my expectations are now low, same author, same idiom set.

Pass-5 candidates (revised priority)

  1. src/resolve/resolved-dns-dnssec.c, large parser, less reviewed than dns-packet, RRSIG/DNSKEY/DS validation arithmetic. Top pick.
  2. src/sysext/sysext.c, image overlay composition, runs as root, takes user-provided image dirs.
  3. src/network/networkd-dhcp4-server.c, DHCP server option emit/parse, touched recently.

Pass 5, resolved-dns-dnssec.c partial (2026-04-17 ~07:15 UTC)

Read crypto verify primitives (lines 71-382) and RRSIG canonicalization (407-690). Observations:

  • RFC 1982 serial-number arithmetic NOT used in inception/expiration comparisons (lines 422, 472). if (inception > expiration) return -EINVAL uses simple unsigned compare. Pre-2038 this is a non-issue. After Y2038 wrap, valid wrap-around RRSIGs would be rejected (DoS, not bypass).
  • dnssec_eddsa_verify_raw (311) builds a q = 0x04 || signature buffer with newa (line 325-327) but never passes it to anything. Dead cruft inherited from the ECDSA pattern. Not a vulnerability.
  • RSA exponent split (135-181) handles both short (<=255) and long (>=256) forms per RFC 3110. Bounds checks use >= so at least 1 modulus byte guaranteed. Tight.
  • ECDSA path: hardcoded P-256/P-384 curves, key_size validated, signature split into r/s of equal halves. Stack alloc bounded. Tight.
  • NSEC3 hash (1193): iteration cap NSEC3_ITERATIONS_MAX enforced before the hash loop. salt_size from RR parser, validated upstream. Tight.

No finding. Remaining DNSSEC reading: NSEC3 search (1364), NSEC wildcard proofs (2013-2128). Lower-priority, expected to be control flow heavy but free of memory bugs given the parser-side bounds.

Pivot for next iteration: build-fuzz2 (llvm-fuzz=true) is rebuilding 9 new harnesses (dhcp-server, dhcp6-client, journal-remote, ndisc-rs, lldp-rx, bus-message, json, link-parser, network-parser). Once built, launch in parallel with shared corpus seeded from test/fuzz/fuzz-X/.


Source · github.com/zionsworking/security-research-notebook · methodology/systemd-coredump-resolved-audit-log.md