The Bug Was Never the Point
Inside the mindset and craft of open-source zero-day research dead ends, silent assumptions, patch archaeology, and the one wrong function name that kept CVE-2026-32808 alive.
I have no security certifications worth mentioning, and I don't think they'd have helped. This field isn't learned from a syllabus, it's learned from being wrong, over and over, in ways that leave a mark. Every real skill I have came from a dead end I refused to walk away from, a hypothesis I loved and had to kill, a "fix" I trusted that turned out to be one function name away from useless. So this isn't a tutorial. It's closer to a field journal - how I actually think, how I search, how I keep going through the long stretches of finding nothing, and how deep a single bug goes when you stop skimming and read until the code confesses.
The bug is CVE-2026-32808 in pyLoad. But the bug is the lens, not the subject. The subject is the craft.
First, the unglamorous truth
Software security research - the source-auditing, zero-day kind - is mostly reading code that turns out to be fine. Not occasionally. Mostly. You'll open a repo, live in it for a day, understand it better than half its contributors, and find nothing. Then do it again. Early on I'd close the laptop convinced I'd lost whatever I thought I had. That quiet shock of nothing isn't a verdict on you, it's the texture of the job. The first thing you build isn't a skill, it's a temperament: a mind that can sit in the nothing without flinching. Everyone who can't sit in it leaves. The ones who stay aren't smarter - they just made peace with the silence and learned to read it.
Because "found nothing" is never actually nothing. Every function you read and cleared is territory you've now mapped as safe. You weren't failing, you were building the map. The bug is just the moment the map shows a cliff nobody drew.
The reframe that changes everything
Beginners think the goal is to find bugs. It isn't. The goal is to understand a system so completely that its mistakes become visible. Bugs are a side effect of understanding, not the target of a search. Once that clicks, your bad days stop being wasted, because understanding compounds even when findings don't.
So when I open a target I don't hunt. I read to answer one question: what did the people who wrote this believe so deeply they never checked it? Bugs don't live in code. They live in the gap between a silent assumption and reality. The whole job is making silent assumptions speak.
pyLoad is a good target for a structural reason, not a clever one: it's a download manager, so its entire purpose is to ingest content a stranger controls - links, files, archives - and process it on your box, often as a service, sometimes as root in a container. When a program's job is to eat hostile input, the attack surface isn't something you manufacture. It's the whole thing. I choose targets like this on purpose. Danger by design beats danger by accident every time.
How I actually search - the part no course covers
Before I read a single line for bugs, I do reconnaissance on the project, not the code. This is the search skill people most underrate, so I'll be concrete about the moves.
I read the project's security history as a map. A one-liner in the repo pays for itself:
git log --oneline --all | grep -iE "GHSA|CVE|traversal|zip slip|symlink|security"pyLoad's history came back dense - and clustered. Advisory after advisory in archive handling: a "Zip Slip" prevention commit, a symlink-escape commit, a js2py/dukpy removal that killed an eval-based RCE, and - the detail that made me sit up - a pair of commits reading fix path traversal issue immediately followed by Revert "fix path traversal issue". A security fix that got reverted. That means the bug came back, and the revert exists because the fix broke something. History like that isn't trivia, it's a heat map. It told me, before I'd read any logic, that this project's soft underbelly is archive handling and that its traversal fixes have a habit of not sticking. Guess where I went.
I search the ecosystem, not just the file. When I understand a dangerous pattern, I don't check one spot - I hunt the whole species with code search (GitHub code search, grep.app, Sourcegraph). "Who else parses 7z l output and joins it to a destination?" turns one finding into a class of findings across many projects. Pattern-level search is how a single afternoon's understanding pays out ten times.
I patch-diff obsessively, which I'll come back to, because it's also how you learn.
And then there's the skill I think separates people who find things from people who only read about them:
Read the Issues and PRs. This is where bugs announce themselves.
Most researchers audit code as if the code is the only artifact. It isn't. A repository is a conversation - issues, pull requests, review comments, reverts - and the conversation leaks security bugs constantly, from people who have no idea they're describing one.
Concretely, here's what I pull up for a target like pyLoad and why each one is a lead:
Open PRs touching validation. A quick search surfaced an open PR titled BaseExtractor: fix _validate_archive_entries. Read that sentence as an auditor: the archive-entry validation is still being fixed, right now, in the base class every extractor inherits. An open PR on the exact chokepoint is a flashing sign that the current code is known-imperfect and the boundary is in motion. Code that's actively being patched is where the live bugs are - the maintainers are telling you where they're unsure.
"It broke after the update" bug reports. Around the extraction hardening, users filed issues like Extracting not working and Package extraction no longer works. This pattern is gold. A security fix tightened path handling, legitimate archives started failing, users reported the symptoms. Those reports hand you the shape of the fix for free, and they beg the two questions that find variants: did the fix over-correct (breaking valid cases, which hints at a brittle check that might be bypassable) or under-correct (some paths still slip through)? A regression report is a fix's X-ray.
Reverts. That Revert "fix path traversal issue" in the log is not noise - it's a documented moment where a traversal defense was removed and (presumably) never fully re-added. The PR/issue discussion around a revert tells you why the fix was painful, which is usually why the bug is subtle. Returning bugs are among the most valuable things you can find, because everyone assumes a CVE'd bug is dead.
Security-hardening batch PRs (I saw one bundling "info disclosure, CSRF, path traversal, and CORS") tell you the classes a project keeps tripping on. Where there were four, there's usually a fifth.
The mindset here: users and maintainers describe security bugs in plain, non-security language all the time - "it deletes my file," "it crashes on this archive," "my config disappeared," "extraction broke after I updated." Your job is to be the person in the room who reads "my config disappeared" and thinks arbitrary file deletion. The issue tracker is a pile of half-reported vulnerabilities written by people who didn't know what they'd found.
The methodology, as a loop (not a checklist)
People ask for my "process" like it's a checklist I run on every target. It isn't. I carry questions, and each codebase screams which ones matter - pyLoad screamed "I shell out to external tools" and "I clone one pattern into five siblings", a crypto library would scream about nonce reuse and timing. The method is the loop, and it always folds back on itself:
flowchart TD
A["Pick a target whose JOB is eating hostile input"] --> B["Read to model the authors'<br/>silent assumptions"]
B --> C["Mine the conversation:<br/>advisory history, open PRs,<br/>reverts, 'it broke' issues"]
C --> D["Form ONE falsifiable hypothesis<br/>(one sentence)"]
D --> E["Trace source → sink<br/>every hop, no jumps"]
E --> F{"Try HARD to kill<br/>your own hypothesis"}
F -- "it dies" --> G["Good. Log WHY it died.<br/>That corpse maps the safe zone."]
G --> B
F -- "it survives" --> H["Prove the primitive:<br/>sentinel file, real environment,<br/>least harm"]
H --> I["Report to the maintainer<br/>as a PARTNER, not an opponent"]
I --> J["Hunt the family:<br/>grep the pattern across<br/>siblings + ecosystem"]
J --> K["Re-audit the FIX like brand-new code<br/>→ variant / incomplete-fix"]
K --> B
The two arrows people skip are the ones that matter: it dies → log why → go again, and re-audit the fix. Most of your time is spent on the first. Most of your best findings come from the second.
Hypotheses you're trying to murder
Here's the mental move that took longest and matters most. You don't "look around for something bad." You form a specific, falsifiable hypothesis and then spend your energy trying to prove yourself wrong.
In pyLoad, mine was one sentence in my notes: a filename from inside an archive reaches a dangerous filesystem operation without sanitization. And here's the trap - I wanted it true. That wanting is the most dangerous thing in the room, more dangerous than any maintainer, because it makes you hallucinate sanitizers that aren't there and wave away the branch conditions that kill your bug. Confirmation bias doesn't feel like bias from the inside, it feels like being right.
So you invert your own incentives until you're happiest when you kill your own hypothesis fast. A hypothesis I murder in ten minutes costs me ten minutes. One I nurse for a week because I love it costs me a week and my credibility. Being wrong at maximum velocity is the engine of this whole field. The bug that survives your honest, aggressive attempts to kill it is the one that's real - and by the time you report it, you've already answered every objection, because you were the one raising them.
The dead end that relocated my attention
Now the part writeups delete, and the part that actually taught me.
My first move on "path traversal in an archive extractor" was the obvious one everyone makes: I chased the write. Zip-Slip. Put a file named ../../etc/cron.d/x in an archive, when extracted it lands outside the target. Built it, ran it, watched.
Nothing.
Modern 7z sanitizes .. on extraction - strips the traversal, writes safely inside. And this is exactly the "nothing happened" moment where most people write "handled" and close the tab. I almost did.
But a dead end isn't an ending. It's a measurement. The malicious archive did nothing at extraction. So the question mutated: from "where does the file get written?" to "what does pyLoad do with its own hands, that 7z has no vote in?" And I remembered my recon note - pyLoad recomputes paths itself for cleanup, in parallel with the external tool. The failed attack physically dragged my eyes off 7z and onto pyLoad's own Python. Had the Zip-Slip worked, I'd have filed a routine finding and never looked at the code that mattered.
That's the real nature of failure in this work. Not motivational-poster "failure teaches you." Mechanical. Each dead attempt eliminates a region of the search space and points at what remains. The researcher who hates dead ends abandons them, the one who's any good interrogates the corpse: you're dead - so what do you tell me about where the live one is?
Where it actually breaks
verify() is called automatically by the auto-extract addon, once per candidate password:
# ExtractArchive.py, ~line 447, "archive testing":
for pw in passwords:
archive.verify(pw)
No click, no confirmation. Reachable the instant an archive hits the queue. Now the function - read the comment first, it's the developer's mind handed over:
def verify(self, password=None):
#: if the header is protected, we can verify the password very fast...
#: otherwise, we find the smallest file in the archive and then try to extract it
encrypted_header, encrypted_files = self._check_archive_encryption()
if encrypted_header:
... # names hidden → quick check, done
elif encrypted_files:
smallest = self._find_smallest_file(password=password)[0]
extracted = os.path.join(self.dest, smallest if self.fullpath else os.path.basename(smallest))
try:
os.remove(extracted) # ← sink #1 (before any extraction)
except OSError:
pass
self.extract(password=password, file=smallest)
...
# except (PasswordError, CRCError, ArchiveError):
# os.remove(extracted) # ← sink #2 (error path)The developer's world splits in two: either the header is encrypted (names hidden, verify fast) or only the data is encrypted while names are readable. In the second case pyLoad reads the names, picks the smallest file, extracts it to test the password.
Second realization, deeper than the first: "encrypted archive = sealed box I don't control" is technically false. Archive formats separate content encryption from header/name encryption. Encrypt the bytes, leave the names bare, and you get an "encrypted" archive that lulls the program while you author every character of its filenames - which fall out of 7z l in plaintext. Encrypting data never meant you could trust metadata. The moment a program conflates "encrypted" with "trusted," it hands you the pen.
Deeper: driving the branch, and the source
To reach the vulnerable branch you have to control _check_archive_encryption, which runs 7z l -slt and classifies on two regexes:
_RE_ENCRYPTED_HEADER = re.compile(r"encrypted archive") # in stderr
_RE_ENCRYPTED_FILES = re.compile(r"Encrypted\s+=\s+\+") # in -slt stdout, per fileSo the archive must show Encrypted = + per entry (data encrypted) without emitting "encrypted archive" on stderr (header not encrypted). That is exactly 7z a -p<pw> -mhe=off - password-protect the data, disable header encryption. This is the whole reachability key, and it's one flag. (More on why I know that flag matters below - I learned it by getting it wrong.)
The source itself, parsed from the plain 7z l (a different, tabular output than -slt):
# _RE_FILES = r"([\d\-]+)\s+([\d:]+)\s+([RHSA.]+)\s+(\d+)\s+(?:(\d+)\s+)?(.+)"
for groups in self._RE_FILES.findall(out):
s = int(groups[3]) # size column
f = groups[-1].strip() # ← name: last group (.+), greedy to EOL - fully attacker-controlled
if smallest[1] == 0 or smallest[1] > s > 0:
smallest = (f, s)Note the selection constraint, because it's a real crafting requirement people skip: smallest[1] > s > 0. The chosen entry must have size > 0 and be the smallest positive one. To guarantee my traversal-named entry is selected, I make it one byte and everything else bigger - or the only file. This is the difference between "I think it's exploitable" and a PoC that fires on the first run: you must drive the selection to your payload.
Deeper still - past "it deletes a file"
Most writeups stop at "os.remove on an attacker path → arbitrary delete." Read further and there's more, and the more is the understanding.
The deletion doesn't need the password. Sink #1 runs before self.extract(...), unconditionally, as "clean up a stale copy." You never had a correct password, you don't need one. The file is gone before extraction is even attempted, and if extraction then fails (it will - wrong password → PasswordError), the except fires sink #2 and deletes again. This isn't "delete if you know the password." It's "delete as a side effect of pyLoad trying a password it will never guess." Password-independent primitives are always worse than they look, because they remove the last precondition an attacker would otherwise need.
os.path.join has two independent footguns. It doesn't collapse .., and an absolute component eats the base:
>>> os.path.join("/data/dl", "../../../etc/cron.d/x") # → '/data/dl/../../../etc/cron.d/x' → realpath /etc/cron.d/x
>>> os.path.join("/data/dl", "/etc/passwd") # → '/etc/passwd' (absolute swallowed the base)The taint doesn't sit still - it becomes state and flows to multiple sinks. _find_smallest_file also builds the full list with the same join (self.files = [os.path.join(self.dest, f) ...]), consumed later in the pipeline, and on a successful test-extract, self.excludefiles.append(smallest) pushes the tainted name into excludefiles, which call_cmd renders into later invocations as -xr!<name> arguments. It's passed as a list element to subprocess, not through a shell - so not command injection - but attacker data crossing into a tool's option/pattern space is its own quieter surface. The lesson is bigger than any one sink: once input is trusted, it doesn't stay put. One tainted string here reaches a delete sink, a path-list sink, and an argv-pattern sink. Find one sink, then assume it has children and go find them.
The write primitive is version-dependent, the delete primitive isn't. extract() uses command = "x" if self.fullpath else "e" and passes the traversal name to 7z x -o<dest> <archive> <file>. Whether 7z x writes a ../ entry outside dest depends on the 7z/p7zip version's own sanitization - modern builds strip it, older ones historically didn't. So on old 7z you may also get a write, on all versions you get the os.remove delete, because that path is computed and executed by pyLoad regardless of what 7z does. Always separate "what the tool guarantees" from "what the caller does independently."
That habit - treating os.remove, os.unlink, shutil.rmtree, os.rename as first-class sinks and asking where did this path come from - came straight out of this bug. Everyone guards the write door. The delete looks like housekeeping, so it's left open. Cleanup code is where security attention goes to die.
The deepest point: one wrong function name kept the bug alive
Here's the layer I most wanted to reach, because it teaches more than the original bug.
The maintainer - GammaC0de, a collaborator, not an adversary (I used to write reports like I was fighting maintainers, it was stupid and it made enemies) - shipped a clean fix. A real containment primitive, and every sink routed through it:
def is_within_directory(base_dir, target_dir):
real_base = os.path.realpath(base_dir) # resolves symlinks AND ..
real_target = os.path.realpath(target_dir)
return os.path.commonpath([real_base, real_target]) == real_base # component-wise
def safejoin(*args):
safe_joined = safepath(os.path.join(*args))
if not is_within_directory(args[0], safe_joined):
raise ValueError("Path traversal attempt detected")
return safe_joinedThis is right. Note also why safepath alone wasn't enough: it sanitizes invalid characters per path component but preserves path structure, so it never removes .. semantics - character hygiene and path containment are different problems, which is exactly why the fix adds is_within_directory on top. CVE-2026-32808 closed.
Except it didn't fully close. Weeks later: CVE-2026-35592, "incomplete fix." And the why is one of the most instructive diffs I've read.
UnTar had a second, older guard predating all this - _safe_extractall, written long ago against the classic Python tarfile traversal (CVE-2007-4559). The first patch hardened the new sinks but never touched this pre-existing guard, whose inline check was:
def _is_within_directory(directory, target):
abs_directory = os.path.abspath(directory) # abspath: does NOT resolve symlinks
abs_target = os.path.abspath(target)
prefix = os.path.commonprefix([abs_directory, abs_target]) # commonprefix: STRING, not path
return prefix == abs_directoryTwo subtle, fatal choices in four lines:
os.path.commonprefix is a string operation - character by character, no notion of a path separator. So destination /data/dl and a target resolving to /data/dl_evil/payload share the string prefix /data/dl, the check returns /data/dl, prefix == abs_directory is true, and the escape passes - even though /data/dl_evil is plainly not inside /data/dl. The bypass is a sibling directory whose name merely starts with the destination's. commonpath compares by component and rejects it. One function name is the entire difference between a real check and a decorative one.
os.path.abspath doesn't resolve symlinks, realpath does. So the old guard was also blind to symlink escape - an extracted symlink pointing at ../../.. looks fine to abspath, and a later member written "through" it lands outside the tree. A follow-up commit added explicit symlink-target validation (reject absolute targets and Windows drive letters, resolve the symlink's target relative to its own location, confirm that stays contained) precisely because even perfect path-joining doesn't stop an extracted symlink from being a second-order escape primitive.
Sit with what this means for how you think. A check that reads correctly in English - it's literally named is_within_directory and returns whether the target is within the directory - was broken by the choice of one stdlib function. If you audit for the presence of a containment check, you miss this every time. You audit the primitive: not "is there a check?" but "does this specific check mean what it says, against .., against symlinks, against string-prefix siblings, against case-folding on Windows, against trailing separators, against the check-to-use TOCTOU gap between realpath and the eventual os.remove?" The incomplete-fix hunt is the same muscle as the original hunt, aimed at the patch: read the fix like brand-new code and ask what it silently assumes.
My field notes, roughly as they looked
I write a lab notebook, not a report, while I work. Findings are the last 5%. The other 95% is hypotheses, kill-attempts, and dead ends - and the dead ends are the valuable part, because they're the map of where the bug isn't, which is what makes variant hunting possible later. A representative slice:
TARGET: pyload/pyload - download manager, ingests hostile archives, runs as service
RECON (before reading logic):
- advisory history clusters in ARCHIVE HANDLING (zip-slip, symlink, js2py RCE)
- found: `fix path traversal issue` → `Revert "fix path traversal issue"` ← bug returned?
- open PR #4754 fixes _validate_archive_entries ← chokepoint still moving
- user issues #4755/#4756 "extraction broke after update" ← fix shape / regression?
=> soft underbelly = archive extractors. Start there.
H1: archive filename → dangerous fs op, unsanitized.
KILL-ATTEMPTS:
[x] write/extraction sink (Zip-Slip)? → DIED. modern 7z sanitizes `x`. ← relocated attention
[ ] what does pyLoad do ITSELF, not via 7z? → os.remove(join(dest, name)) in verify() ✔ survives
[ ] basename clip in default path? → no, only when fullpath=False, default is True ✔
[ ] does name get sanitized anywhere on the path? → reread, no ✔
[ ] reachable? → ExtractArchive:447, auto, per pw ✔
=> H1 survives. Note the branch key: need encrypted_files (not header). regexes:
"Encrypted = +" present, "encrypted archive" absent → craft with -mhe=off
DEAD END (PoC #1): archive did nothing. WHY? I let 7z encrypt the header too (default w/ -p),
so names hidden → encrypted_header branch → never reached smallest-file code.
FIX: -mhe=off. one flag. (lesson: a failed PoC usually means you misread the branch, not the bug.)
VARIANT DEBT (from hour 1: BaseExtractor clones pattern):
- same join in UnRar/UnZip/UnTar/HjSplit + self.files list sink + excludefiles taint
- CHECK AFTER FIX: did they cover every sink? every extractor? the tar guard?
That last line is why I found the fix was incomplete before the second CVE existed publicly: the debt was written down.
How you actually learn this - from mistakes, not certificates
You don't get to "that commonprefix is wrong" by being clever in the moment. You get there by having read it wrong a hundred times, or having read a hundred bugs that turned on exactly that. The seeing is compiled from experience. So the real question - bigger than any single bug - is how you build and feed that mind:
The whole -mhe=off lesson I keep pointing at? I learned it because my first PoC silently failed and I sat there confused. That confusion, not a course, taught me that a dead PoC usually means you've misjudged which branch your input drives. Nobody could have handed me that, I had to fail into it. Almost everything I know arrived that way - as the residue of a specific mistake. Certificates test whether you can recognize a named concept on a multiple-choice screen. This work tests whether you can feel a wrong function name before you can articulate why. Those are different organs.
What feeds the second one, in practice: patch-diff relentlessly - take a fixed CVE, hide the diff, read the vulnerable version, try to find the bug yourself, then compare, the fix is a labeled answer key pointing at the exact boundary crossed, and this whole pyLoad incomplete-fix is itself that lesson. Keep two growing libraries in your head and on disk - a footguns library (os.path.join swallowing absolutes, commonprefix vs commonpath, abspath vs realpath, pickle executing, yaml.load instantiating, integer truncation on length fields) and a primitives library (arbitrary delete/write, path containment, TOCTOU, type confusion, UAF), every bug you read adds an entry, and after a few years they stop being lists you consult and become a smell you have before you can explain it. Read other people's writeups for the moves, not the findings - reverse-engineer why they looked there, which "that's weird" they refused to rationalize away. Chase every "huh, that's odd" without exception - nearly every real bug I've found began as a small wrongness it would've been easier to explain away, the mindset is just the refusal to let it go. Pick targets slightly above you on purpose, because comfort is stagnation. And detach your worth from your findings, or you won't last - measure yourself by the quality of your questions and the honesty of your process, not a CVE count. The compounding is invisible day to day and undeniable year to year, the weeks of "nothing" were you loading pattern after pattern into a mind that will one day glance at four lines and flinch at the word commonprefix.
That's the whole trick
The patch is out (0.5.0b3.dev97), if you run an old pyLoad, update. But the bug was never the point. The point is the posture: sit in long stretches of nothing without flinching, read the project's conversation - history, open PRs, reverts, "it broke" issues - because bugs announce themselves there in plain language, build hypotheses whose murder you'd celebrate, trace every hop without jumping, and when you find a fix - even your own - read it like a fresh target, because a check that reads correct and a check that is correct are separated by exactly one function name, and the entire discipline is learning to feel that difference before you can prove it.
Stop asking "where does the file get written?" Start asking "what does this program do with its own hands, in the exact moment it's most sure it's safe?" That question found the delete. Aimed at the fix, that same question found the commonprefix. It's the only question I really have - and I got it not from a certificate, but from a long line of my own mistakes.