
Hacking the old HackerNews codebase
The intention of this post is to showcase the capability of what we've been building. An autonomous AI hacking agent. And for fun!
At Winfunc, our team evaluate and benchmark the ability of LLMs to "hack" or precisely, uncover and exploit security vulnerabilities in codebases. We recently published one of them: N-Day-Bench!
Based on our experiments thus far, we've noticed that these models sometimes struggle when a codebase is too messy. Not just with hacking them but with writing code on top of it.
Hence we spent a lot of research time into taming these models to work with large and messy codebases.
But one specific eval we did not thoroughly conduct was the reliability of uncovering vulnerabilities in "out of distribution" codebases. Say codebases in languages that are not well represented in the training data as much as the popular ones.
So we decided to come up with one. Here's the story.
During YC (we're a YC S24 company!), we had the awesome opportunity to meet PG and talk about our product. To showcase a fun demo, I remember opening my laptop in the Uber to his home and challenging our agents to find vulnerabilities in the old HackerNews codebase written in Arc. For those unfamiliar, Arc is a programming language designed by Paul Graham and Robert Morris. And the old HackerNews codebase is written in Arc.
It did kinda well but this was when Claude Sonnet 3.5 was the frontier model.
The models we have now changes the picture entirely, especially when equipped with a specialized harness.
This blog goes over the complete details of this fun experiment.
The TL;DR is: Our agent discovered 10 vulnerabilities with complete proof-of-concept and exploit. Here's the PDF export of the audit produced by Winfunc:
Read the Winfunc Audit Report.
First, getting this thing to run
The repo looks small. The runtime is a bit of a trap.
The code in this checkout is Arc 3.0-era code. The README says to use MzScheme 372. We tried modern Racket first and ran straight into the old mutable-pair problem. Newer Racket can fake parts of the legacy environment, but this codebase expects the old behavior all the way down.
The least painful way we found was an amd64 Debian container with MzScheme 372 installed from the historical PLT Scheme bundle.
We used:
Then a tiny image:
And a bootstrap script:
After that, the app came up cleanly at http://127.0.0.1:8080.
What we found
Winfunc discovered 12 total findings.
After reproducing them against live instances, we ended up with this:
| ID | Finding | Verdict |
|---|---|---|
| 1 | Admin /repl executes attacker-controlled code via plain web request | Valid |
| 2 | Bootstrap admin username can be claimed through public signup | Valid |
| 3 | Comment edit path skips comment kill rules | Valid |
| 4 | Memoized URL validation grows memory without bound | Valid |
| 5 | userinfo@host URLs bypass site-ban logic | Valid |
| 6 | Vote-after-login continuation treated as CSRF | Invalid |
| 7 | Login redirect can inject response headers | Valid |
| 8 | Concurrent auth-state saves cause durable on-disk corruption | Invalid |
| 9 | Vote/login open redirect | Valid, same root cause as 12 |
| 10 | Vote URLs and logs leak reusable session tokens | Valid |
| 11 | Public login fnid replay swaps victim into attacker account | Valid |
| 12 | Vote/login flow redirects to attacker-controlled external URL | Valid |
Ten survived. Two didn't.
That's a pretty good showing, and the misses are useful too. One of the invalid findings turned out to be ordinary product behavior dressed up as CSRF. The other exposed a real race, but not the durable auth-file corruption the report claimed. This is desirable because it's exactly the kind of pruning you want if you're trying to use a system like this in the real world.
The bugs that held up
1. Bootstrap admin takeover
This is somewhat of an intended behaviour, it's just not secure implementation.
The repository's setup notes tell you to put an admin username in arc/admins, start the server, click login, and create that account. The app grants admin status by username membership in admins*. It does not protect that name during public self-registration.
On a fresh instance with arc/admins containing only adminrace, we did this:
- hit
/whoami - followed the public
Log inlink - used the
Create Accountform to registeradminrace
The response set:
Using that cookie, both /admin and /prompt worked right away.
That means a fresh public deployment can hand its admin account to the first person who shows up and guesses the bootstrap name.
And this can be chained. Because once the bootstrap admin is yours, the next bug matters.
2. /repl is remote code execution with a browser in the middle
The old Prompt app is loaded by default, and it still has a web REPL:
/promptfor apps/replfor direct evaluation
The /repl route only checks whether the ambient session belongs to an admin. It takes expr from the request, parses it, and runs eval.
We used the stolen adminrace session from the previous bug and sent:
Then checked the host-side file:
Observed:
This is the sort of bug that makes people say "well, it's an admin REPL, what did you expect?" What we expected was a nonce, a POST-only path, an origin check, or any sign that the request had to be deliberate. Instead it's a plain web endpoint that will happily execute code from an authenticated GET.
If you want the shortest version of the exploit chain in this post, it's this:
public signup -> bootstrap admin claim -> /repl -> server-side code execution
3. Public login fnid replay lets you stuff a victim browser into your own account
Arc's fnid mechanism is one of the more charming parts of the codebase. It's also where one of the cleaner bugs lives.
The public login form uses a bare fnform. The generated fnid is stored in a global table, not bound to a browser, not bound to a session, not bound to a user.
We reproduced it with three separate cookie jars:
- create attacker-controlled account
sockswap - harvest a public login
fnidfrom/loginin a different anonymous session - replay that
fnidfrom a third, unrelated victim session, but withu=sockswap&p=pw1234
The replay response included:
And the victim session's /whoami changed from:
to:
Not credential theft. Something weirder. The victim browser is now operating as the attacker's chosen account.
4. The login redirect can inject headers
This one is old-school and fun in a nasty way.
The request parser percent-decodes arguments. Later, reassemble-args rebuilds a redirect target without re-encoding them. Then respond() prints the result straight into Location:.
We used the protected resetpw route as the login-gated entry point:
Then created an account through the returned /y form and captured the raw response:
Achieving an injected header line on the application's own origin.
5. The vote/login flow is an open redirect
Winfunc logged this twice, once as finding 9 and once as finding 12. Same root cause. We kept both, but they're the same bug.
The flow is:
- user is logged out
- attacker sends
/vote?...&whence=<attacker URL> - app shows the normal login page
- user logs in
- app redirects to
whencewithout checking whether it's on-site
We created victim2, created a fresh story with id 2, then requested:
After login, the response was:
The target story's score moved from 0 to 1 in the same flow.
So the site does two things at once:
- casts the deferred vote
- bounces the browser to an attacker-controlled origin
That second part is the actual bug. The first part just makes the redirect more confusing.
6. Vote URLs and news logs leak a reusable session token
This one is fun because the token in auth= is not some special-purpose vote nonce. It's actually the live session identifier.
We logged in as victim3. The active session cookie was:
Then we sent a vote request using that same value in auth= and checked the news log:
Then we replayed it from another client:
The server answered:
That's a straight session replay.
The claim in the original finding talked about vote URLs and logs. In our reproduction, the log path alone was enough to prove impact. Once the log has the token, the account is yours until logout.
7. Comment moderation only runs on create, not on edit
This is the kind of bug people miss because the code "basically works."
We set the admin comment kill list to:
Then we did the same content two ways as user commenter1.
First path:
- post a benign comment
- edit it into
buy now SPAMWORD
That produced comment 7, saved as:
No dead flag.
Second path:
- submit a fresh comment directly containing
SPAMWORD
That produced comment 8, saved as:
That's the whole bug in one comparison.
8. Site bans can be dodged with userinfo@host URLs
The URL parsing here is old enough to have sharp corners in places people forgot existed.
We banned example.com with an ignore entry in banned-sites*, then compared two submissions.
Control:
Observed result:
- redirect to the site message page
- page body said
Stop spamming us. You're wasting your time. - no new story created
Bypass:
Observed result:
- redirect to
newest - new story
6created - saved story had no
deadflag
The parser is treating the userinfo form as a different site name, so the enforcement path never fires.
9. Memory growth through memoized URL validation
The app memoizes valid-url. The memoizer never evicts. Nil results are cached too. That means rejected inputs still stay around forever.
On a clean instance at http://127.0.0.1:8085, we:
- logged in as a normal authenticated user
- fetched
/submitonce - reused that form token for 50 submissions
- gave each submission a unique invalid URL string about 50 KB long
- paced them under the rate limiter
- measured MzScheme RSS before, after, and after a short idle pause
Observed:
So the process kept roughly 54 MB of extra resident memory after the requests stopped.
That's a real low-privilege DoS.
The findings that didn't hold up
Sometimes these models flag vulnerabilities that require "hardening" at best but doesn't really pose an impact when the intended trust-boundary or threat model is taken into account.
10. Deferred vote-after-login is behavior, not a security bug
We reproduced the reported behavior exactly:
- open
/vote?for=1&dir=up&whence=newswhile logged out - see the first-party page saying
You have to be logged in to vote. - log in
- watch the score change from
0to1
We marked it invalid.
Why? Because the site tells the user, in plain English, that they're logging in to vote. The action isn't hidden. A link can still be used in social engineering, sure. But that's not enough for us to keep it as a confirmed CSRF bug.
11. The auth-state race is real, but not in the way the finding claimed
This one was the most annoying to validate.
Winfunc reported a race that could corrupt arc/hpw and arc/cooks, survive restart, and even make admin-listed names reclaimable. We could reproduce the race symptom. We could not reproduce the rest.
What we saw from a fresh public instance:
So yes, concurrent public requests do trip over the fixed file.tmp path. We also hit rename failures in a direct concurrency stress test against the real set-pw path.
But after the race settled:
- the password file was still readable
- the on-disk entries matched the successful requests
- we did not get an empty auth table on reload
- we did not get the follow-on "re-register the admin name after restart" condition
That makes the finding invalid as written. There's still a race-induced reliability bug here. There just wasn't enough to support the reported impact.
Why we keep using weird codebases like this
There's an easy way to oversell this target.
We could say "look, our system found bugs in an old Lisp codebase." That's true, but it misses the interesting part. The interesting part is that Arc is the kind of language that wrecks brittle tooling. No off-the-shelf parser support. Old runtime. Weird macros. Plenty of app logic tucked into code paths that don't look like modern web stacks.
Our product/research makes two claims that this exercise tests directly:
- it can read code in basically any language
- it doesn't stop at a report; it pushes through to PoCs and fixes
The older How Asterisk Works post says the system builds a code graph, generates attack ideas, validates them, and throws away what doesn't survive a running target. It's also exactly what happened here:
- 12 findings in total
- 10 that held up
- 2 that didn't
That's a useful ratio, especially on a codebase this odd (for LLMs ig).
Dogfooding these models to be good at hacking
We improve our agents or the new term for it, "harness", on a daily basis based on a lot of evals and benchmarks we conduct.
We have been thinking about this problem/idea since GPT-2 came out. We started practically applying it since GPT-3.5 on CTFs challenges and real-world bug bounties and pentests. We foresaw what's about to happen ever since.
Now "this" is the talk of the town. "Mythos", "GPT-5.4-Cyber", "Trusted Access for Cyber", etc.
One of the other experiments we conduct albeit rarely is letting our agents find 0-days in mission critical software.
Finding real 0-days with LLMs
We do evals that are beyond just weird codebases btw. On real battle-tested codebases.
So far, we've discovered 0-days in Chromium (dislcosure soon), NGINX, Node.js, React (yeah the recent React one), Bun, etc. and more exciting ones that are pending disclosure.
You can see some of our findings on our Hacktivity page: https://winfunc.com/hacktivity.
All of them were discovered autonomously by Winfunc with no human-in-the-loop.
Our OSS auditing initiative
We audited the old HackerNews codebase so of course we're going to post this on HackerNews. So we have an ask/request for the audience.
If you're a maintainer of a widely used non-commercial open-source project, we'd love to audit your codebase for "free". We're basically doing a "mini" Project Glasswing. We'd love to help secure more critical open-source projects.
We've done this before. For example, we audited the Rust crypto implementation of Ente. They even wrote about us: https://ente.com/blog/rust-crypto-audit/. (Thanks!)
If you're a commercial project/company in need of strong proactive security audits, you can book a demo with us.
Thanks for reading our fun little experiment! :)
