<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>pwn::musings &amp; syndications</title><description>Posts and syndications feed</description><link>https://pwnwriter.me/</link><image><url>https://pwnwriter.me/favicon-lg.png</url><title>pwn::musings logo: a simple tree icon formed by light lines on a dark background</title><link>https://pwnwriter.me</link></image><item><title>One Flake to Hack Them All</title><link>https://pwnwriter.me/notes/notes-cyber-showdown-nix/</link><guid isPermaLink="true">https://pwnwriter.me/notes/notes-cyber-showdown-nix/</guid><pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I recently played &lt;strong&gt;Cyber Showdown CTF&lt;/strong&gt; on a pure Nix setup, and it convinced me that Nix is one of the best things to happen to my CTF workflow. It also broke a few binaries in ways that taught me more about how Linux actually loads a program than any blog post ever did.&lt;/p&gt;
&lt;p&gt;So this is two things at once. It is a practical guide to running a CTF lab on Nix: how to set it up, the everyday commands, and the advanced moves most people never find. And it is an honest field report of the friction, with real terminal output as proof, because a guide that only shows the happy path is lying to you.&lt;/p&gt;
&lt;p&gt;If you have never touched Nix, do not worry. I will explain the mental model before the machinery, and every command has a reason attached.&lt;/p&gt;
&lt;p&gt;Before any of the technical detail: my team, &lt;strong&gt;TU-2026-Team-08&lt;/strong&gt; (team motto, &quot;win win win&quot;), finished first, closing out the event with 20,951 points. Every flag came out of the Nix shell this post is about, so take the rest as proof the setup holds up under real competition pressure and not just on a quiet afternoon.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/cyber-showdown/scoreboard-first.jpeg&quot; alt=&quot;Cyber Showdown scoreboard partway through the event, TU-2026-Team-08 already out in front&quot;&gt;&lt;/p&gt;
&lt;h2&gt;Why bother? The problem Nix actually solves&lt;/h2&gt;
&lt;p&gt;Every CTF player knows the ritual. A challenge drops, you clone an exploit, and then:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ python3 solve.py
ModuleNotFoundError: No module named &apos;pwn&apos;
$ radare2 chall
zsh: command not found: radare2
$ sudo apt install volatility3
E: Unable to locate package volatility3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Twenty minutes gone before you have looked at a single byte. Worse, your machine slowly becomes a landfill: three Python versions, a dozen Go recon tools on your &lt;code&gt;PATH&lt;/code&gt; forever, a &lt;code&gt;pip&lt;/code&gt; environment held together with hope. Six months later you reopen an old writeup and the commands no longer reproduce because a tool silently updated under you.&lt;/p&gt;
&lt;p&gt;There are three distinct problems hiding in there:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Pollution.&lt;/strong&gt; You do not want &lt;code&gt;sqlmap&lt;/code&gt;, &lt;code&gt;masscan&lt;/code&gt;, and forty recon binaries living on your host permanently. You want them while you work and gone afterward.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Irreproducibility.&lt;/strong&gt; &quot;Works on my machine&quot; is a curse. Tool versions drift, and your old solve scripts rot.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Non-portability.&lt;/strong&gt; My Linux tower and my MacBook were never set up the same way, so half my muscle memory broke when I switched.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Nix fixes all three at once, and it does so with a single idea.&lt;/p&gt;
&lt;h2&gt;The one idea behind Nix&lt;/h2&gt;
&lt;p&gt;Here is the whole mental model, and it is worth internalizing before you copy any config.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Core idea.&lt;/strong&gt; In Nix, every package is built from a pure description of its inputs and lands in a read-only store path like &lt;code&gt;/nix/store/abc123...-radare2-5.9.0&lt;/code&gt;. Nothing installs into &lt;code&gt;/usr/bin&lt;/code&gt;. A &quot;shell&quot; or an &quot;environment&quot; is just a set of these store paths stitched onto your &lt;code&gt;PATH&lt;/code&gt; temporarily.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Three consequences fall straight out of that, and they map exactly onto the three problems above:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Because packages live in the store and not on your host, an environment is &lt;strong&gt;isolated&lt;/strong&gt; and &lt;strong&gt;disposable&lt;/strong&gt;. It exists while you are in it.&lt;/li&gt;
&lt;li&gt;Because a package is a pure function of its inputs, and those inputs are pinned in a lock file, the environment is &lt;strong&gt;reproducible&lt;/strong&gt;. Same inputs, same bytes, next year, on any machine.&lt;/li&gt;
&lt;li&gt;Because that same description evaluates per system, one file gives you the same tools on &lt;strong&gt;Linux and macOS, x86 and ARM&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That is the entire pitch. Everything below is mechanics.&lt;/p&gt;
&lt;h2&gt;The setup: one flake, one devshell&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;flake&lt;/strong&gt; is a file, &lt;code&gt;flake.nix&lt;/code&gt;, that declares inputs (where packages come from) and outputs (what you want built). For CTF, the output you care about is a &lt;strong&gt;devShell&lt;/strong&gt;: an environment you drop into that has your tools on &lt;code&gt;PATH&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Here is the skeleton I actually use. The one non-obvious trick is &lt;code&gt;forAllSystems&lt;/code&gt;, which builds the same shell for every platform so I never maintain a separate Mac config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  description = &quot;Reproducible CTF / bug-hunting lab&quot;;

  inputs.nixpkgs.url = &quot;github:nixos/nixpkgs/nixos-unstable&quot;;

  outputs = { nixpkgs, ... }:
    let
      systems = [ &quot;x86_64-linux&quot; &quot;aarch64-linux&quot; &quot;x86_64-darwin&quot; &quot;aarch64-darwin&quot; ];
      forAllSystems = f: nixpkgs.lib.genAttrs systems (system:
        f (import nixpkgs {
          inherit system;
          config.allowUnfree = true;   # opt into unfree tools once (Burp, etc.)
        }));
    in {
      devShells = forAllSystems (pkgs: {
        default = pkgs.mkShell {
          packages = with pkgs; [
            # the whole arsenal goes here
          ];
        };
      });
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two small decisions in there pay off constantly:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;config.allowUnfree = true&lt;/code&gt;&lt;/strong&gt; set once at the &lt;code&gt;import&lt;/code&gt; site. Some tools carry unfree licenses, and this saves you from fighting that wall mid event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The four-entry &lt;code&gt;systems&lt;/code&gt; list.&lt;/strong&gt; Having &lt;code&gt;aarch64-darwin&lt;/code&gt; in there is why the exact same shell works on Apple Silicon with zero extra effort.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Now the part that matters more than people admit: &lt;strong&gt;group the package list by phase, not alphabetically.&lt;/strong&gt; When you are mid challenge with adrenaline up, a flat sorted list is useless. A grouped one is a map:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;packages = with pkgs; [
  # recon
  subfinder amass assetfinder dnsx httpx katana gau waybackurls gowitness
  # web / fuzzing
  ffuf gobuster feroxbuster dalfox arjun sqlmap
  # network / scanning
  nmap naabu masscan
  # reversing / binary
  file binutils gdb ltrace strace radare2 binwalk upx patchelf gcc qemu
  # forensics
  tshark sqlite
  # source &amp;#x26; secret analysis
  semgrep gitleaks trufflehog ripgrep fd jq yq
  # runtimes / glue
  python3 nodejs bun esbuild uv
  unzip zstd age
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is a quiet bonus here. When you reopen this repo in a year, &lt;strong&gt;the flake documents how you approach a target.&lt;/strong&gt; The categories are your methodology, written down.&lt;/p&gt;
&lt;h2&gt;direnv: making it effortless&lt;/h2&gt;
&lt;p&gt;A flake is nice, but typing &lt;code&gt;nix develop&lt;/code&gt; every time you &lt;code&gt;cd&lt;/code&gt; is friction, and friction is what kills good habits. Kill it with &lt;a href=&quot;https://direnv.net/&quot;&gt;direnv&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;One file, &lt;code&gt;.envrc&lt;/code&gt;, with one line:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;use flake
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first time you enter the folder, direnv asks permission once:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ direnv allow
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;From then on, the shell activates &lt;strong&gt;automatically&lt;/strong&gt; when you enter the directory and deactivates when you leave. Your tools appear on &lt;code&gt;cd&lt;/code&gt; in and vanish on &lt;code&gt;cd&lt;/code&gt; out. The environment becomes invisible, which is exactly what you want.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Speed tip.&lt;/strong&gt; Pair it with &lt;code&gt;nix-direnv&lt;/code&gt;, which caches the evaluated environment so re-entering a folder is instant instead of re-evaluating the flake each time. On a big toolset this is the difference between &quot;instant&quot; and &quot;annoying.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;The Python escape hatch: uv&lt;/h2&gt;
&lt;p&gt;Here is a hard-won rule: &lt;strong&gt;do not try to package fast-moving Python security tools as Nix derivations mid competition.&lt;/strong&gt; pwntools plugins, Volatility 3, one-off PoCs with weird pinned deps: packaging those the Nix way is more effort than the challenge itself.&lt;/p&gt;
&lt;p&gt;The clean split is: &lt;strong&gt;Nix for system tools, &lt;code&gt;uv&lt;/code&gt; for the Python churn.&lt;/strong&gt; So &lt;code&gt;uv&lt;/code&gt; goes in the flake, and it manages throwaway Python environments that are as disposable as the shell around them.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;# run Volatility 3 without installing anything permanent
$ uvx --from volatility3 vol -f memory.raw windows.info

# or a scratch venv for one challenge
$ uv venv &amp;#x26;&amp;#x26; uv pip install pwntools requests
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;uv&lt;/code&gt; is extremely fast and its environments live in the challenge folder, so they disappear when you are done. You get a reproducible base with a flexible top layer. This is literally how I ran Volatility on a memory forensics box during the event without leaving a trace on the host.&lt;/p&gt;
&lt;h2&gt;Where it got interesting: running foreign binaries&lt;/h2&gt;
&lt;p&gt;Everything above is the happy path. Now the friction, because this is the part that actually teaches you something.&lt;/p&gt;
&lt;p&gt;CTF binaries are, by definition, foreign binaries. And a clean, isolated Nix environment is precisely what makes foreign binaries hard to run. That is not a bug. It is the isolation doing its job. But you need to know the moves.&lt;/p&gt;
&lt;h3&gt;Gotcha 1: the binary that &quot;does not exist&quot;&lt;/h3&gt;
&lt;p&gt;First pwn challenge of the event, a textbook &lt;code&gt;ret2win&lt;/code&gt;. I built the exploit, went to test locally, and hit this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ ./rpz_gate_1
zsh: no such file or directory: ./rpz_gate_1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The file is right there. &lt;code&gt;ls&lt;/code&gt; shows it. So what is missing? Ask &lt;code&gt;file&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ file rpz_gate_1
rpz_gate_1: ELF 64-bit LSB executable, x86-64, dynamically linked,
  interpreter ./glibc/ld-linux-x86-64.so.2, not stripped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;There is the answer, in the word &lt;strong&gt;interpreter&lt;/strong&gt;. When you run a dynamically linked ELF, the kernel does not run your binary first. It runs the &lt;strong&gt;dynamic loader&lt;/strong&gt; named in the ELF&apos;s interpreter field, and that loader maps your binary and its libraries into memory. The &quot;no such file or directory&quot; error was never about &lt;code&gt;rpz_gate_1&lt;/code&gt;. It was about the &lt;strong&gt;loader path not existing.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On a normal distro the interpreter is &lt;code&gt;/lib64/ld-linux-x86-64.so.2&lt;/code&gt; and it is always present. On Nix there is no &lt;code&gt;/lib64&lt;/code&gt; at all, because nothing installs to those global paths. This particular challenge was even sneakier: it shipped its own loader at a &lt;strong&gt;relative&lt;/strong&gt; path, &lt;code&gt;./glibc/ld-linux-x86-64.so.2&lt;/code&gt;, so it only resolves if your working directory is the challenge directory.&lt;/p&gt;
&lt;p&gt;Three fixes, easiest first. Learn all three because each wins in a different situation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix 1, just &lt;code&gt;cd&lt;/code&gt; in.&lt;/strong&gt; Works when the binary uses a relative loader like this one:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ cd gate1/        # so ./glibc/ld-linux... resolves relative to here
$ python3 solve/exp.py
You won the race!
Here is your key: HTB{f4k3_fl4g_4_t35t1ng}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Fix 2, patch the interpreter.&lt;/strong&gt; This is why &lt;code&gt;patchelf&lt;/code&gt; is in my flake. You rewrite the binary&apos;s interpreter field to point at a loader that actually exists in the Nix store:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;# point it at glibc&apos;s loader from the store
$ patchelf --set-interpreter \
    &quot;$(nix eval --raw nixpkgs#glibc)/lib/ld-linux-x86-64.so.2&quot; ./somebin
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Use this when you need the binary to run from anywhere, not just its own folder.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix 3, &lt;code&gt;nix-ld&lt;/code&gt;.&lt;/strong&gt; The set-and-forget option for NixOS. It installs a small stub at the standard &lt;code&gt;/lib64/ld-linux-x86-64.so.2&lt;/code&gt; path that redirects to libraries you declare in your config. Enable it once and most dynamically linked binaries simply run. If you play CTFs regularly on NixOS, this is worth doing. On non-NixOS, the quick equivalent is &lt;code&gt;steam-run ./bin&lt;/code&gt;, which drops the binary into an FHS sandbox where the traditional paths exist.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;When to reach for which.&lt;/strong&gt; &lt;code&gt;cd&lt;/code&gt; when the binary ships its own loader. &lt;code&gt;patchelf&lt;/code&gt; when it must run anywhere. &lt;code&gt;nix-ld&lt;/code&gt; or &lt;code&gt;steam-run&lt;/code&gt; when you never want to think about this class of problem again.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;And the quieter Nix lesson from this challenge: &lt;code&gt;pwntools&lt;/code&gt; was not something I wanted to negotiate with nixpkgs about mid event, so it is not in the flake. &lt;code&gt;uv&lt;/code&gt; is, and &lt;code&gt;uv pip install pwntools&lt;/code&gt; in the challenge folder solved it in seconds. The rule from earlier, proven under fire.&lt;/p&gt;
&lt;p&gt;Here is what a solve actually looked like from inside that shell. This reversing challenge shipped a loader that dropped an embedded &lt;code&gt;.so&lt;/code&gt; and &lt;code&gt;LD_PRELOAD&lt;/code&gt;ed it into &lt;code&gt;/bin/true&lt;/code&gt;, and the &lt;code&gt;.so&lt;/code&gt; constructor built the flag with a run of &lt;code&gt;mov byte [rip+x], imm&lt;/code&gt; before raising a signal to trip up debuggers. No need to run it: just carve the &lt;code&gt;.so&lt;/code&gt; out and scrape the immediate bytes. Editor, notes, and shell, all one &lt;code&gt;direnv allow&lt;/code&gt; away.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/cyber-showdown/solve-workspace.jpeg&quot; alt=&quot;A reversing solve in progress, solve.py carving immediate bytes out of an embedded .so to recover the flag&quot;&gt;&lt;/p&gt;
&lt;h3&gt;Gotcha 2: the toolchain that only speaks x86&lt;/h3&gt;
&lt;p&gt;Later there was a reversing challenge whose binary was ARM64 while I was on x86:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ file ghost
ghost: ELF 64-bit LSB pie executable, ARM aarch64, dynamically linked, not stripped
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Two separate Nix papercuts, back to back.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Papercut A: &lt;code&gt;objdump&lt;/code&gt; is single-target.&lt;/strong&gt; My reflex is &lt;code&gt;objdump -d&lt;/code&gt;. But the &lt;code&gt;binutils&lt;/code&gt; in your shell is built for your target, so it refuses a different architecture:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ objdump -d ghost
objdump: can&apos;t disassemble for architecture UNKNOWN!
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You could pull a cross-binutils into the flake with &lt;code&gt;pkgsCross.aarch64-multiplatform.binutils&lt;/code&gt;, but that is a yak-shave in the middle of a competition. The pragmatic move: &lt;strong&gt;&lt;code&gt;radare2&lt;/code&gt; is multi-architecture out of the box.&lt;/strong&gt; It was already in the flake and read the ARM64 without complaint:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ r2 -A ghost
[0x00000abc]&gt; pdf @ main    # disassembles aarch64, no cross toolchain needed
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rule of thumb.&lt;/strong&gt; On a Nix devshell, treat &lt;code&gt;objdump&lt;/code&gt; and &lt;code&gt;gdb&lt;/code&gt; as single-target. For anything cross-architecture, default to &lt;code&gt;radare2&lt;/code&gt; or Ghidra and skip the cross-compilation rabbit hole entirely.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;strong&gt;Papercut B: running it needs a sysroot.&lt;/strong&gt; The challenge gimmick was that the flag gets assembled in memory and written to &lt;code&gt;/dev/null&lt;/code&gt;, so running it shows nothing. Three bytes of the flag came from &lt;strong&gt;syscall return values&lt;/strong&gt;, so I actually wanted to run it under emulation and watch. &lt;code&gt;qemu&lt;/code&gt; is in the flake:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ qemu-aarch64 ghost
qemu-aarch64: Could not open &apos;/lib/ld-linux-aarch64.so.1&apos;: No such file or directory
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same loader problem as before, now cross-architecture. QEMU needs to find the aarch64 glibc, which lives deep in the Nix store. You hand it the location with &lt;code&gt;-L&lt;/code&gt;, and here is the single most useful trick in this whole post for locating it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ SYSROOT=$(nix eval --raw nixpkgs#pkgsCross.aarch64-multiplatform.glibc)
$ QEMU_STRACE=1 qemu-aarch64 -L &quot;$SYSROOT&quot; ghost 2&gt;&amp;#x26;1 \
    | grep -E &apos;close|open|read|write&apos;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;nix eval --raw nixpkgs#&amp;#x3C;package&gt;&lt;/code&gt; pattern prints the &lt;strong&gt;store path&lt;/strong&gt; of any package as a plain string, so you can feed it to a tool that knows nothing about Nix. Loaders, sysroots, library headers, whatever a dumb tool needs, Nix already has it built. You just have to point the tool at the path.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;And then &lt;code&gt;strace&lt;/code&gt; lied to me.&lt;/strong&gt; I tried to watch the syscalls the normal way, and it went blind the instant the program ran. The reason is genuinely funny: the program calls &lt;code&gt;close(2)&lt;/code&gt;, which closes standard error, and standard error is exactly where &lt;code&gt;strace&lt;/code&gt; writes its own output. By closing its fd, the program silenced its own tracer.&lt;/p&gt;
&lt;p&gt;The fix was refreshingly low tech. The kernel returns the same values on any architecture, so I reproduced the same &lt;code&gt;close&lt;/code&gt;, &lt;code&gt;open&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;write&lt;/code&gt; sequence in a tiny C program and compiled it with the flake&apos;s &lt;code&gt;gcc&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ gcc -o repro solve/repro.c &amp;#x26;&amp;#x26; ./repro
0 0 -1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Those three values decoded to the three mystery bytes and gave up the flag. The recurring lesson across both challenges: &lt;strong&gt;when the fancy dynamic tool fights the environment, a static or offline reproduction usually wins.&lt;/strong&gt; Reading the loader field beat guessing. A C repro beat &lt;code&gt;strace&lt;/code&gt;. Boring is fast.&lt;/p&gt;
&lt;h2&gt;Advanced moves worth knowing&lt;/h2&gt;
&lt;p&gt;These are the things that took me from &quot;surviving on Nix&quot; to &quot;faster because of Nix.&quot;&lt;/p&gt;
&lt;h3&gt;Grab a tool for one challenge without touching your flake&lt;/h3&gt;
&lt;p&gt;You do not have to edit &lt;code&gt;flake.nix&lt;/code&gt; every time you want something once. Two commands cover it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;# drop a tool onto PATH for this shell only
$ nix shell nixpkgs#ghidra

# or run it once and throw it away
$ nix run nixpkgs#ghidra
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Neither installs anything permanently. This is perfect for a heavy tool like Ghidra that you want for exactly one reversing challenge and never again.&lt;/p&gt;
&lt;h3&gt;Keep separate shells for separate jobs&lt;/h3&gt;
&lt;p&gt;A quick web box should not have to load QEMU and GDB. Expose more than one devShell and pick per task:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;devShells = forAllSystems (pkgs: {
  default = pkgs.mkShell { /* everything */ };
  recon   = pkgs.mkShell { packages = with pkgs; [ subfinder httpx ffuf ]; };
  re      = pkgs.mkShell { packages = with pkgs; [ radare2 gdb qemu patchelf ]; };
});
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ nix develop .#recon      # only the recon tools, loads faster
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Pin the world so your writeup reproduces forever&lt;/h3&gt;
&lt;p&gt;This is the payoff that other approaches cannot match. Your flake has a companion &lt;strong&gt;&lt;code&gt;flake.lock&lt;/code&gt;&lt;/strong&gt; that records the exact revision of nixpkgs you used, which pins every tool version transitively.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Commit &lt;code&gt;flake.lock&lt;/code&gt; next to your writeup.&lt;/strong&gt; Then the commands in that writeup are reproducible byte for byte, years later, on hardware you do not own yet. When you want fresh tools, you update deliberately:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-shell-session&quot;&gt;$ nix flake update      # bump the lock on your schedule, not the CTF&apos;s
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Do this when you choose, not the night before a competition. Pinned means pinned.&lt;/p&gt;
&lt;h3&gt;When a tool truly hates Nix, give it an FHS jail&lt;/h3&gt;
&lt;p&gt;Some tools assume a traditional Linux filesystem: pip wheels with precompiled native extensions, certain commercial binaries, the odd installer that hardcodes &lt;code&gt;/usr/lib&lt;/code&gt;. Rather than patch each one, build a fake standard-filesystem sandbox with &lt;code&gt;buildFHSEnv&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;# an FHS shell where /usr, /lib, /lib64 exist like a normal distro
(pkgs.buildFHSEnv {
  name = &quot;ctf-fhs&quot;;
  targetPkgs = p: with p; [ python3 gcc glibc zlib ];
}).env
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Enter that and stubborn binaries behave as if they are on Ubuntu, while your host stays clean. This is the escape hatch of last resort, and it is good to know it exists before you need it at 2 a.m.&lt;/p&gt;
&lt;h3&gt;Share one flake with your whole team&lt;/h3&gt;
&lt;p&gt;For team events this is quietly a superpower. Commit the flake to your team repo, and every teammate who runs &lt;code&gt;nix develop&lt;/code&gt; gets the &lt;strong&gt;identical toolset at identical versions.&lt;/strong&gt; No more &quot;it works for me but not for you.&quot; The environment stops being a variable in your debugging.&lt;/p&gt;
&lt;h2&gt;The cheat sheet I wish I had started with&lt;/h2&gt;
&lt;p&gt;Everything above, compressed into the table I now keep in a browser tab:&lt;/p&gt;


















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Symptom&lt;/th&gt;&lt;th&gt;Real cause&lt;/th&gt;&lt;th&gt;Fix&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;./bin: no such file or directory&lt;/code&gt;, but the file exists&lt;/td&gt;&lt;td&gt;ELF interpreter path missing on Nix&lt;/td&gt;&lt;td&gt;&lt;code&gt;patchelf --set-interpreter ...&lt;/code&gt;, &lt;code&gt;cd&lt;/code&gt; to a shipped loader, or &lt;code&gt;nix-ld&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;qemu-...: Could not open &apos;/lib/ld-...&apos;&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Cross-arch loader not found&lt;/td&gt;&lt;td&gt;&lt;code&gt;qemu-&amp;#x3C;arch&gt; -L &quot;$(nix eval --raw nixpkgs#pkgsCross.&amp;#x3C;arch&gt;.glibc)&quot; ./bin&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;objdump: can&apos;t disassemble for architecture UNKNOWN!&lt;/code&gt;&lt;/td&gt;&lt;td&gt;nix binutils is single-target&lt;/td&gt;&lt;td&gt;use &lt;code&gt;radare2&lt;/code&gt; or Ghidra (multi-arch)&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;pip install&lt;/code&gt; fights nixpkgs&lt;/td&gt;&lt;td&gt;fast-moving Python packaging&lt;/td&gt;&lt;td&gt;keep &lt;code&gt;uv&lt;/code&gt; in the flake, &lt;code&gt;uv pip install ...&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;strace&lt;/code&gt; goes blind after &lt;code&gt;close(2)&lt;/code&gt;&lt;/td&gt;&lt;td&gt;the program closed strace&apos;s own fd&lt;/td&gt;&lt;td&gt;reproduce the syscalls in a tiny C program&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Need a tool once&lt;/td&gt;&lt;td&gt;do not want to edit the flake&lt;/td&gt;&lt;td&gt;&lt;code&gt;nix shell nixpkgs#tool&lt;/code&gt; or &lt;code&gt;nix run nixpkgs#tool&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;A binary assumes a normal filesystem&lt;/td&gt;&lt;td&gt;it hardcodes &lt;code&gt;/usr&lt;/code&gt;, &lt;code&gt;/lib64&lt;/code&gt;&lt;/td&gt;&lt;td&gt;wrap it in &lt;code&gt;buildFHSEnv&lt;/code&gt; or run &lt;code&gt;steam-run&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Locate glibc, a loader, a sysroot&lt;/td&gt;&lt;td&gt;Nix stores it in &lt;code&gt;/nix/store&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;nix eval --raw nixpkgs#&amp;#x3C;pkg&gt;&lt;/code&gt; prints the path&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h2&gt;Would I run a CTF on Nix again?&lt;/h2&gt;
&lt;p&gt;Without hesitation, yes. The detours in this post are a one-time tax you pay while the reflexes wire in: run &lt;code&gt;file&lt;/code&gt; first, patch or point the loader, reach for &lt;code&gt;radare2&lt;/code&gt; on anything cross-arch, use &lt;code&gt;uv&lt;/code&gt; for Python, and drop to a static reproduction when a live tool fights you. Once those are habits, the payoff is large and permanent.&lt;/p&gt;
&lt;p&gt;Every challenge folder becomes reproducible, disposable, and identical across my Linux box and my Mac. My gate1 exploit will still run byte for byte next year because &lt;code&gt;flake.lock&lt;/code&gt; pins the entire world it needs. Nothing lands on my host, and the whole toolbox closes behind me when I &lt;code&gt;cd&lt;/code&gt; out.&lt;/p&gt;
&lt;p&gt;The tools being missing by default felt like friction in the moment. By the end of Cyber Showdown it felt like the entire point.&lt;/p&gt;
&lt;p&gt;If you are starting out, you do not need to &quot;learn Nix&quot; first. Copy a &lt;code&gt;flake.nix&lt;/code&gt;, add an &lt;code&gt;.envrc&lt;/code&gt; with &lt;code&gt;use flake&lt;/code&gt;, run &lt;code&gt;direnv allow&lt;/code&gt;, and keep this cheat sheet open. That gets you ninety percent of the value on day one. The rabbit hole is deep, but the entrance is a two-line file, and now you know exactly what bites.&lt;/p&gt;
&lt;p&gt;And, for the record, we took first. Here is the final team board and the sticker haul to prove we showed up.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/cyber-showdown/team-stats.jpeg&quot; alt=&quot;HackTheBox team page for TU-2026-Team-08, per-player points and flags&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/cyber-showdown/htb-swag.jpeg&quot; alt=&quot;HackTheBox stickers and patches collected at Cyber Showdown&quot;&gt;&lt;/p&gt;</content:encoded></item><item><title>Ditching Cloudflared for Tailscale</title><link>https://pwnwriter.me/notes/tailscale-homelab/</link><guid isPermaLink="true">https://pwnwriter.me/notes/tailscale-homelab/</guid><pubDate>Mon, 07 Sep 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A while back I wrote about SSHing into my home server from anywhere using a
&lt;a href=&quot;https://pwnwriter.me/notes/cloudflared-ssh&quot;&gt;Cloudflare Tunnel&lt;/a&gt;. It worked. But the more I lived with it, the more it felt
like the wrong tool.&lt;/p&gt;
&lt;p&gt;Cloudflared needs a domain. It routes my SSH through Cloudflare&apos;s edge. It&apos;s a
daemon babysitting a &lt;code&gt;config.yml&lt;/code&gt;, and every service I want to reach needs its
own &lt;code&gt;ingress&lt;/code&gt; rule. That&apos;s a lot of moving parts, and another company sitting in
the middle of me talking to my own machine.&lt;/p&gt;
&lt;p&gt;So I ripped it out and moved to &lt;a href=&quot;https://tailscale.com/&quot;&gt;Tailscale&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Why it&apos;s better for me&lt;/h2&gt;
&lt;p&gt;Tailscale is a WireGuard mesh. My machines talk to each other &lt;strong&gt;directly&lt;/strong&gt;,
peer to peer, over an encrypted tunnel. No domain, no public IP, no open ports,
no ingress config. Every box gets a &lt;code&gt;100.x&lt;/code&gt; address and a name, and I can reach
&lt;strong&gt;any&lt;/strong&gt; port on it, not just the one I remembered to forward.&lt;/p&gt;
&lt;p&gt;And because everything I own is Nix, I don&apos;t click around an app. I add a line,
rebuild, done.&lt;/p&gt;
&lt;h2&gt;The Linux box (dom)&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;dom&lt;/code&gt; is my NixOS home server. Enabling Tailscale is one line, plus telling the
firewall to trust the tailnet interface so I&apos;m not fighting my own rules.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;# machines/dom/services.nix
{
  # tailscale
  services.tailscale.enable = true;

  # firewall — allow ssh + trust the tailnet, block the rest
  networking.firewall = {
    enable = true;
    allowedTCPPorts = [ 22 ];
    trustedInterfaces = [ &quot;tailscale0&quot; ];
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;services.tailscale.enable&lt;/code&gt; installs the daemon, starts it at boot, and opens
the UDP port it needs to punch through NAT. Rebuild:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nixos-rebuild switch --flake .#dom
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;The Mac (whiterose)&lt;/h2&gt;
&lt;p&gt;My MacBook is a nix-darwin machine. nix-darwin has the same option, it just
runs the daemon under launchd. I only wanted it on the laptop, not the Mac Mini,
so I dropped it straight into that machine&apos;s config:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-diff&quot;&gt; # machines/whiterose/default.nix
   modules = [
     {
       nixpkgs.config.allowUnfree = true;
     }
+
+    # MacBook-specific: Tailscale client to reach the homelab from anywhere
+    {
+      services.tailscale.enable = true;
+    }

     ./../../modules/darwin.nix
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;darwin-rebuild switch --flake .#whiterose
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Log in&lt;/h2&gt;
&lt;p&gt;Enabling the service installs everything, but the node still has to join the
tailnet. Once per machine:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tailscale up
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It prints a login URL, I approve it in the browser, and the box shows up in my
tailnet. On macOS I also set myself as operator so I stop needing &lt;code&gt;sudo&lt;/code&gt; for
everyday commands:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo tailscale set --operator=$USER
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now &lt;code&gt;tailscale status&lt;/code&gt; lists everything:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;100.124.21.97   whiterose   macOS   -
100.109.246.23  dom         linux   active; direct 71.121.175.97:41641
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;direct&lt;/code&gt; is the word I wanted. No relay, ~12ms, straight laptop-to-server.&lt;/p&gt;
&lt;h2&gt;Point SSH at the tailnet&lt;/h2&gt;
&lt;p&gt;My &lt;code&gt;ssh dom&lt;/code&gt; alias was still hardcoded to the LAN IP, so it only worked at home.
The whole point was reaching it from anywhere, so I swapped it for the MagicDNS
name. That name resolves on my LAN &lt;em&gt;and&lt;/em&gt; from a coffee shop, Tailscale figures
out the route.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-diff&quot;&gt; # modules/ssh.nix
   &quot;dom&quot; = {
     User = &quot;pwn&quot;;
-    Hostname = &quot;192.168.1.213&quot;;
+    # Tailscale MagicDNS name — reachable on LAN and from anywhere.
+    Hostname = &quot;dom&quot;;
   };
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After a rebuild, this just works, from my bed or from another country:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh dom
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No &lt;code&gt;ssh.pwnwriter.me&lt;/code&gt;. No tunnel daemon. No DNS records to manage.&lt;/p&gt;
&lt;h2&gt;I don&apos;t want it running 24/7&lt;/h2&gt;
&lt;p&gt;Here&apos;s the part I actually care about. I don&apos;t want a VPN glued on all day. I
want it up when I need the homelab and gone when I don&apos;t.&lt;/p&gt;
&lt;p&gt;Turns out you don&apos;t fight the daemon for this:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;tailscale down    # off — disconnects from the tailnet
tailscale up      # on  — back in
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;tailscale down&lt;/code&gt; sets &lt;code&gt;WantRunning=false&lt;/code&gt;, and that &lt;strong&gt;sticks across reboots&lt;/strong&gt;.
Turn it off once and it stays off until I explicitly bring it up. The daemon is
still there, but parked, doing nothing.&lt;/p&gt;
&lt;p&gt;Battery? I worried about this and it&apos;s basically a non-issue. Connected and idle,
it&apos;s a small keepalive every ~25s and one long-poll to a relay, nothing you&apos;d
feel on a day&apos;s charge. After &lt;code&gt;tailscale down&lt;/code&gt; it&apos;s effectively zero. The only
thing that actually costs you is using a box as an exit node, so don&apos;t, unless
you mean to.&lt;/p&gt;
&lt;h2&gt;SSH bits worth stealing&lt;/h2&gt;
&lt;p&gt;Two lines in my &lt;code&gt;Host *&lt;/code&gt; block that make daily SSH nicer over the tunnel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;# modules/ssh.nix, the &quot;*&quot; block
ServerAliveInterval = 30;     # don&apos;t let idle sessions die on NAT
ControlMaster = &quot;auto&quot;;       # reuse one connection
ControlPersist = &quot;10m&quot;;       # 2nd+ ssh/scp/rsync are instant
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Multiplexing means the second &lt;code&gt;ssh dom&lt;/code&gt; rides the first tunnel, no re-auth,
near-instant. And the stuff I run constantly:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh dom &apos;docker ps&apos;              # one-off command, no shell
rsync -avP ~/dir/ dom:~/dir/     # resumable folder sync
ssh -L 8080:localhost:80 dom     # reach dom&apos;s :80 at localhost:8080
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;That&apos;s it&lt;/h2&gt;
&lt;p&gt;Cloudflared was a tunnel to one port behind a domain behind a company.
Tailscale is my machines talking to each other, off when I say so, and it&apos;s four
lines of Nix across two hosts.&lt;/p&gt;
&lt;p&gt;The old post stays up for history. This is what I run now.&lt;/p&gt;
&lt;!--links--&gt;</content:encoded></item><item><title>How Nix Actually Works</title><link>https://pwnwriter.me/notes/how-nix-works/</link><guid isPermaLink="true">https://pwnwriter.me/notes/how-nix-works/</guid><pubDate>Sun, 02 Aug 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Most people meet Nix as a package manager, get scared by the syntax, and leave.
That&apos;s a shame, because the whole thing is built on about three ideas. Once they
click, the weird &lt;code&gt;/nix/store/&lt;/code&gt; paths, the reproducibility, the rollbacks, all of
it stops being magic.&lt;/p&gt;
&lt;p&gt;No fluff. Let&apos;s look at what Nix is actually doing under the hood.&lt;/p&gt;
&lt;h2&gt;1. The language is just a calculator for attribute sets&lt;/h2&gt;
&lt;p&gt;Before any packaging, Nix is a small, lazy, purely functional language. No
statements, no loops, no mutation. Every expression evaluates to a value. Open a
repl and poke it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;$ nix repl
nix-repl&gt; 1 + 2
3

nix-repl&gt; &quot;pwn&quot; + &quot;writer&quot;
&quot;pwnwriter&quot;

nix-repl&gt; { name = &quot;nix&quot;; year = 2003; }
{ name = &quot;nix&quot;; year = 2003; }

nix-repl&gt; let x = 10; in x * x
100
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Functions are &lt;code&gt;arg: body&lt;/code&gt;. That colon is the whole syntax.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;nix-repl&gt; square = x: x * x
nix-repl&gt; square 12
144

nix-repl&gt; greet = name: &quot;hello ${name}&quot;
nix-repl&gt; greet &quot;world&quot;
&quot;hello world&quot;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;mark&gt;Everything you write in Nix is one big expression that evaluates to an
attribute set.&lt;/mark&gt; Your whole NixOS config? One attribute set. A package? One
attribute set. That&apos;s the trick to reading any &lt;code&gt;.nix&lt;/code&gt; file: find the set, follow
the attributes.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fun fact:&lt;/strong&gt; Nix is &lt;em&gt;lazy&lt;/em&gt;. Nothing is evaluated until it&apos;s needed. You can
define an infinite value and Nix won&apos;t blink until you touch it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;nix-repl&gt; rec { a = b; b = 1; }.a   # b defined after a, still fine
1
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;2. Everything is a derivation&lt;/h2&gt;
&lt;p&gt;A &lt;strong&gt;derivation&lt;/strong&gt; is Nix&apos;s word for &quot;a recipe to build one thing.&quot; A package, a
config file, a whole OS, all derivations. It&apos;s a pure function:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;derivation = f(inputs) -&gt; output in the store
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same inputs always produce the same output path. Here&apos;s the most minimal
derivation that exists, no nixpkgs, no helpers:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;nix-repl&gt; d = derivation {
            name = &quot;hello&quot;;
            builder = &quot;/bin/sh&quot;;
            args = [ &quot;-c&quot; &quot;echo hi &gt; $out&quot; ];
            system = &quot;x86_64-linux&quot;;
          }

nix-repl&gt; d
«derivation /nix/store/xd6r...-hello.drv»
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Notice it&apos;s a &lt;code&gt;.drv&lt;/code&gt; file, not the result yet. A &lt;code&gt;.drv&lt;/code&gt; is the &lt;em&gt;build plan&lt;/em&gt;,
frozen JSON-ish that lists every input, the builder, and the env. Nix builds the
plan, not your live shell. You can read one raw:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix derivation show nixpkgs#hello | head -20
{
  &quot;/nix/store/...-hello-2.12.1.drv&quot;: {
    &quot;outputs&quot;: { &quot;out&quot;: { &quot;path&quot;: &quot;/nix/store/...-hello-2.12.1&quot; } },
    &quot;inputDrvs&quot;: { ... },
    &quot;env&quot;: { &quot;pname&quot;: &quot;hello&quot;, &quot;version&quot;: &quot;2.12.1&quot;, ... }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;When people say &quot;Nix builds a graph,&quot; this is the graph: &lt;code&gt;.drv&lt;/code&gt; files pointing
at other &lt;code&gt;.drv&lt;/code&gt; files, all the way down to bootstrap.&lt;/p&gt;
&lt;h2&gt;3. Why the store paths look like that&lt;/h2&gt;
&lt;p&gt;Every build lands in &lt;code&gt;/nix/store/&lt;/code&gt; under a name like:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;/nix/store/1q2w3e4r5t...-hello-2.12.1
           └──────┬──────┘ └────┬────┘
              the hash        the name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That prefix is a hash of &lt;strong&gt;every input that went into the build&lt;/strong&gt;: the source,
the compiler, the build flags, the dependencies&apos; hashes, recursively. Change one
flag, get a different hash, get a different path.&lt;/p&gt;
&lt;p&gt;This is the entire foundation. It buys you three things for free:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No dependency hell.&lt;/strong&gt; Two apps needing different OpenSSL versions get two
different store paths. They coexist. There is no &quot;global&quot; version of anything.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Atomic upgrades.&lt;/strong&gt; The new build goes to a &lt;em&gt;new&lt;/em&gt; path. Nothing is
overwritten. Switching is just repointing a symlink.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Caching that can&apos;t lie.&lt;/strong&gt; If the hash matches, the binary is byte-identical
to what that recipe produces. So Nix can download a prebuilt result from a
cache instead of building, and know it&apos;s correct.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Check what a path actually depends on. &lt;code&gt;path-info&lt;/code&gt; only queries paths that are
already built, so realise it first:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix build nixpkgs#hello --no-link      # pull it into the store
$ nix path-info -rsh nixpkgs#hello        # -r recursive, -s size, -h human
/nix/store/wl60...-libiconv-115.100.1     43.7 MiB
/nix/store/b63h...-hello-2.12.3          110.4 KiB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That list is the &lt;strong&gt;closure&lt;/strong&gt;: everything needed to run &lt;code&gt;hello&lt;/code&gt;, nothing global,
nothing implicit. The exact deps differ by platform: this is macOS, so &lt;code&gt;hello&lt;/code&gt;
leans on &lt;code&gt;libiconv&lt;/code&gt;; on Linux you&apos;d see &lt;code&gt;glibc&lt;/code&gt; and friends instead. Either way,
it&apos;s the &lt;em&gt;whole&lt;/em&gt; runtime, spelled out.&lt;/p&gt;
&lt;h3&gt;Wait, how does it &lt;em&gt;know&lt;/em&gt; the deps?&lt;/h3&gt;
&lt;p&gt;Here&apos;s the part nobody tells you: Nix does not parse your code to find runtime
dependencies. After a build, it &lt;strong&gt;scans the raw output bytes for store-path
hashes&lt;/strong&gt;. If a 32-character store hash shows up anywhere in the result, that path
is a dependency. That&apos;s the entire algorithm.&lt;/p&gt;
&lt;p&gt;Don&apos;t believe me? The &lt;code&gt;hello&lt;/code&gt; binary literally has the libiconv path baked into
it:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ strings result/bin/hello | grep /nix/store
/nix/store/wl60...-libiconv-115.100.1/lib/libiconv.2.dylib

$ nix-store -q --references $(nix path-info nixpkgs#hello)
/nix/store/wl60...-libiconv-115.100.1        # ← same path, now a registered dep
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nix found &lt;code&gt;libiconv&lt;/code&gt; because the string was &lt;em&gt;in the file&lt;/em&gt;. This has real
consequences: &lt;code&gt;strip&lt;/code&gt; a binary and its closure can shrink; leave a compiler&apos;s
path in a debug comment and you&apos;ve accidentally added GCC to your runtime
closure. Dependencies aren&apos;t declared, they&apos;re &lt;em&gt;discovered&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where do prebuilt results come from?&lt;/strong&gt; Since the hash pins the exact recipe,
Nix can ask a &lt;strong&gt;binary cache&lt;/strong&gt; (&lt;code&gt;cache.nixos.org&lt;/code&gt; by default, or your own via
&lt;a href=&quot;https://www.cachix.org/&quot;&gt;cachix&lt;/a&gt;) &quot;got a build for this hash?&quot; before building. If yes, it downloads the
closure instead of compiling. A cache can&apos;t feed you the wrong thing, because the
only question is whether the hash matches. That&apos;s what &quot;caching that can&apos;t lie&quot;
actually means in practice.&lt;/p&gt;
&lt;h2&gt;4. Purity is enforced, not requested&lt;/h2&gt;
&lt;p&gt;Here&apos;s the part that makes reproducibility real instead of aspirational: Nix
builds in a &lt;strong&gt;sandbox&lt;/strong&gt;. No network. No &lt;code&gt;/usr/bin&lt;/code&gt;. No &lt;code&gt;$HOME&lt;/code&gt;. No system time
that matters. If your build secretly &lt;code&gt;curl&lt;/code&gt;s something or reads a file from the
host, it fails, on purpose.&lt;/p&gt;
&lt;p&gt;That&apos;s why a Nix build on my Mac and on a CI runner in Frankfurt produce the same
store hash. The build literally &lt;em&gt;cannot&lt;/em&gt; see anything that isn&apos;t a declared
input.&lt;/p&gt;
&lt;p&gt;Want network access? You have to declare the hash of what you&apos;re downloading up
front, so the result is still deterministic:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;fetchurl {
  url = &quot;https://ftp.gnu.org/gnu/hello/hello-2.12.1.tar.gz&quot;;
  hash = &quot;sha256-jZkUKv2SV28wsM18tCqNxoCZmLxdYH2Idh9RLibH2yA=&quot;;
  # ↑ if the download doesn&apos;t match this, the build fails
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No hash, no network. The impurity is boxed in and pinned.&lt;/p&gt;
&lt;h2&gt;5. Reading the graph&lt;/h2&gt;
&lt;p&gt;Once you internalize &quot;it&apos;s all a dependency graph,&quot; Nix ships tools to interrogate
it. This is genuinely useful for debugging bloat.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why is this thing even here?&lt;/strong&gt; (&lt;code&gt;--precise&lt;/code&gt; shows the exact file that
references it, pin &lt;code&gt;perl.out&lt;/code&gt; since Perl has multiple outputs.)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix why-depends --precise nixpkgs#git nixpkgs#perl.out
/nix/store/...-git-2.54.0
└───bin/git-cvsserver: #!/nix/store/...-perl-5.42.0/bin/perl  use lib (…
    → /nix/store/...-perl-5.42.0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Git pulls in Perl because scripts like &lt;code&gt;git-cvsserver&lt;/code&gt; have a Perl shebang. You
didn&apos;t have to guess. The graph told you which file did it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How big is the whole closure?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix path-info --closure-size -h nixpkgs#git
/nix/store/...-git-2.54.0   1.5 GiB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Yes, 1.5 GiB. Git&apos;s full closure drags in Perl, Python, and their deps. The
graph doesn&apos;t let anything hide.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What changed between two builds?&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix store diff-closures /run/current-system ./result
python3: 3.11.9 → 3.12.4, +2.1 MiB
openssl: 3.0.14 → 3.0.15
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;6. Generations: time travel that always works&lt;/h2&gt;
&lt;p&gt;Every time you rebuild, Nix keeps the old one. Your system profile is just a
symlink to the current generation, and the old generations are still sitting in
the store untouched.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix-env --list-generations --profile /nix/var/nix/profiles/system
  42   2026-07-28 09:14:02
  43   2026-07-30 21:03:55
  44   2026-08-02 11:20:10   (current)
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Broke your system on generation 44? Reboot, pick 43 from the boot menu, you&apos;re
back. Nothing was destroyed, because Nix never overwrites; it only ever adds a
new path and moves a symlink. Rollback is &lt;code&gt;nixos-rebuild switch --rollback&lt;/code&gt;, and
it&apos;s instant because the old closure never left.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;The store is append-only. That single design choice is why rollbacks can&apos;t
fail.&lt;/mark&gt;&lt;/p&gt;
&lt;h2&gt;Bending packages without forking&lt;/h2&gt;
&lt;aside class=&quot;marginalia&quot; data-note=&quot;the moment nix stopped being a package manager to me&quot;&gt;
&lt;p&gt;Every package exposes &lt;code&gt;override&lt;/code&gt; (change its &lt;em&gt;inputs&lt;/em&gt;) and &lt;code&gt;overrideAttrs&lt;/code&gt;
(change its &lt;em&gt;build&lt;/em&gt;). You patch someone else&apos;s package inline, no fork, no
maintaining a copy:&lt;/p&gt;
&lt;/aside&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;# add a patch and a build flag to upstream `hello`, in place
hello.overrideAttrs (old: {
  pname = &quot;hello-patched&quot;;
  patches = (old.patches or [ ]) ++ [ ./fix.patch ];
  configureFlags = (old.configureFlags or [ ]) ++ [ &quot;--enable-silent-rules&quot; ];
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And because you changed an input, the hash changes, so you get a &lt;em&gt;different&lt;/em&gt;
store path. The original stays untouched:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix eval --raw --expr \
    &apos;(import &amp;#x3C;nixpkgs&gt; {}).hello.overrideAttrs (o: { pname = &quot;hello-patched&quot;; }).name&apos;
hello-patched-2.12.3
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Scale that up with an &lt;strong&gt;overlay&lt;/strong&gt; and you can rewrite any package in nixpkgs for
your whole system, from one file. That&apos;s the &quot;why I keep coming back to Nix&quot;
feeling: the entire ecosystem is a value you&apos;re allowed to edit.&lt;/p&gt;
&lt;h2&gt;When a build breaks&lt;/h2&gt;
&lt;p&gt;Nix build errors scare people off, but the debugging tools are right there:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# see the full build log of anything (only for locally-built paths,
# not ones downloaded from a cache)
$ nix log nixpkgs#somepkg

# keep the failed build sandbox around so you can poke at it
$ nix build nixpkgs#somepkg --keep-failed

# drop into the exact environment the build runs in, then run the
# phases by hand: unpackPhase, configurePhase, buildPhase, ...
$ nix develop nixpkgs#somepkg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;For a build that dies deep in a phase, add &lt;code&gt;breakpointHook&lt;/code&gt; to the derivation
and Nix pauses &lt;em&gt;inside&lt;/em&gt; the sandbox at the failure so you can look around. Beats
staring at a wall of C errors.&lt;/p&gt;
&lt;h2&gt;Where flakes fit&lt;/h2&gt;
&lt;p&gt;You&apos;ve seen &lt;code&gt;flake.nix&lt;/code&gt; in every other post here without me ever saying what it
&lt;em&gt;is&lt;/em&gt;. A flake is just this whole model with a clean boundary drawn around it: a
&lt;code&gt;flake.nix&lt;/code&gt; with typed &lt;strong&gt;inputs&lt;/strong&gt; (its dependencies, pinned) and &lt;strong&gt;outputs&lt;/strong&gt;
(packages, dev shells, NixOS configs), plus a &lt;code&gt;flake.lock&lt;/code&gt; that nails every
input to an exact commit hash. Same derivations, same store, same graph, now
reproducible down to the git rev and evaluated purely by default. That&apos;s why
&lt;code&gt;nix run nixpkgs#hello&lt;/code&gt; and &lt;code&gt;.#wolf&lt;/code&gt; from my &lt;a href=&quot;https://pwnwriter.me/notes/nixos-hetzner&quot;&gt;VPS post&lt;/a&gt; work the
way they do.&lt;/p&gt;
&lt;h2&gt;Tricks I actually use&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Run anything without installing it.&lt;/strong&gt; The store makes this cheap; it downloads
the closure, runs it, and garbage-collects it later.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix run nixpkgs#cowsay -- &quot;no install, no trace&quot;
$ nix run nixpkgs#nmap -- -sV scanme.nmap.org
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Drop into a shell with tools, just for now:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix shell nixpkgs#{ripgrep,fd,jq}
# rg, fd, jq available; exit the shell and they&apos;re gone from PATH
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;&quot;I typed a command that isn&apos;t installed.&quot;&lt;/strong&gt; &lt;a href=&quot;https://github.com/nix-community/comma&quot;&gt;comma&lt;/a&gt; runs it straight from
nixpkgs by finding which package ships that binary:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ , htop        # not installed? comma fetches and runs it anyway
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Find which package provides a file:&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix-locate bin/ffmpeg
ffmpeg-full.out   ...   bin/ffmpeg
ffmpeg.out        ...   bin/ffmpeg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Pin nixpkgs to an exact commit&lt;/strong&gt; so a project builds the same in two years:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ nix run github:nixos/nixpkgs/a1b2c3d4#hello
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Explore nixpkgs like a data structure&lt;/strong&gt;, because that&apos;s all it is:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;$ nix repl -f &apos;&amp;#x3C;nixpkgs&gt;&apos;
nix-repl&gt; lib.version
&quot;26.11pre-git&quot;
nix-repl&gt; hello.meta.description
&quot;A program that produces a familiar, friendly greeting&quot;
nix-repl&gt; builtins.length (builtins.attrNames pkgs)   # top-level attrs
27648
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;A few fun facts to leave with&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;The hash comes before the build.&lt;/strong&gt; Nix knows a package&apos;s store path &lt;em&gt;before
it compiles anything&lt;/em&gt;, because the path is derived from inputs, not output.
That&apos;s how it checks the cache first and skips the build entirely.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;nixpkgs&lt;/code&gt; is one giant function.&lt;/strong&gt; The entire package set is a single Nix
expression that evaluates to an attribute set, with tens of thousands of top-level
entries, many of them nested sets with thousands more. &lt;code&gt;import &amp;#x3C;nixpkgs&gt; {}&lt;/code&gt;
calls it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reproducible enough to prove it.&lt;/strong&gt; Projects rebuild NixOS twice on different
machines and diff the hashes bit-for-bit. &lt;a href=&quot;https://reproducible.nixos.org/&quot;&gt;r13y.com&lt;/a&gt; tracks how many packages
are 100% reproducible.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/nix/store&lt;/code&gt; is world-readable and that&apos;s fine.&lt;/strong&gt; The hash &lt;em&gt;is&lt;/em&gt; the identity,
so there&apos;s nothing to hide and nothing to collide. Same reason I can keep &lt;a href=&quot;https://pwnwriter.me/notes/agenix-nix&quot;&gt;my
whole config public&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Deleting is safe.&lt;/strong&gt; &lt;code&gt;nix-collect-garbage -d&lt;/code&gt; only removes store paths that
nothing (no generation, no profile, no shell) points at. The graph decides
what&apos;s garbage, not you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&apos;s the mental model. Language → derivation → hashed store → sandbox. Every
fancy Nix feature is just those four ideas wearing a different hat. Once you see
the graph, you can&apos;t unsee it.&lt;/p&gt;
&lt;!--links--&gt;</content:encoded></item><item><title>life: summer of scholarships, black hat, and devday</title><link>https://pwnwriter.me/syndications/summer-scholarships/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/summer-scholarships/</guid><pubDate>Mon, 27 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;This summer break turned out unreal.&lt;/p&gt;
&lt;p&gt;I got a scholarship from &lt;strong&gt;&lt;em&gt;NYC DevRel&lt;/em&gt;&lt;/strong&gt;, then another one for &lt;strong&gt;&lt;em&gt;Black Hat&lt;/em&gt;&lt;/strong&gt; in &lt;em&gt;Las
Vegas&lt;/em&gt;, and now I’m also going to OpenAI &lt;strong&gt;&lt;em&gt;DevDay&lt;/em&gt;&lt;/strong&gt; through a scholarship.&lt;/p&gt;
&lt;p&gt;A few months ago, these rooms felt very far away. Now I’m packing for places I
used to only read about online.&lt;/p&gt;
&lt;p&gt;Grateful, nervous, excited.&lt;/p&gt;
&lt;p&gt;Mostly just trying to take it all in.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;a very weird season of saying yes and then figuring it out.&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded></item><item><title>life: turned 22</title><link>https://pwnwriter.me/syndications/turned-22/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/turned-22/</guid><pubDate>Sun, 12 Jul 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;yerp, turned 22 today.&lt;/p&gt;
&lt;aside class=&quot;marginalia&quot; data-note=&quot;too much coding the day before — training a small llm on my own hardware, for fun&quot;&gt;
&lt;p&gt;i was asleep at home when my friends showed up with a cake and gifts. i
was surprised but also kinda knew something was coming, we always plan
surprises for each other in our circle. they watched football while i
lay in bed half-dead.&lt;/p&gt;
&lt;/aside&gt;
&lt;p&gt;the cake was mango flavored and genuinely so smooth. after cake and gifts we
went out for dinner, came home, slept.&lt;/p&gt;
&lt;p&gt;perfect day honestly.&lt;/p&gt;
&lt;p&gt;since last year:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;won &lt;strong&gt;best elevenlabs&lt;/strong&gt; project at a hackathon&lt;/li&gt;
&lt;li&gt;shipped &lt;code&gt;eipi.boo&lt;/code&gt; and &lt;code&gt;tes.chat&lt;/code&gt;, and the feedback has been really good&lt;/li&gt;
&lt;li&gt;started learning how llms actually work under the hood&lt;/li&gt;
&lt;li&gt;got a scholarship to a devrel conference in new york&lt;/li&gt;
&lt;li&gt;got a scholarship to black hat in las vegas (thanks blackhat &amp;#x3C;3)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;mark&gt;22 started soft. i’ll take it.&lt;/mark&gt;&lt;/p&gt;
&lt;p&gt;i guess that&apos;s it for the night. see y&apos;all in the next one.&lt;/p&gt;</content:encoded></item><item><title>eipi.boo: confessions over ssh</title><link>https://pwnwriter.me/syndications/eipi-boo-ssh/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/eipi-boo-ssh/</guid><pubDate>Sun, 21 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Made an anonymous confession board that lives inside your terminal.&lt;/p&gt;
&lt;p&gt;No account, no browser, just ssh and a little chaos.&lt;/p&gt;
&lt;p&gt;Go ahead and post your confession.&lt;/p&gt;
&lt;div align=&quot;center&quot;&gt;
  &lt;h1&gt;&lt;code&gt;$ ssh eipi.boo&lt;/code&gt;&lt;/h1&gt; 
&lt;/div&gt;</content:encoded></item><item><title>Remote browsers and tab pooling in haylxon</title><link>https://pwnwriter.me/notes/haylxon-remote-pooling/</link><guid isPermaLink="true">https://pwnwriter.me/notes/haylxon-remote-pooling/</guid><pubDate>Tue, 09 Jun 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I&apos;ve been working on &lt;a href=&quot;https://github.com/pwnwriter/haylxon&quot;&gt;haylxon&lt;/a&gt; for a while now. It takes screenshots of URLs from the terminal using Chrome&apos;s headless mode, talking to Chrome over CDP (Chrome DevTools Protocol). I even &lt;a href=&quot;https://pwnwriter.me/posts/my-talk-on-ptn&quot;&gt;gave a talk about it at PTN 11&lt;/a&gt;. It was already pretty fast. But I kept noticing things that bugged me about how it worked under the hood.&lt;/p&gt;
&lt;h2&gt;what was bothering me&lt;/h2&gt;
&lt;p&gt;Every time you ran &lt;code&gt;hxn&lt;/code&gt;, it would spin up an entirely new Chromium process. Boot it. Wait for it. Connect via CDP. Do the work. Kill it. That browser startup alone eats 300-500ms. If you&apos;re screenshotting a handful of URLs, most of your time is just... waiting for Chrome to exist.&lt;/p&gt;
&lt;p&gt;The other thing, for every single URL, haylxon would open a fresh tab, navigate to the page, take the screenshot, close the tab. Open, close, open, close. Each of those is a CDP round-trip. Felt wasteful. Why not just reuse the tab?&lt;/p&gt;
&lt;p&gt;And then there was the bigger question I&apos;d been sitting on for a while: why does haylxon need to launch Chrome at all? What if Chrome is already running somewhere, on a server, on another laptop, in a container? CDP is a network protocol. It works over WebSockets. There&apos;s no reason the browser has to be on the same machine.&lt;/p&gt;
&lt;h2&gt;digging into chromiumoxide&lt;/h2&gt;
&lt;p&gt;I started looking at what &lt;a href=&quot;https://crates.io/crates/chromiumoxide&quot;&gt;chromiumoxide&lt;/a&gt; (the Rust CDP library haylxon uses) actually supports. Turns out it has two ways to get a &lt;code&gt;Browser&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;// what haylxon was doing, launch a new process
let (browser, handler) = Browser::launch(config).await?;

// what I didn&apos;t know existed, connect to an existing one
let (browser, handler) = Browser::connect(url).await?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The interesting part: both return the exact same &lt;code&gt;Browser&lt;/code&gt; type. Same API. Same everything. The pages you create, the screenshots you take, the JavaScript you evaluate, all identical regardless of how you got the browser. The only difference is whether Chrome was already running or not.&lt;/p&gt;
&lt;p&gt;Even better, &lt;code&gt;Browser::connect()&lt;/code&gt; is smart about what you pass it. Give it a WebSocket URL like &lt;code&gt;ws://host:9222/devtools/browser/&amp;#x3C;uuid&gt;&lt;/code&gt; and it connects directly. Give it an HTTP URL like &lt;code&gt;http://host:9222&lt;/code&gt; and it hits &lt;code&gt;/json/version&lt;/code&gt; to discover the WebSocket endpoint automatically. That&apos;s the same endpoint you&apos;d hit with &lt;code&gt;curl&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-json&quot;&gt;{
  &quot;Browser&quot;: &quot;Chrome/148.0.7778.0&quot;,
  &quot;webSocketDebuggerUrl&quot;: &quot;ws://127.0.0.1:9222/devtools/browser/68dbbb49-...&quot;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the abstraction I needed was thin. Really thin. Not a trait over every CDP operation, just a trait over how you &lt;em&gt;get&lt;/em&gt; the browser in the first place:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;#[async_trait]
pub trait BrowserProvider: Send + Sync {
    async fn connect(&amp;#x26;self) -&gt; miette::Result&amp;#x3C;Arc&amp;#x3C;Browser&gt;&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;LocalBrowser&lt;/code&gt; wraps the existing launch logic. &lt;code&gt;RemoteBrowser&lt;/code&gt; calls &lt;code&gt;Browser::connect()&lt;/code&gt;. Done.&lt;/p&gt;
&lt;h2&gt;how LocalBrowser works&lt;/h2&gt;
&lt;p&gt;Before this change, the browser launch code lived directly in the main execution function, a big block of &lt;code&gt;BrowserConfig::builder()&lt;/code&gt; calls mixed with CLI argument handling. I pulled it into its own struct:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;pub struct LocalBrowser {
    config: BrowserConfig,
}

impl LocalBrowser {
    pub fn new(
        binary_path: &amp;#x26;str,
        width: u32,
        height: u32,
        user_agent: Option&amp;#x3C;&amp;#x26;str&gt;,
        proxy: Option&amp;#x3C;&amp;#x26;str&gt;,
    ) -&gt; miette::Result&amp;#x3C;Self&gt; {
        let mut builder = BrowserConfig::builder();
        builder = builder
            .no_sandbox()
            .arg(&quot;--disable-dev-shm-usage&quot;)
            .arg(&quot;--disable-gpu&quot;)
            .window_size(width, height)
            .chrome_executable(binary_path)
            .viewport(Viewport {
                width, height,
                device_scale_factor: None,
                emulating_mobile: false,
                is_landscape: false,
                has_touch: false,
            });

        if let Some(ua) = user_agent {
            builder = builder.arg(format!(&quot;--user-agent={ua}&quot;));
        }
        if let Some(proxy_url) = proxy {
            builder = builder.arg(format!(&quot;--proxy-server={proxy_url}&quot;));
        }

        let config = builder.build().map_err(|e| miette::miette!(e))?;
        Ok(Self { config })
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;BrowserProvider&lt;/code&gt; impl just calls &lt;code&gt;Browser::launch()&lt;/code&gt; with that config and spawns the CDP event handler:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;#[async_trait]
impl BrowserProvider for LocalBrowser {
    async fn connect(&amp;#x26;self) -&gt; miette::Result&amp;#x3C;Arc&amp;#x3C;Browser&gt;&gt; {
        let (browser, mut handler) = Browser::launch(self.config.clone())
            .await.into_diagnostic()?;
        tokio::task::spawn(async move {
            while handler.next().await.is_some() {}
        });
        Ok(Arc::new(browser))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That &lt;code&gt;handler&lt;/code&gt; loop is important, chromiumoxide uses it to process CDP events from Chrome. Without it, nothing works. The old code had this exact same spawn, just inlined in &lt;code&gt;exec.rs&lt;/code&gt;. Now it lives where it belongs.&lt;/p&gt;
&lt;h2&gt;how RemoteBrowser works&lt;/h2&gt;
&lt;p&gt;This is the part I was most excited about. The entire remote browser implementation:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;pub struct RemoteBrowser {
    url: String,
}

impl RemoteBrowser {
    pub fn from_ws_url(url: String) -&gt; Self {
        Self { url }
    }

    pub fn from_host(host: String) -&gt; Self {
        Self { url: format!(&quot;http://{host}&quot;) }
    }
}

#[async_trait]
impl BrowserProvider for RemoteBrowser {
    async fn connect(&amp;#x26;self) -&gt; miette::Result&amp;#x3C;Arc&amp;#x3C;Browser&gt;&gt; {
        let (browser, mut handler) = Browser::connect(&amp;#x26;self.url)
            .await
            .into_diagnostic()
            .map_err(|e| miette::miette!(
                &quot;Failed to connect to remote browser at {}: {e}&quot;, self.url
            ))?;
        tokio::task::spawn(async move {
            while handler.next().await.is_some() {}
        });
        Ok(Arc::new(browser))
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. The &lt;code&gt;from_host&lt;/code&gt; constructor just prepends &lt;code&gt;http://&lt;/code&gt;, chromiumoxide handles the &lt;code&gt;/json/version&lt;/code&gt; discovery internally. No extra HTTP client code. No JSON parsing. The library already does all of that.&lt;/p&gt;
&lt;p&gt;The orchestration code in &lt;code&gt;exec.rs&lt;/code&gt; picks the right provider based on CLI flags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;let provider: Box&amp;#x3C;dyn BrowserProvider&gt; = if let Some(ws_url) = remote_url {
    Box::new(RemoteBrowser::from_ws_url(ws_url))
} else if let Some(host) = remote_host {
    Box::new(RemoteBrowser::from_host(host))
} else {
    Box::new(LocalBrowser::new(&amp;#x26;binary_path, width, height, ...)?)
};

let browser = provider.connect().await?;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;After this point, the rest of the code doesn&apos;t know or care whether Chrome is local or remote. Same &lt;code&gt;browser.new_page()&lt;/code&gt;, same &lt;code&gt;page.goto()&lt;/code&gt;, same &lt;code&gt;page.save_screenshot()&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;tab pooling, the details&lt;/h2&gt;
&lt;p&gt;The tab reuse was even simpler conceptually. I already had a &lt;code&gt;Semaphore&lt;/code&gt; controlling how many tabs run in parallel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;let semaphore = Arc::new(Semaphore::new(tabs));

for url in urls {
    let permit = semaphore.clone().acquire_owned().await?;
    tokio::spawn(async move {
        take_screenshot(...).await;
        drop(permit); // release the slot
    });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This gates concurrency, if &lt;code&gt;tabs&lt;/code&gt; is 8, only 8 screenshots run at once. But every one of those screenshots was creating a fresh page and destroying it. The pool sits behind that gate:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;pub struct PagePool {
    browser: Arc&amp;#x3C;Browser&gt;,
    pages: Mutex&amp;#x3C;Vec&amp;#x3C;Page&gt;&gt;,
    max_size: usize,
}

impl PagePool {
    pub async fn acquire(&amp;#x26;self) -&gt; miette::Result&amp;#x3C;Page&gt; {
        let mut pages = self.pages.lock().await;
        if let Some(page) = pages.pop() {
            drop(pages);
            let _ = page.goto(&quot;about:blank&quot;).await;
            Ok(page)
        } else {
            drop(pages);
            self.browser.new_page(&quot;about:blank&quot;).await.into_diagnostic()
        }
    }

    pub async fn release(&amp;#x26;self, page: Page) {
        let mut pages = self.pages.lock().await;
        if pages.len() &amp;#x3C; self.max_size {
            pages.push(page);
        } else {
            drop(pages);
            let _ = page.close().await;
        }
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The lock is held only for the &lt;code&gt;push&lt;/code&gt;/&lt;code&gt;pop&lt;/code&gt;, microseconds. The actual page navigation and screenshot happen outside the lock. No contention in practice.&lt;/p&gt;
&lt;p&gt;The &lt;code&gt;about:blank&lt;/code&gt; navigation on acquire is the cheapest way to reset a page. No cookies from the previous URL leak through. No leftover DOM. Clean slate. I thought about calling &lt;code&gt;page.close()&lt;/code&gt; and creating a new one every time, but that defeats the whole point, &lt;code&gt;new_page&lt;/code&gt; is a CDP command that allocates renderer resources. Reusing the existing page and just navigating away is way cheaper.&lt;/p&gt;
&lt;p&gt;The screenshot function now does this instead of the old create/close dance:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;// acquire page from pool (or create new)
let page = if let Some(pool) = page_pool {
    pool.acquire().await?
} else {
    browser.new_page(&quot;about:blank&quot;).await.into_diagnostic()?
};

// ... set user agent, navigate, screenshot ...

// release back to pool (or close)
if let Some(pool) = page_pool {
    pool.release(page).await;
} else {
    page.close().await.into_diagnostic()?;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;Option&amp;#x3C;&amp;#x26;PagePool&gt;&lt;/code&gt; keeps it backwards compatible, pass &lt;code&gt;--reuse-tabs false&lt;/code&gt; and there&apos;s no pool, falls back to the old behavior.&lt;/p&gt;
&lt;h2&gt;wiring it up&lt;/h2&gt;
&lt;p&gt;The CLI got three new flags:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# connect to a remote browser, just give it host:port
hxn --remote-host 192.168.1.42:9222 -f urls.txt

# or a direct WebSocket URL if you already have it
hxn --remote-url ws://192.168.1.42:9222/devtools/browser/&amp;#x3C;uuid&gt; -u https://example.com

# tab pool size (default 8, enabled by default)
hxn -b $(which brave) -f urls.txt --pool-size 16
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The &lt;code&gt;--remote-host&lt;/code&gt; flag is the one I use most. You start Chrome headless somewhere:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;chromium --headless --remote-debugging-port=9222 --no-sandbox
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And point haylxon at it. No UUIDs to copy. The tool discovers the WebSocket URL itself.&lt;/p&gt;
&lt;p&gt;One gotcha I ran into while testing this: Chrome often ignores &lt;code&gt;--remote-debugging-address=0.0.0.0&lt;/code&gt; and only binds to localhost even when you tell it not to. Spent way too long debugging that. Ended up using &lt;code&gt;socat&lt;/code&gt; to expose the port:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;socat TCP-LISTEN:9223,fork,reuseaddr,bind=0.0.0.0 TCP:127.0.0.1:9222 &amp;#x26;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Not ideal, but it works reliably. Chrome&apos;s fault, not ours.&lt;/p&gt;
&lt;h2&gt;how fast is it now&lt;/h2&gt;
&lt;p&gt;I set up a &lt;a href=&quot;https://github.com/sharkdp/hyperfine&quot;&gt;hyperfine&lt;/a&gt; benchmark on GitHub Actions comparing haylxon against &lt;a href=&quot;https://github.com/sensepost/gowitness&quot;&gt;gowitness&lt;/a&gt;:&lt;/p&gt;





























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;Benchmark&lt;/th&gt;&lt;th&gt;hxn&lt;/th&gt;&lt;th&gt;gowitness&lt;/th&gt;&lt;th&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Single URL&lt;/td&gt;&lt;td&gt;360ms&lt;/td&gt;&lt;td&gt;3.99s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;11x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;5 URLs, 4 tabs&lt;/td&gt;&lt;td&gt;1.81s&lt;/td&gt;&lt;td&gt;26.29s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;14x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;5 URLs, 8 tabs&lt;/td&gt;&lt;td&gt;1.70s&lt;/td&gt;&lt;td&gt;14.67s&lt;/td&gt;&lt;td&gt;&lt;strong&gt;8x faster&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;The single URL case is the one that got me. 360ms. That includes launching Chrome, connecting, navigating, rendering the page, capturing the screenshot, and writing it to disk. On a CI runner. That&apos;s not a lot of time.&lt;/p&gt;
&lt;p&gt;With a remote browser that&apos;s already running, you skip the launch entirely. The first screenshot comes back even faster because there&apos;s no boot cost to amortize.&lt;/p&gt;
&lt;h2&gt;what I kept simple&lt;/h2&gt;
&lt;p&gt;I didn&apos;t add connection retry logic. I didn&apos;t add health checks or reconnection pools or a configuration file for remote browser settings. I didn&apos;t build a browser manager that keeps Chrome alive between runs.&lt;/p&gt;
&lt;p&gt;All of that might make sense someday. But right now, the tool is fast, the code is small, and there&apos;s nothing in it that doesn&apos;t need to be there. That feels right.&lt;/p&gt;
&lt;p&gt;Sometimes the fastest path is just removing the things that were slow.&lt;/p&gt;</content:encoded></item><item><title>Secrets in Nix, Publicly</title><link>https://pwnwriter.me/notes/agenix-nix/</link><guid isPermaLink="true">https://pwnwriter.me/notes/agenix-nix/</guid><pubDate>Wed, 06 May 2026 00:00:00 GMT</pubDate><content:encoded>&lt;img src=&quot;/_astro/nix-age.B47IRU2d.png&quot; alt=&quot;pwnwriter&apos;s talk in owasp Kathmandu&quot;/&gt;
&lt;p&gt;I keep my entire Nix config in a public repo. Dotfiles, packages, shell setup,
everything. But I also need my SSH and GPG keys on every machine I use. Storing
private keys in a public repo is obviously a terrible idea. So how do you manage
secrets in Nix without going private?&lt;/p&gt;
&lt;p&gt;The answer is &lt;a href=&quot;https://github.com/ryantm/agenix&quot;&gt;agenix&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;How it works&lt;/h2&gt;
&lt;p&gt;agenix uses &lt;a href=&quot;https://age-encryption.org/&quot;&gt;age&lt;/a&gt; encryption. You encrypt secrets with your SSH &lt;strong&gt;public&lt;/strong&gt; key,
and only someone with the matching &lt;strong&gt;private&lt;/strong&gt; key can decrypt them. The
encrypted &lt;code&gt;.age&lt;/code&gt; files live in your repo. On rebuild, agenix decrypts them
automatically.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nix
├── secrets/                     ~/.local/share/ssh/
│   ├── ssh-key.age  ───────────►  id_rsa (decrypted on rebuild)
│   └── gpg-key.age              id_ed25519 (decryption key)
├── secrets.nix
└── modules/agenix.nix
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Anyone can clone the repo. Only I can use it.&lt;/p&gt;
&lt;h2&gt;Setting it up&lt;/h2&gt;
&lt;p&gt;I generated a dedicated &lt;code&gt;ed25519&lt;/code&gt; key for agenix. My existing RSA key stays
untouched for GitHub, servers, and everything else.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh-keygen -t ed25519 -C &quot;agenix@pwnwriter&quot; -f ~/.local/share/ssh/id_ed25519 -N &quot;&quot; # FIXME: change comment and path
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Add agenix to the flake&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-diff&quot;&gt; inputs = {
   nixpkgs.url = &quot;github:nixos/nixpkgs/nixpkgs-unstable&quot;;
   home-manager = { ... };
   darwin = { ... };
   catppuccin.url = &quot;github:catppuccin/nix&quot;;
+
+  agenix = {
+    url = &quot;github:ryantm/agenix&quot;;
+    inputs.nixpkgs.follows = &quot;nixpkgs&quot;;
+  };
 };
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;Define who can decrypt what&lt;/h3&gt;
&lt;p&gt;A &lt;code&gt;secrets.nix&lt;/code&gt; file at the repo root maps public keys to secret files.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;let
  pwnwriter = &quot;ssh-ed25519 AAAA... agenix@pwnwriter&quot;; # FIXME: your ed25519 public key
in
{
  &quot;secrets/ssh-key.age&quot;.publicKeys = [ pwnwriter ]; # FIXME: your secret files
  &quot;secrets/gpg-key.age&quot;.publicKeys = [ pwnwriter ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This is just a list of public keys. Safe to commit. It tells agenix which keys
are allowed to encrypt and decrypt each secret.&lt;/p&gt;
&lt;h3&gt;Encrypt the secrets&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;nix run github:ryantm/agenix -- -e secrets/ssh-key.age
nix run github:ryantm/agenix -- -e secrets/gpg-key.age
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This opens your editor. Paste the secret, save, done. The output is an
encrypted &lt;code&gt;.age&lt;/code&gt; file that only your ed25519 key can unlock.&lt;/p&gt;
&lt;h3&gt;Wire it into home-manager&lt;/h3&gt;
&lt;p&gt;I created a module that tells agenix where the decryption key lives and where to
place the decrypted secrets.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;# modules/agenix.nix
{ config, ... }:
{
  age = {
    identityPaths = [
      &quot;${config.xdg.dataHome}/ssh/id_ed25519&quot; # FIXME: path to your ed25519 key
    ];

    secrets = {
      ssh-key = {
        file = ../secrets/ssh-key.age;
        path = &quot;${config.xdg.dataHome}/ssh/id_rsa&quot;; # FIXME: where to place decrypted key
      };
      gpg-key = {
        file = ../secrets/gpg-key.age;
      };
    };
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then each machine needs the agenix home-manager module. Here&apos;s the diff for one
of my darwin machines:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-diff&quot;&gt; # machines/earlymoon.nix
 let
   inherit (inputs)
     darwin
     home-manager
     catppuccin
+    agenix
     ;
 in
 darwin.lib.darwinSystem {
   modules = [
     home-manager.darwinModules.home-manager
     {
       home-manager.users.pwnwriter = { # FIXME: your username
         imports = [
           ./../modules
           catppuccin.homeModules.catppuccin
+          agenix.homeManagerModules.default
         ];
       };
     }
   ];
 }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Same pattern for every machine. My standalone home-manager config on Linux gets
the same one-liner.&lt;/p&gt;
&lt;h3&gt;Rebuild&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;darwin-rebuild switch --flake .#earlymoon # FIXME: your machine name
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That&apos;s it. agenix reads the ed25519 private key, decrypts the &lt;code&gt;.age&lt;/code&gt; files, and
drops them where the module says. My SSH key appears at
&lt;code&gt;~/.local/share/ssh/id_rsa&lt;/code&gt;, ready to use.&lt;/p&gt;
&lt;p&gt;Five touchpoints total. No external services, no key servers, no runtime
daemons. Just age encryption and a rebuild.&lt;/p&gt;
&lt;h3&gt;NOW,&lt;/h3&gt;
&lt;h2&gt;Adding a new secret later&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# encrypt
nix run github:ryantm/agenix -- -e secrets/new-secret.age

# add to secrets.nix
&quot;secrets/new-secret.age&quot;.publicKeys = [ pwnwriter ];

# add to modules/agenix.nix
age.secrets.new-secret.file = ../secrets/new-secret.age;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;To edit an existing secret, same encrypt command. It decrypts to your editor,
you change it, and it re-encrypts on save.&lt;/p&gt;
&lt;!--links--&gt;</content:encoded></item><item><title>Hacking 10$ Nexus Phone Into a Hacking Machine</title><link>https://pwnwriter.me/notes/nexus-google-phone-kali/</link><guid isPermaLink="true">https://pwnwriter.me/notes/nexus-google-phone-kali/</guid><pubDate>Fri, 17 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;img src=&quot;/_astro/cover.D0fIoEzx.jpg&quot; alt=&quot;Nexus 6P running Kali NetHunter chroot manager on a laptop&quot;/&gt;
&lt;p&gt;I saw Nexus 6P on Facebook Marketplace for $10. Owner forgot the PIN, just wanted it gone.&lt;/p&gt;
&lt;p&gt;I already know Kali NetHunter support this phone officially. So I bought immediately without a second thought.&lt;/p&gt;
&lt;p&gt;Literaly drove 25 miles after college to pick it up. First I spent a couple hours researching how to get in without the PIN. Wanted to do it properly and then i found &lt;code&gt;CVE-2016-8467&lt;/code&gt;. Tried it. It worked. Here&apos;s the full story on how come.&lt;/p&gt;
&lt;h3&gt;The Phone&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;Nexus 6P&lt;/strong&gt; : Inside name &lt;code&gt;angler&lt;/code&gt;. Made by Huawei, Google flagship from 2015.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/nexus-google-hack/google-nexus-boot.jpg&quot; alt=&quot;Nexus 6P booting up&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ fastboot getvar version-bootloader
version-bootloader: 03.61
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Bootloader &lt;code&gt;03.61&lt;/code&gt;. This one is weak. Safe version is &lt;code&gt;03.64&lt;/code&gt; and above. When I see this number I actually pump my fist lol.&lt;/p&gt;
&lt;h3&gt;CVE-2016-8467&lt;/h3&gt;
&lt;p&gt;It&apos;s a High risk cve and was already fixed in January 2017, but the phone wasn&apos;t even updated since then. A big plus point for me ;P
So, to summarize this,&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;&lt;strong&gt;CVE&lt;/strong&gt;&lt;/th&gt;&lt;th&gt;&lt;strong&gt;&lt;code&gt;CVE-2016-8467&lt;/code&gt;&lt;/strong&gt;&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Type&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;Boot mode bypass&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Risk&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;High&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Affected&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;Nexus 6P bootloader &amp;#x3C; 03.64&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Need&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;&lt;code&gt;Physical access only, no PIN&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;So basically: this phone has hidden boot modes that Huawei put inside for factory testing. One of them called &lt;code&gt;bp-tools&lt;/code&gt;. When you turn it on, phone open ADB connection &lt;strong&gt;even if you already disable developer mode&lt;/strong&gt;, because the system enable it from boot level, not from the settings toggle.&lt;/p&gt;
&lt;p&gt;The bug is Huawei forget to block this option when phone is locked. Anyone can just select it. No password, nothing.&lt;/p&gt;
&lt;h3&gt;Why PIN not important here&lt;/h3&gt;
&lt;pre&gt;&lt;code&gt;  PIN / Lock Screen  →  protect Android only
  CVE-2016-8467      →  go in before Android even start

  We didn&apos;t pick the lock. We went through the basement.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3&gt;The Exploit&lt;/h3&gt;
&lt;blockquote&gt;
&lt;p&gt;Need: phone, USB cable, laptop with &lt;code&gt;fastboot&lt;/code&gt;. That&apos;s all.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Hold &lt;strong&gt;Volume Down + Power&lt;/strong&gt; to enter fastboot mode. On weak phone you see this menu:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;  START
  RESTART BOOTLOADER
  RECOVERY MODE
  POWER OFF
  BP-TOOLS          ← this one
  FACTORY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Press volume to go down to &lt;strong&gt;BP-Tools&lt;/strong&gt;. Press Power.&lt;/p&gt;
&lt;p&gt;That&apos;s it. One button. No command, no tool, no password. I&apos;m serious.&lt;/p&gt;
&lt;p&gt;Phone save this setting inside, so every reboot also still have it. Lock screen look totally normal. Owner don&apos;t know anything.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ fastboot devices
84B5T15B08000140    fastboot
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full access on a locked phone. Can flash anything.&lt;/p&gt;
&lt;h3&gt;Unlock and Flash&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ fastboot oem unlock
(bootloader) Bootloader unlocked.
OKAY
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Then flash TWRP recovery:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ fastboot boot twrp-3.7.0-angler.img   # test before flash
$ fastboot flash recovery twrp-3.7.0-angler.img
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/nexus-google-hack/twrp.jpg&quot; alt=&quot;TWRP 3.7.0 running on the Nexus 6P&quot;&gt;&lt;/p&gt;
&lt;p&gt;Here I made mistake ,i flashed custom ROM but forget to wipe cache. Phone go bootloop straight away. Had to reflash stock and start again. So painful.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Always wipe Dalvik and cache before flash new ROM.&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;Wipe → Advanced Wipe: ☑ Dalvik  ☑ System  ☑ Cache  ☑ Data
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://pwnwriter.me/images/nexus-google-hack/nethunter-boot.jpg&quot; alt=&quot;Kali NetHunter boot logo&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ adb shell
angler:/ $ chroot /data/local/nhsystem/kali-arm64 /bin/bash
┌──(root㉿kali)-[/]
└─# whoami
root
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Full Kali Linux. On a phone. For $10. Cannot believe.&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;What It Can Do&lt;/h3&gt;
&lt;p&gt;WiFi monitor mode work on built-in WiFi, can run full &lt;code&gt;aircrack-ng&lt;/code&gt;, no need extra hardware. Can also do USB attack where target computer think phone is a keyboard, i always wanted to try this and it did work, i might write an another blog for the same if i get a chance from these college assignments..&lt;/p&gt;
&lt;p&gt;I guess that&apos;s it for this. See ya :/&lt;/p&gt;
&lt;hr&gt;
&lt;h3&gt;Aditional resources&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;CVE-2016-8467 original research — &lt;a href=&quot;https://alephsecurity.com/2017/01/05/attacking-android-custom-bootmodes/&quot;&gt;alephsecurity.com/2017/01/05/attacking-android-custom-bootmodes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Bypassing Nexus 6 Secure Boot.. - &lt;a href=&quot;https://alephsecurity.com/2017/05/23/nexus6-initroot/&quot;&gt;Bypassing Nexus 6 Secure Boot through....&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;IBM X-Force full paper — &lt;a href=&quot;https://www.docdroid.net/dxKUj5c/attacking-nexus-6-6p-custom-bootmodes.pdf.html&quot;&gt;docdroid.net/dxKUj5c/attacking-nexus-6-6p-custom-bootmodes.pdf&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;January 2017 Android Security Bulletin — &lt;a href=&quot;https://source.android.com/security/bulletin/2017-01-01.html&quot;&gt;source.android.com/security/bulletin/2017-01-01&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;NetHunter downloads — &lt;a href=&quot;https://www.kali.org/get-kali/#kali-mobile&quot;&gt;kali.org/get-kali/#kali-mobile&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;TWRP for angler — &lt;a href=&quot;https://twrp.me/huawei/huaweinexus6p.html&quot;&gt;twrp.me/huawei/huaweinexus6p.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</content:encoded></item><item><title>hackathon: won best elevenlabs at morganhacks</title><link>https://pwnwriter.me/syndications/morganhacks-26/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/morganhacks-26/</guid><pubDate>Wed, 15 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Hacked for two days straight at MorganHacks and somehow walked away with
&lt;strong&gt;Best ElevenLabs&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;Our project was &lt;strong&gt;inventoryAi&lt;/strong&gt;. The demo was stressful, the sleep was fake, and
the team still pulled it off.&lt;/p&gt;
&lt;img src=&quot;https://pwnwriter.me/me-proud-nix-user.jpg&quot; alt=&quot;morganhacks&quot; style=&quot;width: 100%; max-width: 500px; height: auto;&quot;&gt;
&lt;p&gt;&lt;mark&gt;tired, happy, proud.&lt;/mark&gt;&lt;/p&gt;</content:encoded></item><item><title>nvim: per project configuration</title><link>https://pwnwriter.me/syndications/neovim_per_dir_config/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/neovim_per_dir_config/</guid><pubDate>Thu, 02 Apr 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I finally flipped my config to &lt;strong&gt;Neovim 0.12&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;This release makes project-local config and package management feel much closer
to Neovim itself instead of another layer glued on top.&lt;/p&gt;
&lt;img src=&quot;https://pwnwriter.me/images/syndications/neovim-11-12/neovim-11-12.png&quot; alt=&quot;neovim 12&quot; style=&quot;width: 100%; max-width: 500px; height: auto;&quot;&gt;
&lt;p&gt;The part that got me interested: &lt;mark&gt;the built-in package manager&lt;/mark&gt;.&lt;/p&gt;
&lt;h3&gt;Leaving &lt;code&gt;lazy.nvim&lt;/code&gt; Behind&lt;/h3&gt;
&lt;p&gt;I was using &lt;code&gt;lazy.nvim&lt;/code&gt; with a &lt;code&gt;.lazy.lua&lt;/code&gt; setup. It worked fine, so this was not
some dramatic migration. But with more things moving into Neovim itself, I wanted
to see how far the native path could go.&lt;/p&gt;
&lt;p&gt;So I moved this part away from &lt;code&gt;lazy.nvim&lt;/code&gt; and tried the built-in approach.&lt;/p&gt;
&lt;p&gt;Here’s what my &lt;code&gt;.nvim.lua&lt;/code&gt; looks like now:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;vim.pack.add({
  { src = &quot;https://github.com/neovim/nvim-lspconfig&quot;, name = &quot;lspconfig&quot; }
})

vim.lsp.enable({
  &quot;ts_ls&quot;, -- typescript
  &quot;astro&quot;, -- astro
  &quot;tailwindcss&quot;, -- tailwindcss
  &quot;tinymist&quot;, -- typst
  &quot;harper_ls&quot;, -- markdown
  &quot;ty&quot;, -- python
})

return {}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;What This Does&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Installs &lt;code&gt;nvim-lspconfig&lt;/code&gt; using the built-in package manager&lt;/li&gt;
&lt;li&gt;Enables a set of LSP servers using defaults from upstream &lt;code&gt;lspconfig&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;img src=&quot;https://pwnwriter.me/images/syndications/neovim-11-12/lsp.mov&quot; alt=&quot;lsp usages&quot; style=&quot;width: 100%; max-width: 500px; height: auto;&quot;&gt;
&lt;p&gt;That’s it. Less config, fewer moving parts.&lt;/p&gt;</content:encoded></item><item><title>SSH Home Server Anywhere</title><link>https://pwnwriter.me/notes/cloudflared-ssh/</link><guid isPermaLink="true">https://pwnwriter.me/notes/cloudflared-ssh/</guid><pubDate>Thu, 05 Mar 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I recently got a ThinkPad and decided to turn it into a small home server. I
plan to use it for heavier tasks like running Hack The Box machines, practicing
for Red Hat, and other lab work. Since it stays at home, I wanted a simple way
to SSH into it from anywhere.&lt;/p&gt;
&lt;p&gt;The usual way is to &lt;strong&gt;port-forward port 22&lt;/strong&gt;, but that exposes your server
directly to the internet. I didn’t want that.&lt;/p&gt;
&lt;p&gt;Instead, I used &lt;strong&gt;Cloudflare Tunnel&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;With a tunnel, my server creates a secure outbound connection to Cloudflare’s network, and I reach it through my domain. No open ports, no public IP, and it works even behind NAT.&lt;/p&gt;
&lt;h3&gt;Create the tunnel&lt;/h3&gt;
&lt;p&gt;After installing &lt;code&gt;cloudflared&lt;/code&gt;, I logged in and created a tunnel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cloudflared tunnel login
cloudflared tunnel create pwnlab
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cloudflare generates a credential file inside &lt;code&gt;~/.cloudflared/&lt;/code&gt;.&lt;/p&gt;
&lt;h3&gt;Configure the tunnel&lt;/h3&gt;
&lt;p&gt;Then I created a small config file:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;~/.cloudflared/config.yml&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-yaml&quot;&gt;tunnel: pwnlab
credentials-file: /home/pwn/.cloudflared/&amp;#x3C;tunnel-id&gt;.json

ingress:
  - hostname: ssh.pwnwriter.me
    service: ssh://localhost:22
  - service: http_status:404
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;This tells the tunnel to send traffic from &lt;code&gt;ssh.pwnwriter.me&lt;/code&gt; to my local SSH server.&lt;/p&gt;
&lt;h3&gt;Add the DNS route&lt;/h3&gt;
&lt;p&gt;Next I mapped the subdomain to the tunnel:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cloudflared tunnel route dns pwnlab ssh.pwnwriter.me
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Cloudflare automatically creates the DNS record.&lt;/p&gt;
&lt;h3&gt;Run the tunnel&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;cloudflared tunnel run pwnlab
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now my server stays connected to Cloudflare through a secure tunnel.&lt;/p&gt;
&lt;h3&gt;SSH from anywhere&lt;/h3&gt;
&lt;p&gt;From any machine I can now simply run:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;ssh pwn@ssh.pwnwriter.me
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;That’s it.&lt;/p&gt;
&lt;p&gt;No router configuration.
No exposed ports.
Just a clean and secure.&lt;/p&gt;</content:encoded></item><item><title>Oneshot canceled haylxon</title><link>https://pwnwriter.me/notes/on-shot-haylxon/</link><guid isPermaLink="true">https://pwnwriter.me/notes/on-shot-haylxon/</guid><pubDate>Thu, 26 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A small bug in &lt;a href=&quot;https://github.com/pwnwriter/haylxon&quot;&gt;&lt;code&gt;haylxon&lt;/code&gt;&lt;/a&gt;caused screenshots to fail with this warning:&lt;/p&gt;
&lt;p&gt;&lt;code&gt;warning: oneshot canceled&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;But the tool still said:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;info: Screenshots Taken and saved in directory hxnshots
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;No screenshot was actually saved.&lt;/p&gt;
&lt;h2&gt;What Was Happening&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;haylxon&lt;/code&gt; uses &lt;code&gt;chromiumoxide&lt;/code&gt; to talk to Chrome. In the background, there’s a handler loop that keeps reading messages from Chrome and sending responses back through oneshot channels.&lt;/p&gt;
&lt;p&gt;This was the code:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;task::spawn(async move {
    while let Some(h) = handler.next().await {
        if h.is_err() {
            break;
        }
    }
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It looks fine. If something errors, stop.&lt;/p&gt;
&lt;p&gt;But Chrome sends non-fatal errors all the time like console warnings from websites. When that happened:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;handler.next()&lt;/code&gt; returned &lt;code&gt;Some(Err(...))&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The loop hit &lt;code&gt;break&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The handler stopped running&lt;/li&gt;
&lt;li&gt;All pending oneshot senders were dropped&lt;/li&gt;
&lt;li&gt;The receiver saw that and returned: &lt;code&gt;&quot;oneshot canceled&quot;&lt;/code&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;So the screenshot call was waiting for a response… but the handler had already exited.&lt;/p&gt;
&lt;p&gt;The connection wasn’t broken. We just stopped listening too early.&lt;/p&gt;
&lt;h2&gt;The Fix&lt;/h2&gt;
&lt;p&gt;I changed it to:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-rust&quot;&gt;task::spawn(async move {
    while handler.next().await.is_some() {}
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now it keeps running until Chrome actually disconnects. It ignores non-fatal errors instead of shutting everything down.&lt;/p&gt;
&lt;p&gt;That’s it.&lt;/p&gt;
&lt;p&gt;One &lt;code&gt;break&lt;/code&gt; statement was killing the whole message loop. Removing it fixed the issue completely.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Issue:&lt;/strong&gt; &lt;a href=&quot;https://github.com/pwnwriter/haylxon/issues/140&quot;&gt;#140&lt;/a&gt;&lt;/p&gt;</content:encoded></item><item><title>font: migrate jetbrains → lilex</title><link>https://pwnwriter.me/syndications/new-font/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/new-font/</guid><pubDate>Tue, 24 Feb 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Today I switched to &lt;code&gt;Lilex Nerd Font Mono&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Feels weird, but refreshing.&lt;/p&gt;
&lt;p&gt;I didn’t realize how attached I was to a font until changing it made the whole
editor feel like a different room.&lt;/p&gt;
&lt;p&gt;&lt;del&gt;it is just a font&lt;/del&gt; no, apparently it is not.&lt;/p&gt;
&lt;img src=&quot;https://pwnwriter.me/images/syndications/font.png&quot; alt=&quot;we&quot; style=&quot;width: 100%; max-width: 500px; height: auto;&quot;&gt;</content:encoded></item><item><title>gadget: got my first split keyboard</title><link>https://pwnwriter.me/syndications/split-keyboard/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/split-keyboard/</guid><pubDate>Mon, 12 Jan 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Got my first split keyboard today.&lt;/p&gt;
&lt;p&gt;I’m still painfully slow on it, but every keypress feels intentional. Weirdly
fun.&lt;/p&gt;
&lt;img src=&quot;https://pwnwriter.me/images/syndications/split-keyboard.jpg&quot; alt=&quot;we&quot; style=&quot;width: 100%; max-width: 500px; height: auto;&quot;&gt;</content:encoded></item><item><title>Cozy</title><link>https://pwnwriter.me/notes/dev-setup/</link><guid isPermaLink="true">https://pwnwriter.me/notes/dev-setup/</guid><pubDate>Wed, 10 Dec 2025 14:52:14 GMT</pubDate><content:encoded>&lt;p&gt;I spend a lot of time building, breaking, and rebuilding software, so my workspace matters.&lt;/p&gt;
&lt;p&gt;For the past two years, I’ve been using Nix on my MacBook and NixOS as a daily driver. Everything is declarative, reproducible, and easy to roll back when experiments go wrong.&lt;/p&gt;
&lt;p&gt;Most of my work happens in the terminal and Neovim — for coding, notes, and writing. The setup is simple, predictable, and quiet.&lt;/p&gt;
&lt;div style=&quot;display: flex; gap: 10px;&quot;&gt;
  &lt;img src=&quot;https://pwnwriter.me/images/setup/nix-setup.jpg&quot; style=&quot;width: 50%; height: auto;&quot;&gt;
  &lt;img src=&quot;https://pwnwriter.me/images/setup/server.jpg&quot; style=&quot;width: 50%; height: auto;&quot;&gt;
&lt;/div&gt;
&lt;p&gt;Nothing fancy. Just cozy, reliable tools that stay out of the way.&lt;/p&gt;</content:encoded></item><item><title>milestone: bought my first car</title><link>https://pwnwriter.me/syndications/car/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/car/</guid><pubDate>Mon, 13 Oct 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Never thought I’d write this, but I got my license today and bought my first
car.&lt;/p&gt;
&lt;aside class=&quot;marginalia&quot; data-note=&quot;still can&amp;#x27;t believe it&amp;#x27;s mine&quot;&gt;
&lt;p&gt;I used to walk those roads in the rain. Now I drive them.&lt;/p&gt;
&lt;/aside&gt;
&lt;p&gt;&lt;mark&gt;small freedom, big feeling.&lt;/mark&gt;&lt;/p&gt;</content:encoded></item><item><title>Typst for University Notes</title><link>https://pwnwriter.me/notes/typst/</link><guid isPermaLink="true">https://pwnwriter.me/notes/typst/</guid><pubDate>Thu, 17 Jul 2025 12:26:54 GMT</pubDate><content:encoded>&lt;p&gt;For my first two semesters, I used &lt;a href=&quot;https://daringfireball.net/projects/markdown/&quot;&gt;Markdown&lt;/a&gt; and &lt;a href=&quot;https://obsidian.md/&quot;&gt;Obsidian&lt;/a&gt; to take notes,
&lt;a href=&quot;https://git-scm.com/&quot;&gt;Git&lt;/a&gt; to version them, and &lt;a href=&quot;https://github.com/&quot;&gt;GitHub&lt;/a&gt; to back them up. Exported as PDFs when
needed. It worked, but always felt like a patchwork solution.&lt;/p&gt;
&lt;p&gt;Then I found &lt;a href=&quot;https://typst.app/&quot;&gt;Typst&lt;/a&gt; — a modern typesetting system with proper math support,
macros, and beautiful PDF output. The moment I tried it, I switched everything
over.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;~/assets/typst/typst-assignment.png&quot; alt=&quot;Typst Assignment&quot;&gt;&lt;/p&gt;
&lt;h2&gt;The setup&lt;/h2&gt;
&lt;p&gt;A dedicated Git repo for all my university notes. &lt;a href=&quot;https://neovim.io/&quot;&gt;Neovim&lt;/a&gt; as the editor with
&lt;a href=&quot;https://github.com/nyinyithann/tinymist&quot;&gt;tinymist&lt;/a&gt; for Typst LSP. A &lt;code&gt;flake&lt;/code&gt; with &lt;code&gt;nix-direnv&lt;/code&gt; for dependencies.&lt;/p&gt;
&lt;p&gt;I use NixOS and macOS, so flakes just work everywhere. And I get the
&lt;code&gt;git push/pull&lt;/code&gt; workflow I like.&lt;/p&gt;
&lt;h3&gt;&lt;code&gt;flake.nix&lt;/code&gt;&lt;/h3&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  inputs = {
    nixpkgs.url = &quot;github:nixos/nixpkgs/nixos-unstable&quot;;
  };

  outputs = { nixpkgs, ... }:
    let
      systems = [ &quot;x86_64-linux&quot; &quot;aarch64-linux&quot; &quot;x86_64-darwin&quot; &quot;aarch64-darwin&quot; ];
      forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (import nixpkgs { inherit system; }));
    in {
      devShells = forAllSystems (pkgs: {
        default = pkgs.mkShell {
          packages = with pkgs; [
            typst         # Typesetting
            tinymist      # LSP for Typst
            python3       # Python runtime
            basedpyright  # Python LSP
            ruff          # Formatter
            uv            # Python package manager
          ];

          env.LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [
            pkgs.libz
            pkgs.stdenv.cc.cc.lib
          ];
        };
      });
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3&gt;&lt;code&gt;.nvim.lua&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;Neovim 0.12 has a built-in package manager, so I dropped &lt;code&gt;lazy.nvim&lt;/code&gt;. I use a
&lt;code&gt;.nvim.lua&lt;/code&gt; at the project root for per-directory config.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-lua&quot;&gt;vim.pack.add({
  { src = &quot;https://github.com/neovim/nvim-lspconfig&quot;, name = &quot;lspconfig&quot; },
})

vim.lsp.enable({ &quot;basedpyright&quot;, &quot;tinymist&quot; })

vim.api.nvim_create_autocmd(&quot;filetype&quot;, {
  pattern = &quot;python&quot;,
  callback = function()
    vim.keymap.set(&quot;n&quot;, &quot;&amp;#x3C;leader&gt;lf&quot;, function()
      vim.cmd(&quot;silent! !ruff format %&quot;)
      vim.cmd(&quot;edit!&quot;)
    end, { desc = &quot;Format Python file with ruff&quot; })
  end,
})
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inline errors, autocompletion, signature help, and quick formatting. No
external plugin manager needed.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://github.com/user-attachments/assets/feafaffe-3db2-4e67-8bb0-ae16d3b69744&quot; alt=&quot;typst-nvim&quot;&gt;&lt;/p&gt;
&lt;p&gt;A couple of years ago, my older brothers introduced me to LaTeX — I thought
that was the gold standard. After showing them Typst, even they&apos;ve switched.&lt;/p&gt;
&lt;p&gt;I&apos;m still watching the &lt;a href=&quot;https://github.com/typst/typst/issues/5512&quot;&gt;HTML support&lt;/a&gt; PR, which would make it even easier to
integrate Typst in more places. But even now, it&apos;s already changed how I work.&lt;/p&gt;
&lt;!--links--&gt;</content:encoded></item><item><title>Remaining silent</title><link>https://pwnwriter.me/notes/remain-silent/</link><guid isPermaLink="true">https://pwnwriter.me/notes/remain-silent/</guid><pubDate>Tue, 15 Jul 2025 04:49:36 GMT</pubDate><content:encoded>&lt;p&gt;&lt;em&gt;Staying silent is powerful. Not every question needs an answer. Not every
comment deserves a reply. And not every situation needs your voice in it.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;I was watching &lt;a href=&quot;https://www.youtube.com/watch?v=neUaSTSKFZc&quot;&gt;this video by Ron White&lt;/a&gt;, where he says,&lt;strong&gt;&lt;em&gt;“You have
the right to remain silent, and I wish to God you’d use it.”&lt;/em&gt;&lt;/strong&gt;.It’s a joke,
but it really stuck with me. Silence is a choice, one that can save you from
unnecessary trouble and help you stay in control of yourself.&lt;/p&gt;
&lt;p&gt;When you’re quiet, you notice more, not just what people say, but how they
feel. You give yourself space to think before reacting, and you avoid saying
things you might later regret.&lt;/p&gt;
&lt;p&gt;Remaining silent has helped me focus on what really matters. It gives me time
to reflect and keeps me out of unnecessary conflicts.&lt;/p&gt;
&lt;p&gt;Not every moment needs words. Sometimes, silence says enough.&lt;/p&gt;</content:encoded></item><item><title>life: turned 21</title><link>https://pwnwriter.me/syndications/turned-21/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/turned-21/</guid><pubDate>Fri, 11 Jul 2025 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Turned 21 today.&lt;/p&gt;
&lt;p&gt;Grateful for the growth, the lessons, and the people who made the year feel
worth it.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;older, hopefully a little better.&lt;/mark&gt;&lt;/p&gt;</content:encoded></item><item><title>college: first semester done</title><link>https://pwnwriter.me/syndications/first-sem/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/first-sem/</guid><pubDate>Thu, 28 Nov 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;First semester done.&lt;/p&gt;
&lt;p&gt;Sometimes I miss home, the food, the people, the normal rhythm of everything.
But I’m okay.&lt;/p&gt;
&lt;p&gt;I’m grateful for the professor who helped me connect with people at the
university. I also started working in the computer lab, which feels small from
the outside but means a lot to me.&lt;/p&gt;
&lt;p&gt;&lt;mark&gt;new place, slowly becoming familiar.&lt;/mark&gt;&lt;/p&gt;</content:encoded></item><item><title>travel: first flight, miami bound</title><link>https://pwnwriter.me/syndications/first-flight/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/first-flight/</guid><pubDate>Mon, 26 Aug 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;First flight. First long goodbye.&lt;/p&gt;
&lt;p&gt;Home behind me, Miami ahead.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;trying to act normal at the airport while my whole life is packed in bags.&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded></item><item><title>A Year in Corporate</title><link>https://pwnwriter.me/notes/job/</link><guid isPermaLink="true">https://pwnwriter.me/notes/job/</guid><pubDate>Mon, 19 Aug 2024 23:32:00 GMT</pubDate><content:encoded>&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;After high school, I was at home with my parents, doing errands and
self-learning while preparing for my &lt;strong&gt;USA&lt;/strong&gt; visa interview. Out of the blue, a
well-known company reached out with a job offer. Despite not getting the terms
I hoped for, I decided to take the opportunity.&lt;/p&gt;
&lt;h2&gt;The job&lt;/h2&gt;
&lt;p&gt;There wasn&apos;t anything specifically like working on just one domain. I used to
manage linux server&apos;s, providing technical support and so on.&lt;/p&gt;
&lt;h2&gt;what did i get..?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;The best boys:&lt;/p&gt;
&lt;p&gt;It’s probably impossible to find teammates who are both incredibly helpful and equally supportive and encouraging at the same time.&lt;/p&gt;
&lt;p&gt;Gotta miss them ♥&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;img src=&quot;https://pwnwriter.me/images/mulkot/we.png&quot; alt=&quot;we&quot; style=&quot;width: 100%; max-width: 500px; height: auto;&quot;&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;A new opportunity :&lt;/p&gt;
&lt;p&gt;I was able to introduce &lt;a href=&quot;https://nixos.org/&quot;&gt;&lt;code&gt;nix&lt;/code&gt;&lt;/a&gt; across my teammates, Not
just that but 3 of our production servers now use &lt;code&gt;nix&lt;/code&gt; as a user level and
&lt;code&gt;core&lt;/code&gt; level.&lt;/p&gt;
&lt;p&gt;It was an absolutely fantastic opportunity for me to learn
and apply Nix on servers according to user requirements, rather than just
for personal projects.&lt;/p&gt;
&lt;p&gt;I believe that working to fulfill other’s needs teaches you more than
working on personal projects alone.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;The Experience :&lt;/p&gt;
&lt;p&gt;While I’m a bit sad this is ending, we had an amazing time exploring different
places. My first week alone was filled with tours and adventures. The office
environment was incredibly friendly and helpful, which made the experience even
better.&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center;&quot;&gt;
    &lt;img alt=&quot;patan&quot; src=&quot;https://pwnwriter.me/images/mulkot/patan.png&quot; style=&quot;width: 15rem; height: 20rem; object-fit: cover;&quot;&gt;
    &lt;img alt=&quot;nix-mac&quot; src=&quot;https://pwnwriter.me/images/mulkot/nix.png&quot; style=&quot;width: 15rem; height: 20rem; object-fit: cover;&quot;&gt;
&lt;/div&gt;
&lt;h2&gt;Moving on&lt;/h2&gt;
&lt;aside class=&quot;marginalia&quot; data-note=&quot;miss the boys ♥&quot;&gt;
&lt;p&gt;I&apos;ve my &lt;strong&gt;USA&lt;/strong&gt; flight soon within a very few weeks. I took resign from the job
but i miss the boys i met there. They were absolute amazing people. I wish to
work with them later and forever.&lt;/p&gt;
&lt;/aside&gt;
&lt;!-- links --&gt;</content:encoded></item><item><title>My talk on PTN 11</title><link>https://pwnwriter.me/notes/my-talk-on-ptn/</link><guid isPermaLink="true">https://pwnwriter.me/notes/my-talk-on-ptn/</guid><pubDate>Sat, 03 Aug 2024 20:32:00 GMT</pubDate><content:encoded>&lt;img src=&quot;/_astro/cover.D4X3o7Xu.jpg&quot; alt=&quot;pwnwriter&apos;s talk in owasp Kathmandu&quot;/&gt;
&lt;p&gt;&lt;a href=&quot;https://pentesternepal.com/&quot;&gt;PTN&lt;/a&gt; — Pentester Nepal, is a community for security folks in Nepal. They
organize events, meetups, and challenges. PTN 11 was their 11th anniversary event,
and I got to speak there.&lt;/p&gt;
&lt;p&gt;My talk was on &lt;strong&gt;OS as Code: Using Nix on Apple Silicon&lt;/strong&gt;.&lt;/p&gt;
&lt;h2&gt;What even is Nix?&lt;/h2&gt;
&lt;p&gt;Nix is three things at once — a purely functional programming language, a package
manager, and an entire Linux distro (NixOS) built around it.&lt;/p&gt;
&lt;p&gt;The package repo, &lt;a href=&quot;https://github.com/nixos/nixpkgs&quot;&gt;nixpkgs&lt;/a&gt;, lives on GitHub and is honestly the largest
and most up-to-date collection of packages I&apos;ve seen. Everything is a derivation.&lt;/p&gt;
&lt;h2&gt;NixOS&lt;/h2&gt;
&lt;p&gt;If you take Nix the package manager and build an entire OS around it, you get NixOS.
Your whole system — packages, services, users, networking — lives in a single
&lt;code&gt;configuration.nix&lt;/code&gt; file. That&apos;s the &quot;OS as Code&quot; part. You commit it to git, push
it, and spin up the exact same machine anywhere. Wipe and reinstall? Just run
&lt;code&gt;nixos-rebuild switch&lt;/code&gt;. Back to exactly where you were.&lt;/p&gt;
&lt;h2&gt;Why Nix though?&lt;/h2&gt;
&lt;p&gt;The main idea is &lt;strong&gt;declarative&lt;/strong&gt; configuration. Instead of running:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ apt install git
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You write:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  environment.systemPackages = with pkgs; [ git ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;And you get the exact same result every time, on every machine. That&apos;s the whole
point — &lt;strong&gt;reproducible&lt;/strong&gt; environments. No more &quot;works on my machine&quot; nonsense.&lt;/p&gt;
&lt;p&gt;It&apos;s also &lt;strong&gt;immutable&lt;/strong&gt; by default. All packages live in &lt;code&gt;/nix/store&lt;/code&gt; with their
hash in the path. Nothing overwrites anything. Rollbacks are trivial.&lt;/p&gt;
&lt;p&gt;The learning curve is real though. New filesystem layout, new language, a whole
rabbit hole — but once it clicks, it clicks.&lt;/p&gt;
&lt;h2&gt;Installing&lt;/h2&gt;
&lt;p&gt;The cleanest way to install Nix (not NixOS, just the package manager) is via the
Determinate Systems installer:&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;curl --proto &apos;=https&apos; --tlsv1.2 -sSf -L https://install.determinate.systems/nix | sh -s -- install
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It comes with flake support out of the box, it&apos;s easy to uninstall, and it&apos;s
written in Rust — so obviously I like it.&lt;/p&gt;
&lt;h2&gt;Flakes and Direnv&lt;/h2&gt;
&lt;p&gt;A &lt;code&gt;flake.nix&lt;/code&gt; has two parts — &lt;code&gt;inputs&lt;/code&gt; (your dependencies) and &lt;code&gt;outputs&lt;/code&gt; (what
you&apos;re providing). It pins everything, so unlike the old &lt;code&gt;nix-shell&lt;/code&gt; approach,
you&apos;re not at the mercy of whatever channel your system has.&lt;/p&gt;
&lt;p&gt;Pair that with &lt;a href=&quot;https://direnv.net/&quot;&gt;direnv&lt;/a&gt; and &lt;code&gt;nix-direnv&lt;/code&gt;, and your dev shell loads
automatically when you &lt;code&gt;cd&lt;/code&gt; into a directory. The presentation repo itself is an
example of this — just &lt;code&gt;direnv allow&lt;/code&gt; and you&apos;re in.&lt;/p&gt;
&lt;h2&gt;Home Manager and nix-darwin&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://github.com/nix-community/home-manager&quot;&gt;Home Manager&lt;/a&gt; lets you manage all your dotfiles and user packages declaratively
through Nix. One fun example from the talk — building a starship prompt that
shows a different icon depending on whether you&apos;re on macOS or Linux, all in Nix.&lt;/p&gt;
&lt;p&gt;For Apple Silicon specifically, &lt;a href=&quot;https://github.com/LnL7/nix-darwin&quot;&gt;nix-darwin&lt;/a&gt; brings the NixOS-style
declarative config to macOS. You get the same &lt;code&gt;environment.systemPackages&lt;/code&gt; and
module system but on Darwin.&lt;/p&gt;
&lt;p&gt;This is what makes &quot;OS as Code&quot; actually work on a Mac — nix-darwin handles the
system layer, home-manager handles the user layer, and flakes tie it all together.&lt;/p&gt;
&lt;p&gt;The presentation itself was built and rendered using &lt;code&gt;presenterm&lt;/code&gt;, inside a Nix
flake, on NixOS. Bit of a meta moment.&lt;/p&gt;
&lt;p&gt;You can find the slides at &lt;a href=&quot;https://github.com/pwnwriter/PTN11&quot;&gt;github:pwnwriter/PTN11&lt;/a&gt;.&lt;/p&gt;</content:encoded></item><item><title>career: resigned from job</title><link>https://pwnwriter.me/syndications/job/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/job/</guid><pubDate>Thu, 25 Jul 2024 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Wrapped up my job and said goodbye to the people I worked with.&lt;/p&gt;
&lt;p&gt;I feel sad and excited at the same time, which probably means it mattered. Some
places teach you more than the job description ever says.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;a href=&quot;https://pwnwriter.me/notes/job&quot;&gt;Read my full blog post »&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;</content:encoded></item><item><title>NixOS on a VPS</title><link>https://pwnwriter.me/notes/nixos-hetzner/</link><guid isPermaLink="true">https://pwnwriter.me/notes/nixos-hetzner/</guid><pubDate>Fri, 19 Jul 2024 23:32:00 GMT</pubDate><content:encoded>&lt;img src=&quot;/_astro/nix-pills.DFDk_uot.png&quot; alt=&quot;pwnwriter&apos;s talk in owasp Kathmandu&quot;/&gt;
&lt;p&gt;My company has recently provided me with a VPS to test things before deploying
them to production. This is a great opportunity for me to push the envelope and
use Nix extensively.&lt;/p&gt;
&lt;p&gt;While most VPS providers don&apos;t offer Nix as a default OS installation option,
there is a workaround using &lt;a href=&quot;https://github.com/nix-community/nixos-anywhere&quot;&gt;nixos-anywhere&lt;/a&gt;. In this post, I&apos;ll walk you
through how to install NixOS on any VPS.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;PS: I&apos;m using &lt;code&gt;hetzner&lt;/code&gt; vps, already running an ubuntu iso.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;How&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://wiki.archlinux.org/title/kexec&quot;&gt;Kexec&lt;/a&gt; is a system call that allows you to load and boot into a new kernel
directly from the currently running kernel. We will use it to boot into our
minimal install system without needing a flash drive. Additionally,
&lt;a href=&quot;https://github.com/nix-community/nixos-anywhere&quot;&gt;nixos-anywhere&lt;/a&gt; allows us to automatically format and
partition using &lt;a href=&quot;https://github.com/nix-community/disko&quot;&gt;disko&lt;/a&gt; declaratively, and install the OS.&lt;/p&gt;
&lt;p&gt;Requirements:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A nix(os) system&lt;/li&gt;
&lt;li&gt;Root access over the server&lt;/li&gt;
&lt;li&gt;1 gigs of RAM&lt;/li&gt;
&lt;li&gt;1 cores of cpu&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;Configuration&lt;/h2&gt;
&lt;p&gt;We&apos;ll be using &lt;code&gt;flakes&lt;/code&gt; to define our overall &lt;code&gt;nixos&lt;/code&gt; config.&lt;/p&gt;
&lt;p&gt;The file tree structure should look like this below.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ tree .
   nixos-on-vps
   ├── configuration.nix
   ├── disk-config.nix
   └── flake.nix
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;flake.lock&lt;/code&gt; is similar to &lt;code&gt;cargo.lock,&lt;/code&gt; &lt;code&gt;package.json,&lt;/code&gt; &lt;code&gt;lazy-lock.json&lt;/code&gt; it locks the package git rev hash.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;configuration.nix&lt;/code&gt; is used to define our configuration for the nixos.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;disk-config.nix&lt;/code&gt; is to define our disko config&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The initial &lt;code&gt;flake.nix&lt;/code&gt; skeleton look like this&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  inputs = {

  };

  outputs = { ... }: {

  };
}

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Inside &lt;code&gt;flake.nix&lt;/code&gt;, we&apos;ll add our input repository url, &lt;code&gt;nixpkgs&lt;/code&gt; and &lt;code&gt;disko&lt;/code&gt;.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  inputs = {
    nixpkgs.url = &quot;github:nixos/nixpkgs/nixpkgs-unstable&quot;;
    disko.url = &quot;github:nix-community/disko&quot;;
    disko.inputs.nixpkgs.follows = &quot;nixpkgs&quot;;
  };

  outputs = {  ... }: {

  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;and then we&apos;ll define our output servers. For this example, i&apos;m using my
server name as &lt;code&gt;wolf&lt;/code&gt;, you can name yours anything.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  inputs = {
    nixpkgs.url = &quot;github:nixos/nixpkgs/nixpkgs-unstable&quot;;
    disko.url = &quot;github:nix-community/disko&quot;;
    disko.inputs.nixpkgs.follows = &quot;nixpkgs&quot;;
  };

  outputs = { nixpkgs, disko, ... }: {
    nixosConfigurations.wolf = nixpkgs.lib.nixosSystem { #FIXME: change server name
      system = &quot;x86_64-linux&quot;; # FIXME: Change arch
      modules = [
        disko.nixosModules.disko
        ./configuration.nix
      ];
    };
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;My server is &lt;code&gt;x86_64-linux&lt;/code&gt; you&apos;ll want to change that accordingly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;I&apos;m using the default example &lt;code&gt;disk&lt;/code&gt; config from &lt;code&gt;disko&lt;/code&gt;. It&apos;ll create &lt;code&gt;boot&lt;/code&gt;
and &lt;code&gt;root&lt;/code&gt; partitions. I also recommend using the default unless you explicitly
want to change. You&apos;ll want to change the device name, to know run &lt;code&gt;$ lsblk&lt;/code&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;disk-config.nix&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{ lib, ... }:
{
  disko.devices = {
    disk.disk1 = {
      device = lib.mkDefault &quot;/dev/sda&quot;; # FIXME: do lsblk  and change it to your&apos;s
      type = &quot;disk&quot;;
      content = {
        type = &quot;gpt&quot;;
        partitions = {
          boot = {
            name = &quot;boot&quot;;
            size = &quot;1M&quot;;
            type = &quot;EF02&quot;;
          };
          esp = {
            name = &quot;ESP&quot;;
            size = &quot;500M&quot;;
            type = &quot;EF00&quot;;
            content = {
              type = &quot;filesystem&quot;;
              format = &quot;vfat&quot;;
              mountpoint = &quot;/boot&quot;;
            };
          };
          root = {
            name = &quot;root&quot;;
            size = &quot;100%&quot;;
            content = {
              type = &quot;lvm_pv&quot;;
              vg = &quot;pool&quot;;
            };
          };
        };
      };
    };
    lvm_vg = {
      pool = {
        type = &quot;lvm_vg&quot;;
        lvs = {
          root = {
            size = &quot;100%FREE&quot;;
            content = {
              type = &quot;filesystem&quot;;
              format = &quot;ext4&quot;;
              mountpoint = &quot;/&quot;;
              mountOptions = [
                &quot;defaults&quot;
              ];
            };
          };
        };
      };
    };
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now the configuration part.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;configuration.nix&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;We&apos;ll first import &lt;code&gt;qemu-guest&lt;/code&gt; module since it&apos;s a virtual machine.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{ modulesPath, config, lib, pkgs, ... }: {
  imports = [
    (modulesPath + &quot;/installer/scan/not-detected.nix&quot;)
    (modulesPath + &quot;/profiles/qemu-guest.nix&quot;)
    ./disk-config.nix
  ];
  system.stateVersion = &quot;24.05&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I&apos;ll be using &lt;code&gt;grub&lt;/code&gt; as bootloader.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  boot.loader.grub = {
        efiSupport = true;
        efiInstallAsRemovable = true;
    };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Openssh&lt;/code&gt; is necessary for us to be able to ssh into the new server.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
  services.openssh.enable = true;
  users.users.root.openssh.authorizedKeys.keys = [
    &quot;Your public ssh key&quot; #FIXME: Add your ssh public key
  ];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;You can define aditional packages to install as follows.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{
environment.systemPackages = map lib.lowPrio [
    pkgs.curl # FIXME: define your more packages here
    pkgs.gitMinimal
  ];
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, our final &lt;code&gt;configuration.nix&lt;/code&gt; should look like this.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-nix&quot;&gt;{ modulesPath, config, lib, pkgs, ... }: {
  imports = [
    (modulesPath + &quot;/installer/scan/not-detected.nix&quot;)
    (modulesPath + &quot;/profiles/qemu-guest.nix&quot;)
    ./disk-config.nix
  ];
  boot.loader.grub = {
    efiSupport = true;
    efiInstallAsRemovable = true;
  };
  services.openssh.enable = true;

  environment.systemPackages = map lib.lowPrio [
    pkgs.curl
    pkgs.gitMinimal
  ];

  users.users.root.openssh.authorizedKeys.keys = [
    &quot;You&apos;re public ssh key&quot;
  ];

  system.stateVersion = &quot;24.05&quot;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2&gt;Install the os&lt;/h2&gt;
&lt;p&gt;Now, we&apos;ll have to run our flake to install nixos on the vps.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt; nix run github:nix-community/nixos-anywhere -- --flake .#wolf root@&amp;#x3C;server-ip&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It&apos;ll ask for the root password on installation.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;~/assets/nix-vps/ssh-upload.png&quot; alt=&quot;&quot;&gt;
&lt;img src=&quot;~/assets/nix-vps/final.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;Congratulations!! The nixos has been installed successfully. You should be able to just edit the configuration and run&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;sudo nixos-rebuild switch --flake .#wolf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;the other time on any changes.&lt;/p&gt;
&lt;h2&gt;Aditional tips&lt;/h2&gt;
&lt;p&gt;You can use &lt;code&gt;justfile&lt;/code&gt; to make this script running easy&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-makefile&quot;&gt;set shell := [&quot;zsh&quot;, &quot;-c&quot;]

_default:
    @just -l

alias g := gens
alias c := clean
alias r := rebuild

# lists build generations
gens:
    @echo &quot;Listing build generations &quot;
    @nix-env --list-generations

# cleans up the nix garbage
clean:
    @echo &quot;Cleaning up unused Nix store items&quot;
    @sudo nix-collect-garbage -d

# Builds the serer
rebuild:
    @echo &quot;Rebuilding server configuration&quot;
    @sudo nixos-rebuild switch --flake .#wolf
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The recipe should look like this&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;$ just
Available recipes:
    clean   # cleans up the nix garbage
    c       # alias for `clean`
    gens    # lists build generations
    g       # alias for `gens`
    rebuild # Builds the serer
    r       # alias for `rebuild`
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Now, rebuild the os with &lt;code&gt;just r(ebuild)&lt;/code&gt;&lt;/p&gt;
&lt;p&gt;Well, it&apos;s this for now, I&apos;ll see you in the next one!&lt;/p&gt;
&lt;!--links--&gt;</content:encoded></item><item><title>life: US student visa approved</title><link>https://pwnwriter.me/syndications/got-visa/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/got-visa/</guid><pubDate>Mon, 20 May 2024 00:00:00 GMT</pubDate><content:encoded>&lt;aside class=&quot;marginalia&quot; data-note=&quot;still feels unreal to type&quot;&gt;
&lt;p&gt;Got my US student visa approved today.&lt;/p&gt;
&lt;/aside&gt;
&lt;p&gt;It still feels unreal to type that. I’m flying to Miami for my bachelor’s, and a
chapter that used to live in my head is becoming an actual date on the calendar.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Miami, FL. soon.&lt;/strong&gt;&lt;/p&gt;</content:encoded></item><item><title>My talk on owasp 0x03</title><link>https://pwnwriter.me/notes/owasp-talk/</link><guid isPermaLink="true">https://pwnwriter.me/notes/owasp-talk/</guid><pubDate>Tue, 25 Apr 2023 20:32:00 GMT</pubDate><content:encoded>&lt;img src=&quot;/_astro/cover.Com0j-Ay.jpg&quot; alt=&quot;pwnwriter&apos;s talk in owasp Kathmandu&quot;/&gt;
&lt;p&gt;&lt;a href=&quot;https://owasp.org/&quot;&gt;OWASP&lt;/a&gt; is a non-profit open-source foundation focused on application
security. They have local and global &lt;a href=&quot;https://owasp.org/chapters/&quot;&gt;chapters&lt;/a&gt; around the world
aimed at improving community security. We also have a local chapter called
OWASP Kathmandu, which organizes events to spread awareness within Nepal. I had
the privilege to speak at OWASP Kathmandu 0x03, where I presented my tool,
&lt;a href=&quot;https://github.com/pwnwriter/haylxon&quot;&gt;Halyxon&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;If you do not know what haylxon is, simple, it&apos;s a tool to take screenshots of webpages
from terminal using chrome&apos;s headless feature written in Rust!&lt;/p&gt;
&lt;p&gt;You can simply take a screenshot of a webpage as below!&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;hxn -b &amp;#x3C;path/to/chrome/bin&gt; -u https://example.com
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;But wait, you could do that by just opening a browser and pressing some holy keys?&lt;/p&gt;
&lt;p&gt;I mean yeah? but what would you do when you have &lt;code&gt;hundreds&lt;/code&gt; of urls, sub(domains) to take?&lt;/p&gt;
&lt;h2&gt;Background&lt;/h2&gt;
&lt;p&gt;There were already tools like &lt;a href=&quot;https://github.com/RedSiege/EyeWitness&quot;&gt;Eyewitness&lt;/a&gt; and &lt;a href=&quot;https://github.com/sensepost/gowitness&quot;&gt;Gowitness&lt;/a&gt;, which I had used,
but they either felt slow or lacked my personal touch. Since I wanted to learn
Rust, it was the perfect time to start a new project, especially since I
already had a basic knowledge of Rust.&lt;/p&gt;
&lt;h3&gt;Usages&lt;/h3&gt;
&lt;p&gt;Haylxon comes with a lot of options now, reading urls from &lt;code&gt;stdin&lt;/code&gt;, &lt;code&gt;defining ports&lt;/code&gt;, &lt;code&gt;running arbitary javascript&lt;/code&gt; and a lot more.&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;~/assets/owasp/haylxon.png&quot; alt=&quot;&quot;&gt;&lt;/p&gt;
&lt;p&gt;You can pass a file containing a list of urls, and define height, width and
even a delayed screenshot is possible.&lt;/p&gt;
&lt;p&gt;One of the most interesting usages of this tools is that, you can run it on &lt;code&gt;github&lt;/code&gt; actions
to take screenshot of your subdomain, zip that out upload to &lt;a href=&quot;https://0x0.st/&quot;&gt;0x0.st&lt;/a&gt; or your own instance.&lt;/p&gt;
&lt;p&gt;After the talk, I was gifted some cute swag and a letter of appreciation. I
want to thank OWASP for that &amp;#x3C;3.&lt;/p&gt;
&lt;!---Links--&gt;</content:encoded></item><item><title>year: 2023 in review</title><link>https://pwnwriter.me/syndications/2023/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/2023/</guid><pubDate>Mon, 02 Jan 2023 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;2023 felt slower in the best way.&lt;/p&gt;
&lt;p&gt;I got to stay home more, help at our shop near the school, talk with people,
play cricket with kids, and spend time with my brother. The foggy morning runs
were rough, but I miss them now.&lt;/p&gt;
&lt;p&gt;On the computer side, I wrote &lt;a href=&quot;https://github.com/pwnwriter/haylxon&quot;&gt;haylxon&lt;/a&gt; and &lt;a href=&quot;https://github.com/pwnwriter/hysp&quot;&gt;hysp&lt;/a&gt;, both in
Rust, and went way deeper into the language than I expected. I also made it back
to Threatcon.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;less rushing, more noticing.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;Bye!&lt;/p&gt;</content:encoded></item><item><title>year: 2022 in review</title><link>https://pwnwriter.me/syndications/2022/</link><guid isPermaLink="true">https://pwnwriter.me/syndications/2022/</guid><pubDate>Sat, 01 Jan 2022 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;2022 was a strange little unlock.&lt;/p&gt;
&lt;p&gt;I launched &lt;a href=&quot;https://github.com/metis-os&quot;&gt;METISLinux&lt;/a&gt;, met some really good people at
&lt;a href=&quot;https://threatcon.io/&quot;&gt;Threatcon&lt;/a&gt;, and somehow finished +2 science with grades I was proud
of. I also got recognized by the Dutch government and Cambridge for bug reports,
earned &lt;strong&gt;eJPT&lt;/strong&gt;, and started learning &lt;strong&gt;Rust&lt;/strong&gt;.&lt;/p&gt;
&lt;p&gt;The best part was not even the certificates. It was meeting old online friends
in real life and realizing &lt;mark&gt;the internet had been real people this whole time&lt;/mark&gt;.&lt;/p&gt;
&lt;p&gt;See yaa!&lt;/p&gt;</content:encoded></item></channel></rss>