← Writing
OCT 24, 2026 FIELD NOTE ARGUMENT

I Fired 4,471 CVEs at a Bitcoin Challenge and Came Up Empty


stealmybitcoin.com makes you an offer. There is a binary on the server called /print-flag that prints a Bitcoin private key worth about $2,000, and the challenge text says that binary is “accessible to the PHP user.” The site runs nginx with PHP. The money is real. Go get it.

It is a good hook because it points you at a very specific problem: find a PHP or nginx bug, get code execution, run the binary, read the key. That is the whole genre, and it is what the payout is baiting.

I spent a morning on it and never got close. What I want to be careful about is the reason. The tempting story is “there was nothing to exploit.” The honest story is narrower and more useful: I never reached the thing I needed to exploit, and the method I used could not have told me whether I had.


The obvious plan, and the result that should have made me suspicious

The first move is reconnaissance, and the first finding looked like it settled everything.

Every request came back the same. Not similar, identical. A 12,620-byte index.html, Content-Type: text/html, 200 OK, and the same MD5 (b77ccc2a…) every single time. A legitimate path, a traversal string, a PHP wrapper, a 500-character garbage URL: same bytes back. The one exception was %-style format-string payloads, which came back 400 Bad Request from what looks like a WAF out front.

It is very tempting to read that consistency as proof: a server that runs code produces different output when you change the input, so a server that never varies must not be running code at all. I wrote exactly that in my first draft of these notes. It is wrong, or at least it claims far more than the evidence supports.

Byte-for-byte identical responses are also what you get from a CDN or cache answering in front of the origin, or from a server that routes every unmatched request to the same landing page and quietly drops your input on the floor. The site genuinely runs PHP; the challenge says so and I have no reason to doubt it. So “the responses never changed” does not mean “there is no interpreter.” It means my input never reached one that I could see. Those are different sentences, and the gap between them is the whole point of this piece.

Throwing the catalog at it

So I did the unreasonable version. I pulled the known-CVE list for both components, 449 for nginx and 4,022 for PHP, going back to the PHP 3.x era, and worked through them by category.

Let me be honest about what that number is and is not. “4,471 CVEs” does not mean 4,471 hand-built exploits each lovingly aimed at its specific bug. It means I enumerated the catalog and fired representative payloads for each vulnerability class: RCE strings (system, exec, eval, assert), local-file-inclusion and wrapper tricks, object-injection serialized blobs, directory traversal in every encoding I could think of, nginx buffer and format-string cases, the recent PHP2SHELL heap use-after-free (CVE-2026-109618/109619). The large categories got sampled, not exhausted.

There is a second honesty problem, and it is bigger than the first. I was grading every shot the same way: did the response body change. That catches a reflected result. It is blind to everything that does not echo back to the page, which is most of what actually matters here. Blind command execution, a time-based delay, an out-of-band callback, a file written somewhere I never looked, a side effect that surfaces on some other request. For an RCE or a deserialization bug, a working exploit and a dead one can hand back the exact same 12,620 bytes. So “zero” does not mean “nothing fired.” It means “nothing I could see from where I was standing.”

The result was the same bytes every time, across all of it. Which is what you would predict for a landing page that never routed my input anywhere, and tells you nothing more exciting than that.

The rest of the perimeter

With the application surface looking like “I could not find one,” I checked whether the infrastructure around it was leaking a way in.

Ports. Two open: 80 redirects to 443, 443 serves the page. FTP, SSH, SMTP, the databases, the alternate HTTP ports, all closed. One real entry point.

TLS. TLS 1.3 only, TLS_AES_256_GCM_SHA384, ECDHE for forward secrecy, a valid Let’s Encrypt chain, old protocols refused. Nothing to downgrade, nothing stale.

DNS. A single A record (155.138.222.55), Porkbun as registrar, stable across repeated queries so no rebinding angle. The www, admin, and api subdomains all resolve to the same IP and serve the same file. Worth noting that is equally consistent with one origin and with an edge answering uniformly for everything, which I could not tell apart from outside.

HTTP protocol games. Request smuggling in all three flavors (CL.TE, TE.CL, TE.TE) came back 400. X-Forwarded-For, X-Original-URL, X-Forwarded-Proto were accepted and then ignored. Response times were flat, around 145ms regardless of payload size, so no obvious timing channel on the landing page. I did not run real statistical timing against the crypto path, so read that as “not tested,” not “clean.”

The one genuine soft spot: no rate limiting. Fifty sequential and twenty concurrent requests all returned 200, and a 1MB POST body was accepted without complaint. For this target the blast radius is small, but it is the one thing on the list I would actually fix.

About that /print-flag endpoint

The binary the challenge waves at you does exist. Request it over HTTP and the connection just hangs. Five-plus seconds, no response, every time.

It is tempting to write this up as a deliberate “unreachable endpoint” defense. I want to resist that too. A request that hangs is evidence of no HTTP route mapped to that path, which is the absence of a door, not a guard standing in front of one. And I will be straight about the test conditions: when I ran it, the homepage itself was returning 504, so the whole box was timing out on normal traffic. A gateway that is already failing will swallow /print-flag right along with everything else. So I am not going to lean on that result at all. The direct HTTP path is a dead end; that is all I can say.

The path the challenge actually points at is the indirect one, some reachable input that ends up running as the PHP user. I never found it. A direct GET was never going to.


The part I did not test, which is the part that matters

Here is the honest map of where I did not go, and it is where a funded CTF’s bug almost certainly lives.

  • The Ed25519 proof embedded in the page. The one bespoke, author-written thing on the whole target, and I never looked at it. Crypto challenges live here: nonce reuse, a weak or predictable RNG, signature malleability, a verification oracle, a parameter you can lean on. If I picked this back up, I would start here.
  • The indirect /print-flag path. The challenge hands you the arrow. A CVE sweep will never follow it.
  • Everything I did not enumerate. No content discovery, no parameter fuzzing, no reading of the client-side JavaScript or the requests it makes. One hidden route changes the entire picture.
  • The whole blind and out-of-band column. SSRF, blind injection, anything that needs a callback channel I never set up. Invisible to the way I was grading, by construction.

A clean known-CVE sweep against a target that is openly advertising a way in is not a finish line. It is a signal to put the scanner down and start reading the application.

The part worth keeping

The reusable lesson survives all of this, as long as you state it at the right size.

Reducing what an attacker can reach is one of the highest-leverage things you can do. A server that keeps its interpreter and its sensitive binary off the default request path forces an attacker to go find the entry point instead of tripping over it, and that is real work you have imposed on them. That is why I could fire thousands of payloads and land on zero: the surface they needed was not where I was aiming.

But I am not going to tell you the stronger version, because it is the version that gets people hurt. “You cannot exploit what does not execute” is a great line and a dangerous conclusion, because the code almost always does execute somewhere, and the only question is whether you found the path to it. Reducing reachable surface is a complement to patching, not a replacement for it. A surface you did not find is still a surface.

Two caveats so I do not oversell it. First, “I sent thousands of payloads and got the same bytes back” is a statement about my method, not a verdict on the target. The number could be that large and still land on zero simply because my oracle was blind and my recon was shallow. Second, I did not get the key, and I am specifically not going to tell you it is ungettable. I ran the obvious play, it came back empty, and the most useful thing I can do is show you exactly how far that result does and does not reach.


Update: Challenge Solved

On 25 October 2026 at 15:47:38 UTC, an attacker successfully extracted the Bitcoin private key from stealmybitcoin.com. The exploit bypassed all external defenses by targeting PHP’s internals directly, using a sophisticated multi-stage attack that leveraged memory-limit conditions to leak heap and code addresses, then forged PHP structures to achieve code execution. The attacker’s approach exploited exactly what this piece describes: the indirect path that no brute-force CVE sweep will find. The challenge has been won. This is the kind of elegant, application-logic-aware attack that happens when you put the scanner down and start reading the code.

For the full technical breakdown and working proof-of-concept, see the complete writeup on GitHub.