<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>enzoventuri.com</title>
    <link>https://enzoventuri.com/</link>
    <description>linux, networking and go</description>
    <item>
      <title>Posts</title>
      <link>https://enzoventuri.com/posts/</link>
      <guid>https://enzoventuri.com/posts/</guid>
      <pubDate>Thu, 16 Jul 2026 08:47:29 +0000</pubDate>
      <description>&lt;h1&gt;Posts&lt;/h1&gt;&#xA;&lt;p&gt;Small notes, longer complaints, and the occasional useful thing.&#xA;</description>
    </item>
    <item>
      <title>Plan 9 shell (rc) in Go</title>
      <link>https://enzoventuri.com/posts/2026-07-16-plan9-shell-in-go/</link>
      <guid>https://enzoventuri.com/posts/2026-07-16-plan9-shell-in-go/</guid>
      <pubDate>Thu, 16 Jul 2026 00:00:00 +0000</pubDate>
      <description>&lt;h1&gt;Plan 9 shell (rc) in Go&lt;/h1&gt;&#xA;&lt;p&gt;&lt;a href=&#34;https://github.com/enzv/rc&#34;&gt;enzv/rc&lt;/a&gt; uses an rc script to compile itself.&#xA;&lt;pre&gt;% go run . make.rc build&#xA;built ./rc&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;rc is the part of Plan 9 I missed most when work pulled me back to Linux. Not the nostalgia. The grammar. It treats shell input like a language, not like string soup.&#xA;&lt;p&gt;So I rebuilt it in Go as a standalone binary. No plan9port wrapper. No secondary shell. No CGO. One static binary you can ship, debug, and move around without dragging the rest of Plan 9 behind it.&#xA;&lt;p&gt;The pipeline is the point.&#xA;&lt;pre&gt;ParseSource(src)&#xA;prepareSource(src)&#xA;Lex(prepared.Stripped)&#xA;parseTokens(tokens)&#xA;attachHereDocs(prog, prepared.HereDocs)&#xA;RunProgram(prog, opts)&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;Source is evidence.&#xA;The tree is the contract.&#xA;Execution is what happens after the parser turns noise into structure.&#xA;&lt;p&gt;First comes bookkeeping, not parsing. The source walker tracks quotes, comments, and heredoc markers before the lexer ever sees the input. That keeps the lexer from guessing where the body ends and the syntax begins.&#xA;&lt;pre&gt;for i := 0; i &amp;lt; len(src); {&#xA;    ch := src[i]&#xA;    if inComment {&#xA;        ...&#xA;    }&#xA;    if inQuote {&#xA;        ...&#xA;    }&#xA;    switch ch {&#xA;    case &amp;apos;&amp;lt;&amp;apos;:&#xA;        if i+1 &amp;lt; len(src) &amp;amp;&amp;amp; src[i+1] == &amp;apos;&amp;lt;&amp;apos; {&#xA;            pending = append(pending, scanHereTag(src, i+2))&#xA;            ...&#xA;        }&#xA;    case &amp;apos;\n&amp;apos;:&#xA;        ...&#xA;    }&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;That pass exists for one reason. Heredocs need to land on the right node later. If you capture them too late, function bodies start lying about what they contain. Shells already have enough ways to lie.&#xA;&lt;p&gt;The lexer is stateful because shell syntax is stateful. It remembers whether the previous token was a word. It remembers whether $ should trigger identifier mode. It remembers quotes, comments, and continuation because context changes the meaning of the next byte.&#xA;&lt;pre&gt;func (lx *Lexer) nextToken() (LexToken, error) {&#xA;    if lx.lastWord {&#xA;        ...&#xA;    }&#xA;    lx.skipWhite()&#xA;    pos := lx.position()&#xA;    c := lx.advance()&#xA;    switch c {&#xA;    case &amp;apos;$&amp;apos;:&#xA;        ...&#xA;    case &amp;apos;`&amp;apos;:&#xA;        ...&#xA;    }&#xA;    ...&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;Tokens keep the facts that matter. Quoted, Glob, RType, FD0, FD1, and Pos. Later stages do not need to rediscover intent from raw bytes. They can read what the lexer already proved.&#xA;&lt;p&gt;The tree stays small on purpose. A Program carries the original tokens, node array, offset table, here-doc map, and root id. That is enough to parse, print, expand, and execute without inventing a second representation for every phase.&#xA;&lt;pre&gt;Program{&#xA;    Tokens:   tokens,&#xA;    Nodes:    nodes,&#xA;    Offsets:  offsets,&#xA;    HereDocs: hereDocs,&#xA;    Root:     root,&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;One tree.&#xA;Many readers.&#xA;No translation layer trying to be clever behind your back.&#xA;&lt;p&gt;The evaluator walks the tree directly. No bytecode. No VM frame. No generic command executor pretending every shell construct is the same thing.&#xA;&lt;pre&gt;func (r *runner) exec(id int) error {&#xA;    node := r.prog.Node(id)&#xA;    if node == nil {&#xA;        ...&#xA;    }&#xA;    switch node.Type {&#xA;    case &amp;apos;;&amp;apos;:&#xA;        ...&#xA;    case &amp;apos;&amp;amp;&amp;apos;:&#xA;        ...&#xA;    case tokenSimple:&#xA;        ...&#xA;    case tokenFn:&#xA;        ...&#xA;    }&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;That switch is the whole job. rc does not flatten control flow into one universal path. Conditionals, loops, functions, word lists, subshells, redirections, and pipelines all keep their own rules. They should. The shell is not one thing with different hats.&#xA;&lt;p&gt;^ is list-aware concatenation, not casual string glue. If one side is a scalar and the other is a list, the scalar expands across the list. If both sides have the same length, they join pairwise. If they do not line up, the command fails. No made-up shape. No polite lie.&#xA;&lt;p&gt;That is the whole trick. Keep the shape when the shape makes sense. Fail when it does not.&#xA;&lt;pre&gt;func concatWords(left, right []wordValue) ([]wordValue, error) {&#xA;    if len(left) == 0 || len(right) == 0 {&#xA;        return nil, fmt.Errorf(&amp;quot;null list in concatenation&amp;quot;)&#xA;    }&#xA;    switch {&#xA;    case len(left) == len(right):&#xA;        ...&#xA;    case len(left) == 1:&#xA;        ...&#xA;    case len(right) == 1:&#xA;        ...&#xA;    default:&#xA;        return nil, fmt.Errorf(&amp;quot;mismatched list lengths in concatenation&amp;quot;)&#xA;    }&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;That is the kind of algorithm rc wants. Shape-preserving. Strict when it has to be. Small enough that a human can still reason about it without pretending they love writing parsers.&#xA;&lt;p&gt;The environment is not one big string map. It keeps raw variables, encoded word values, function bodies, a job table, a current directory, branch state, and runtime flags. That split is what keeps PATH, IFS, $*, and fn#name=... from collapsing into sludge.&#xA;&lt;pre&gt;func (e *shellEnv) exportEnv() []string {&#xA;    var env []string&#xA;    if path, ok := e.words[&amp;quot;path&amp;quot;]; ok {&#xA;        ...&#xA;    }&#xA;    for name, values := range e.words {&#xA;        ...&#xA;    }&#xA;    for name, body := range e.fns {&#xA;        ...&#xA;    }&#xA;    return env&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;vars stores the plain shell view. words stores the glob-aware shell view. That means a variable can round-trip through expansion and export without losing the shape it had when the user set it.&#xA;&lt;p&gt;IFS matters because tiny shell rules break big command lines. An empty IFS means no splitting. Add characters to it and each character becomes a delimiter. That behavior is explicit, local to the environment, and not buried in a helper with mysterious defaults.&#xA;&lt;pre&gt;func splitByIFSWords(output string, env *shellEnv) []wordValue {&#xA;    ifs := &amp;quot; \t\n&amp;quot;&#xA;    if v, ok := env.vars[&amp;quot;ifs&amp;quot;]; ok {&#xA;        ...&#xA;    }&#xA;    if ifs == &amp;quot;&amp;quot; {&#xA;        ...&#xA;    }&#xA;    for _, ch := range output {&#xA;        ...&#xA;    }&#xA;    return words&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;Command substitution uses the same rule. Run the subtree, capture stdout, then split the result through the current IFS behavior. No special parser for captured text. No one-off exception pretending it is not part of the language.&#xA;&lt;p&gt;Globbing follows the same logic. Literal markers, wildcard markers, bracket classes, and dotfile rules stay separate from command execution. Completion, expansion, and printing can all share the same semantics without inventing their own little lies.&#xA;&lt;pre&gt;func matchSegment(subject, pattern string, stop rune, relaxed bool) bool {&#xA;    for len(pattern) != 0 {&#xA;        r, size := utf8.DecodeRuneInString(pattern)&#xA;        if r == stop {&#xA;            ...&#xA;        }&#xA;        switch r {&#xA;        case globMark:&#xA;            ...&#xA;        case &amp;apos;*&amp;apos;:&#xA;            ...&#xA;        case &amp;apos;?&amp;apos;:&#xA;            ...&#xA;        case &amp;apos;[&amp;apos;:&#xA;            ...&#xA;        }&#xA;    }&#xA;    return subject == &amp;quot;&amp;quot;&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;The useful property is that the matcher only recurses where the syntax demands it. That keeps the implementation small and the failure modes visible. No hidden behavior. No ambient search path outside the inputs.&#xA;&lt;p&gt;Here-docs stay attached to the node that owns them. Quote the delimiter and the body stays literal. Leave it unquoted and it expands. The parser already decided. The evaluator just carries the decision forward.&#xA;&lt;pre&gt;func (r *runner) applyRedir(id int) (io.Closer, bool, error) {&#xA;    node := r.prog.Node(id)&#xA;    switch node.Type {&#xA;    case tokenDup:&#xA;        ...&#xA;    case tokenRedir:&#xA;        ...&#xA;    }&#xA;    return nil, false, fmt.Errorf(&amp;quot;unsupported redirection node %s&amp;quot;, tokenName(node.Type))&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;Redirection order is source-order sensitive. The code collects the chain first, sorts by source offset, and applies each step in sequence. &amp;gt;[...] , &amp;lt;[...] , &amp;lt;&amp;lt;, and file targets do not commute. Get that wrong and the script says something else entirely.&#xA;&lt;p&gt;Process substitution is plumbing, not magic. os.Pipe creates the transport. One end is bound to the child. The other is returned as a /dev/fd/N path so the rest of the shell can treat it like ordinary redirection.&#xA;&lt;pre&gt;func (r *runner) execProcSub(id int) ([]string, error) {&#xA;    pr, pw, err := os.Pipe()&#xA;    if err != nil {&#xA;        ...&#xA;    }&#xA;    ...&#xA;    go func() {&#xA;        sub.exec(node.Child[0])&#xA;        ...&#xA;    }()&#xA;    return []string{procSubFDPath(osFd)}, nil&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;&amp;amp; follows the same pattern, but the contract is different. The child gets a cloned environment and its own job bookkeeping. The parent gets a virtual pid in $apid and keeps going. That keeps background execution cheap without pretending fork semantics are free in Go.&#xA;&lt;pre&gt;func (r *runner) execAsync(id int) error {&#xA;    sub := r.child(r.env.clone())&#xA;    ...&#xA;    r.env.jobs.jobs[pid] = done&#xA;    ...&#xA;    go func() {&#xA;        sub.exec(node.Child[0])&#xA;        ...&#xA;    }()&#xA;    return r.exec(node.Child[1])&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;I wanted that tradeoff to be visible. This is not Plan 9 process semantics with the edges sanded off. It is a Go-native mapping that preserves the shell behavior users can see while keeping the runtime sane.&#xA;&lt;p&gt;The interactive loop had to be native or it was not worth shipping. Raw TTY mode, bracketed paste, history, cursor movement, line clearing, and Ctrl-V are one feature. Usable input without an external wrapper.&#xA;&lt;pre&gt;oldState, err := term.MakeRaw(fd)&#xA;defer term.Restore(fd, oldState)&#xA;enableBracketedPaste()&#xA;defer disableBracketedPaste()&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;The refresh path tracks rendered rows and cursor position. It clears only what it needs. It redraws from the current buffer instead of trying to patch terminal state with guesses. That matters once a command wraps or pasted text spans multiple lines.&#xA;&lt;p&gt;The prompt is not a dumb string either. If prompt is a function, the shell runs it. Then it restores status so prompt logic does not leak into command semantics. Small detail. Big difference.&#xA;&lt;p&gt;Signals stay literal too. SIGINT, SIGQUIT, SIGHUP, and SIGALRM dispatch to matching rc functions if they exist. Otherwise the shell falls back to its current exit code. Interrupts should be visible, not buried in runtime tricks.&#xA;&lt;p&gt;rfork is where the port has to admit reality. The parser takes the combined flag string. The validator rejects combinations the host cannot represent. The executor maps e, E, F, s, n, f, and m onto what Go and the OS can actually do. Fake success is worse than a clean failure.&#xA;&lt;pre&gt;flags, err := parseRforkArgs(args)&#xA;if err != nil {&#xA;    ...&#xA;}&#xA;if err := r.applyRfork(flags); err != nil {&#xA;    ...&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;That is the line I wanted to keep visible. The shell should fail where the abstraction boundary is real. It should not blur the difference between supported and unsupported behavior just because the original syntax looked nicer.&#xA;&lt;p&gt;The tests close the loop. We compare our rc against plan9port byte for byte, including stdout, stderr, exit status, and the rest of the observable behavior. The corpus under testdata/*.rc keeps that comparison grounded. That is enough to keep the language honest without turning the post into a checklist.&#xA;&lt;p&gt;I did not rebuild rc to decorate Unix. I rebuilt it to keep the language honest while letting Go carry the process model. Explicit ASTs. Explicit expansion. Explicit status. Explicit failure. That is the point.&#xA;</description>
    </item>
    <item>
      <title>The do_exit File Descriptor Heist</title>
      <link>https://enzoventuri.com/posts/2026-06-29-the-do_exit-file-descriptor-heist/</link>
      <guid>https://enzoventuri.com/posts/2026-06-29-the-do_exit-file-descriptor-heist/</guid>
      <pubDate>Mon, 29 Jun 2026 00:00:00 +0000</pubDate>
      <description>&lt;h1&gt;The do_exit File Descriptor Heist&lt;/h1&gt;&#xA;&lt;p&gt;People think process termination in Linux is a clean, atomic event. You pull the trigger. The process dies. Memory vanishes. Resources get freed. Done.&#xA;&lt;p&gt;It isn&amp;apos;t. CVE-2026-46333 proves it.&#xA;&lt;p&gt;Process death is a multi-step bureaucracy managed by do_exit(). And like any bad bureaucracy, if you move fast enough during the paperwork, you can steal the office furniture.&#xA;&lt;p&gt;Inside &lt;a href=&#34;https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/tree/kernel/exit.c?id=31e62c2ebbfdc3fe3dbdf5e02c92a9dc67087a3a&#34;&gt;exit.c&lt;/a&gt;, the kernel cleans up the corpse in stages. This creates a temporal gap that is ripe for exploitation.&#xA;&lt;p&gt;Here is what that looks like:&#xA;&lt;pre&gt;[ Process Active ]&#xA;        |&#xA;  do_exit() called&#xA;        |&#xA;        v&#xA;+---------------+&#xA;| exit_mm()     | ---&amp;gt; task-&amp;gt;mm = NULL (Memory map destroyed)&#xA;+---------------+&#xA;        |&#xA;        | &amp;lt;--- VULNERABILITY WINDOW (The Zombie Phase)&#xA;        |      task-&amp;gt;mm == NULL, but task-&amp;gt;files is STILL VALID!&#xA;        v&#xA;+---------------+&#xA;| exit_files()  | ---&amp;gt; File descriptors finally closed&#xA;+---------------+&#xA;        |&#xA;[ Process Dead ]&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;There is a narrow race window. The process has no memory map. But its file descriptor table is still fully populated with the privileged files it was authorized to open. Attackers cannot read arbitrary files; they abuse specific SUID binaries like &amp;quot;chage&amp;quot; or &amp;quot;ssh-keysign&amp;quot; to steal the exact file descriptors those binaries had legitimately opened before dying.&#xA;&lt;p&gt;Security systems frequently use a null memory map as a proxy for &amp;quot;the process is gone.&amp;quot; That is a lie. The process is a brain-dead zombie. But its hands are still clutching loaded guns.&#xA;&lt;p&gt;This is compounded by a severe logic regression. In the v4.10-rc1 development window, commit &lt;a href=&#34;https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=bfedb589252c01fa505ac9f6f2a3d5d68d707ef4&#34;&gt;bfedb589&lt;/a&gt; (titled “mm: Add a user_ns owner to mm_struct and fix ptrace permission checks”) rewrote part of the ptrace authorization path. By the time the kernel shipped to production in early 2017, the authorization logic had been silently inverted.&#xA;&lt;p&gt;Before the refactor, the kernel explicitly denied access if a task&amp;apos;s memory map was null. After the refactor, the check simply fell through to a successful &amp;quot;allow&amp;quot; state.&#xA;&lt;p&gt;But the tragedy is not the bug. The tragedy is the ecosystem.&#xA;&lt;p&gt;In 2020, &lt;a href=&#34;https://lore.kernel.org/all/20201016230915.1972840-1-jannh@google.com/&#34;&gt;Jann Horn&lt;/a&gt; from Google Project Zero noticed the exact architectural flaw. He proposed a patch. However, the concern did not result in a merged fix. The time bomb kept ticking across affected Linux deployments for nearly six more years.&#xA;&lt;p&gt;On May 11, 2026, the &lt;a href=&#34;https://blog.qualys.com/vulnerabilities-threat-research/2026/05/20/cve-2026-46333-local-root-privilege-escalation-and-credential-disclosure-in-the-linux-kernel-ptrace-path&#34;&gt;Qualys Threat Research Unit (TRU)&lt;/a&gt; found the live wire and reported it privately to the upstream Linux kernel security contact, engaging linux-distros before moving to public &lt;a href=&#34;https://www.openwall.com/lists/oss-security/2026/05/20/14&#34;&gt;OSS-Security&lt;/a&gt;. Three days later, on May 14, Linus Torvalds pushed a patch to the public repository with his &lt;a href=&#34;https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=31e62c2ebbfdc3fe3dbdf5e02c92a9dc67087a3a&#34;&gt;classic deadpan description&lt;/a&gt;: &amp;quot;ptrace: slightly saner &amp;apos;get_dumpable()&amp;apos; logic&amp;quot;. A cute way to describe patching a gaping hole.&#xA;&lt;p&gt;Hours later, an independent researcher known as &amp;quot;_SiCk&amp;quot; looked at the public commit, reverse-engineered the logic, and dropped &lt;a href=&#34;https://github.com/0xdeadbeefnetwork/ssh-keysign-pwn&#34;&gt;public exploits on GitHub&lt;/a&gt;: &amp;quot;ssh-keysign-pwn&amp;quot; and &amp;quot;chage_pwn&amp;quot;.&#xA;&lt;p&gt;By May 15, the embargo had completely collapsed. Distributions accelerated patch releases while attackers were already running the code.&#xA;&lt;p&gt;In the open-source kernel, the patch is the exploit documentation. If you rely on the speed of your vendor&amp;apos;s patches, you have already lost the race.&#xA;&lt;p&gt;The exploit is not that Linux leaves arbitrary secrets lying around after process death. The exploit is that, during a narrow exit window, ptrace-style authorization can misclassify a dying privileged task, allowing an attacker to duplicate file descriptors that the privileged process already opened.&#xA;&lt;p&gt;The upstream kernel finally patched it in stable tags like &lt;a href=&#34;https://nvd.nist.gov/vuln/detail/CVE-2026-46333&#34;&gt;versions 7.1, 7.0.8, 6.18.31, 6.12.89, 6.6.139, 6.1.173, and 5.15.207&lt;/a&gt; (note that distribution package versions will vary wildly). &lt;a href=&#34;https://security-tracker.debian.org/tracker/CVE-2026-46333&#34;&gt;Debian&lt;/a&gt; pushed fixes for Bullseye, Bookworm, and Trixie. &lt;a href=&#34;https://access.redhat.com/security/vulnerabilities/RHSB-2026-004&#34;&gt;RHEL&lt;/a&gt;, &lt;a href=&#34;https://almalinux.org/blog/2026-05-15-ssh-keysign-pwn-cve-2026-46333/&#34;&gt;AlmaLinux&lt;/a&gt;, and Rocky Linux patched branches 8, 9, and 10.&#xA;&lt;p&gt;But here is a grim detail about exploitability versus vulnerability.&#xA;&lt;p&gt;AlmaLinux 8 has the exact flawed kernel logic. However, the current public Proof of Concept exploits fail against it. Meanwhile, AlmaLinux 9 and 10 fall over immediately when hit with the same PoC.&#xA;&lt;p&gt;This proves exactly why defenders must separate the exploitability of a system from its underlying vulnerability. Just because a GitHub script fails today does not mean the kernel will not betray you tomorrow.&#xA;&lt;p&gt;To pull off the heist, an attacker targets a privileged SUID binary via &lt;a href=&#34;https://man7.org/linux/man-pages/man2/pidfd_open.2.html&#34;&gt;pidfd_open&lt;/a&gt;. Then they spam &lt;a href=&#34;https://man7.org/linux/man-pages/man2/pidfd_getfd.2.html&#34;&gt;pidfd_getfd&lt;/a&gt;. Because the attacker spawned the target SUID binary, they are technically the parent process.&#xA;&lt;p&gt;They race the teardown. When they hit that exact exit_mm window in the diagram above, the target&amp;apos;s memory map is null. The access check falls through. They clone the target&amp;apos;s file descriptors.&#xA;&lt;p&gt;Standard user-path telemetry struggles with this descriptor cloning because path attribution and file descriptor provenance are lost.&#xA;&lt;p&gt;A SIEM is looking for open() or openat2() syscalls. But the attacker didn&amp;apos;t open the target file. The SUID binary did.&#xA;&lt;p&gt;The attacker just copied the file descriptor from the target&amp;apos;s pocket while it was dying. A file descriptor number like &amp;quot;FD 3&amp;quot; is meaningless without process context. When the target process dies a microsecond later, that context is gone forever.&#xA;&lt;p&gt;Path-based auditing is a lock on a door when the thief is already inside, copying the keys.&#xA;&lt;p&gt;High-level eBPF frameworks like &lt;a href=&#34;https://tetragon.io/docs/concepts/tracing-policy/&#34;&gt;Tetragon&lt;/a&gt; will tell you who the thief is. They enrich system calls with UIDs, container namespaces, and Kubernetes pod metadata perfectly. A simplified tracepoint event for this behavior looks like this:&#xA;&lt;pre&gt;{&#xA;  &amp;quot;process_tracepoint&amp;quot;: {&#xA;    &amp;quot;process&amp;quot;: { &#xA;      &amp;quot;binary&amp;quot;: &amp;quot;/tmp/exploit&amp;quot;, &#xA;      &amp;quot;uid&amp;quot;: 33,&#xA;      &amp;quot;pod&amp;quot;: { &amp;quot;namespace&amp;quot;: &amp;quot;prod&amp;quot;, &amp;quot;name&amp;quot;: &amp;quot;web-frontend-hash&amp;quot; }&#xA;    },&#xA;    &amp;quot;sys_enter&amp;quot;: { &#xA;      &amp;quot;syscall_name&amp;quot;: &amp;quot;sys_pidfd_getfd&amp;quot; &#xA;    },&#xA;    &amp;quot;args&amp;quot;: [&#xA;      { &amp;quot;pidfd&amp;quot;: 5 }, { &amp;quot;fd&amp;quot;: 3 }&#xA;    ]&#xA;  }&#xA;}&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;But they don&amp;apos;t inspect the internal memory pointer or the exiting flags of the target. High-level context tells you who is acting. To confirm the exact mechanism of the vulnerability, we need a custom surgical eBPF sensor. We need the ground truth.&#xA;&lt;p&gt;To get it, you might think about dropping into the &lt;a href=&#34;https://docs.ebpf.io/linux/program-type/BPF_PROG_TYPE_LSM/&#34;&gt;Linux Security Module (LSM) layer&lt;/a&gt;. You could hook lsm/ptrace_access_check to block the attack.&#xA;&lt;p&gt;But here is the grim reality of enterprise infrastructure. Many cloud VMs and managed distributions do not ship with CONFIG_BPF_LSM=y enabled by default. Or they require modifying GRUB boot parameters, which is a non-starter in managed environments.&#xA;&lt;p&gt;If you want truly portable, passive defensive observability, you don&amp;apos;t use LSM. You use standard tracing trampolines. You use fentry.&#xA;&lt;p&gt;You could write a full &lt;a href=&#34;https://github.com/enzv/talks/blob/main/2026/secopsdays-cve-2026-46333/main.ebpf.c&#34;&gt;fentry probe in C&lt;/a&gt;, but dumping the whole file here is a waste of time. Let&amp;apos;s look at the core logic that actually matters.&#xA;&lt;p&gt;First, we hook the authorization function.&#xA;&lt;pre&gt;SEC(&amp;quot;fentry/security_ptrace_access_check&amp;quot;)&#xA;int BPF_PROG(on_security_ptrace_access_check, struct task_struct *child, unsigned int mode) {&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;We chose fentry/security_ptrace_access_check. This lets us read the memory states directly without altering the kernel execution path. We don&amp;apos;t need LSM enforcement privileges to just watch the gate.&#xA;&lt;p&gt;Second, we read the target&amp;apos;s internal state.&#xA;&lt;pre&gt;target_flags = BPF_CORE_READ(child, flags);&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;We use the &lt;a href=&#34;https://docs.ebpf.io/concepts/core/&#34;&gt;BPF CO-RE macro&lt;/a&gt; BPF_CORE_READ. Assuming the target kernel has BTF metadata enabled (CONFIG_DEBUG_INFO_BTF), this &lt;a href=&#34;https://nakryiko.com/posts/bpf-portability-and-co-re/&#34;&gt;vastly improves portability&lt;/a&gt; across different kernel versions, assuming compatible types and verifier constraints.&#xA;&lt;p&gt;Third, we filter out the noise.&#xA;&lt;pre&gt;if (target_flags &amp;amp; PF_KTHREAD) return 0;&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;We explicitly discard true kernel threads. We check if the target has the PF_KTHREAD flag set. If it does, we bail. We only care about user-space processes masquerading as kernel threads because their memory map is gone.&#xA;&lt;p&gt;Fourth, we extract the smoking gun.&#xA;&lt;pre&gt;mm = BPF_CORE_READ(child, mm);&#xA;event-&amp;gt;target_mm_null = mm == NULL;&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;We check if the target&amp;apos;s memory map is null. But a single condition does not equal compromise. You must correlate a pidfd_getfd syscall with a non-kthread target whose mm == NULL during exit. This correlation is a high-signal indicator of the exploitation phase.&#xA;&lt;p&gt;Finally, we ship the evidence.&#xA;&lt;pre&gt;bpf_ringbuf_submit(event, 0);&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;We package this high-fidelity evidence and push it asynchronously to user-space using a BPF ring buffer.&#xA;&lt;p&gt;If you are not in a position to deploy custom eBPF sensors, you need to rely on static mitigation while operations applies the kernel patches.&#xA;&lt;p&gt;Because pidfd_getfd relies on &lt;a href=&#34;https://www.kicksecure.com/wiki/Ptrace_scope&#34;&gt;ptrace authorization&lt;/a&gt;, the &lt;a href=&#34;https://www.kernel.org/doc/Documentation/security/Yama.txt&#34;&gt;Yama Linux Security Module&lt;/a&gt; governs it. By default, most distributions ship with &lt;a href=&#34;https://linux-audit.com/protect-ptrace-processes-kernel-yama-ptrace_scope/&#34;&gt;kernel.yama.ptrace_scope = 1&lt;/a&gt;.&#xA;&lt;p&gt;Level 1 allows a parent process to attach to its child. Since the attacker&amp;apos;s script spawned the target SUID binary, the attacker is technically the parent. Yama happily assumes this is safe and allows the attach. The race condition succeeds.&#xA;&lt;p&gt;You can break the chain entirely by raising the scope to Admin-only attach.&#xA;&lt;pre&gt;sysctl -w kernel.yama.ptrace_scope=2&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;At Level 2, Yama strictly requires the CAP_SYS_PTRACE capability for any process attachment. The unprivileged attacker does not have this capability. The kernel returns -EPERM immediately, rendering the race unreachable.&#xA;&lt;p&gt;Keep in mind that you can dynamically lower the scope from 2 to 1 at runtime. But if you set it to 3 to disable ptrace entirely, you cannot lower it again without rebooting the host. Plan accordingly.&#xA;&lt;p&gt;The risk does not end on monolithic servers. Containers share the underlying worker node kernel. An unprivileged pod can pack its own SUID binary and attack the host kernel from the inside.&#xA;&lt;p&gt;The &lt;a href=&#34;https://docs.docker.com/engine/security/seccomp/&#34;&gt;RuntimeDefault&lt;/a&gt; seccomp profile acts as a highly effective surface reduction. In many standard Docker or containerd setups, it blocks the pidfd_getfd syscall entirely, though this remains environment-specific.&#xA;&lt;p&gt;But there is a trap in &lt;a href=&#34;https://kubernetes.io/docs/concepts/security/pod-security-standards/&#34;&gt;Kubernetes Pod Security Standards (PSS)&lt;/a&gt;. The Baseline standard allows minimal/default pod configs which can be an illusion of security in this context &lt;a href=&#34;https://juliet.sh/blog/cve-2026-46333-kubernetes-eks-bottlerocket-seccomp-pidfd&#34;&gt;Juliet Security&lt;/a&gt;. PSS Baseline explicitly prohibits &amp;quot;Unconfined&amp;quot; profiles, but it allows you to leave the seccomp field &lt;a href=&#34;https://www.reddit.com/r/kubernetes/comments/1tg1cd8/cve202646333_in_kubernetes_unset_seccomp_let_pods/&#34;&gt;empty (unset)&lt;/a&gt;.&#xA;&lt;p&gt;Unless kubelet &lt;a href=&#34;https://kubernetes.io/docs/tutorials/security/seccomp/&#34;&gt;seccompDefault&lt;/a&gt; is enabled, an unset seccomp field can translate to Unconfined. The vulnerability is totally exposed.&#xA;&lt;p&gt;The PSS Restricted profile is designed to stop this attack. It requires seccomp to be explicitly set to &lt;a href=&#34;https://docs.docker.com/engine/security/seccomp/&#34;&gt;RuntimeDefault&lt;/a&gt; or an approved Localhost profile, and it also forces allowPrivilegeEscalation: false, which activates NoNewPrivs and nullifies the effect of any SUID binary inside the container.&#xA;&lt;p&gt;If a vulnerable host was exposed to untrusted local users before patching, assume compromise.&#xA;&lt;p&gt;You must rotate all SSH host private keys. If host-based authentication is not strictly required, remove the SUID bit from utilities like ssh-keysign.&#xA;&lt;pre&gt;chmod u-s /usr/lib/openssh/ssh-keysign&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;User-space logs describe what the process admitted doing. Kernel state describes what actually happened.&#xA;</description>
    </item>
    <item>
      <title>Text In, HTML Out</title>
      <link>https://enzoventuri.com/posts/2026-06-27-text-in-html-out/</link>
      <guid>https://enzoventuri.com/posts/2026-06-27-text-in-html-out/</guid>
      <pubDate>Sat, 27 Jun 2026 00:00:00 +0000</pubDate>
      <description>&lt;h1&gt;Text In, HTML Out&lt;/h1&gt;&#xA;&lt;p&gt;This is a blog.&#xA;&lt;p&gt;It is made of text, which appears to be a difficult architectural concept now.&#xA;&lt;p&gt;The files end in .godoc. Go parses them with go/doc/comment. A tiny program wraps the result in html/template, writes static files to public, emits RSS and a sitemap, then stops.&#xA;&lt;p&gt;That is the system.&#xA;&lt;p&gt;No dashboard. No database. No admin panel. No plugin bazaar. No content model pretending to be a worldview. No YAML altar where simple ideas go to become incident reports.&#xA;&lt;p&gt;Text goes in. HTML comes out.&#xA;&lt;p&gt;The engine is embarrassing.&#xA;&lt;pre&gt;var parser comment.Parser&#xA;doc := parser.Parse(src)&#xA;&#xA;var printer comment.Printer&#xA;html := printer.HTML(doc)&#xA;&lt;/pre&gt;&#xA;&lt;p&gt;That is most of the trick.&#xA;&lt;p&gt;If I need prose, I write prose. If I need HTML, I write HTML. Not a shortcode. Not a component. Not a sacred template partial with three undocumented parameters.&#xA;&lt;p&gt;There is a &amp;lt;raw&amp;gt; escape hatch for trusted HTML, because HTML is not contraband and a personal blog does not need a committee to approve a figure tag.&#xA;&lt;p&gt;The generator reads content, copies static files, renders .godoc files, wraps pages, lists sibling posts from index.godoc, writes RSS, writes a sitemap, and stops.&#xA;&lt;p&gt;Stopping is a feature.&#xA;&lt;p&gt;Most tools do not stop. They discover themes. Then taxonomies. Then assets. Then pipelines. Then front matter. Then migrations. Then a Github issue where someone explains why your paragraph did not render because the date field had opinions.&#xA;&lt;p&gt;This is for text.&#xA;&lt;p&gt;The server does not need to think. It needs to hand over files. Static files are portable, cacheable, inspectable, and boring in the healthy way. Boring is what you want from publishing infrastructure unless your hobby is apologizing to your future self.&#xA;&lt;p&gt;Design is not absent here. It is just not performing.&#xA;&lt;p&gt;The page has words. The words are readable. Links are links. Titles are titles. The browser can do its job without being escorted by a JavaScript framework wearing a headset.&#xA;&lt;p&gt;Every feature sounds reasonable alone.&#xA;&lt;p&gt;Tags sound reasonable. Pagination sounds reasonable. Draft states sound reasonable. Image processing sounds reasonable. Search sounds reasonable. Themes sound reasonable. Then one day you are reading release notes to publish six paragraphs about a network interface.&#xA;&lt;p&gt;No.&#xA;&lt;p&gt;This is a blog.&#xA;&lt;p&gt;It parses text.&#xA;&lt;p&gt;It writes HTML.&#xA;&lt;p&gt;That should not feel radical. The fact that it does is the bug.&#xA;</description>
    </item>
  </channel>
</rss>
