Matthias Görgens

A source audit of the Bitcoin Piñata

12 August 2026

The Bitcoin Piñata was a MirageOS unikernel holding the private key to 10 BTC. Connect over TLS, complete a mutual-authentication handshake with a certificate signed by the Piñata’s own CA, prove you hold the corresponding private key, and it sends you the Bitcoin key. The challenge ran from February 2015 to March 2018. Nobody claimed the bounty, and no successful compromise was publicly reported. The owners withdrew the funds themselves.

Three years, roughly US$200,000 at the December 2017 Bitcoin peak. Was the TLS stack actually solid, or did nobody look hard enough?

I spent several days in August 2026 finding out. I pointed two different model families at the code — Codex (GPT-based) and DeepSeek — and asked each to break it. Each model’s output served as a hypothesis generator; every finding was checked against the source by me. Then I built a Docker lab with a source-compatible reconstruction of the January 2018 package set and verified each concrete reproducer against a running unikernel.

The target

The Piñata ran a pinned stack from the mirleft project, which set out to build a “not quite so broken” TLS implementation in OCaml. The opam manifest pins the project’s #pinata branches — ocaml-tls at a844e52 (one commit past the 0.9.0 release, raising the minimum DH and RSA sizes to 2048 bits), ocaml-x509 at 31abe40 (disabling the notBefore check for the Piñata deployment), nocrypto 0.5.4 from January 2017, and asn1-combinators 0.2.0. Five ports:

On the three authenticated ports the Piñata generates a fresh 4096-bit RSA CA at every boot and uses it as the sole trust anchor. To extract the secret you need a certificate signed by that CA and the corresponding private key to produce a CertificateVerify signature over the handshake transcript. The web page puts it plainly: “No, you can’t have the certificate key.”

What I found

The nearly-twelve-year-old five-byte crash

The most striking bug was in parse_change_cipher_spec, the only wire parser in the entire library not wrapped in the catch combinator that converts exceptions into handled errors:

(* lib/reader.ml:118-121 *)
let parse_change_cipher_spec buf =
  match len buf, get_uint8 buf 0 with
  | 1, 1 -> return ()
  | _    -> fail (Unknown "bad change cipher spec message")

OCaml evaluates both expressions in the match tuple before matching. get_uint8 buf 0 on an empty buffer raises Invalid_argument — cstruct 3.2.1 bounds-checks the access — and the exception propagates through the TLS stack uncaught. Every other parser runs through catch, which converts Invalid_argument into a handled Underflow error. This one does not.

Five bytes — 14 03 03 00 00, an empty ChangeCipherSpec record — trigger it on any active ocaml-tls connection, pre-authentication (confirmed on ports :443 and :10000; port :10001 is a plain-TCP trigger so the initial connection is not affected). The release notes for version 2.0.4, tagged 2026-03-10, read: “present since the initial release 0.1.0.” Version 0.1.0 is dated July 2014 — nearly twelve years.

In practice Lwt catches the exception at the monad boundary and kills only the connection, not the whole unikernel. An unauthenticated five-byte record could therefore terminate a connection in every affected release for nearly twelve years.

Memory exhaustion through unbounded fragments

Handshake messages in TLS can be up to 2^24−1 bytes, about 16 MiB. The Piñata buffers incomplete messages in hs_fragment with no size cap, and the accumulation happens in handle_packet before any state-machine check — you do not need to complete a handshake, or even start one properly. One connection can pin up to 16 MiB by declaring a 16 MiB Certificate message and slow-dripping bytes. Ten connections with a single 16 KiB fragment each increased the unikernel’s RSS from 39 MiB to 56 MiB in my lab.

A patch in version 0.12.0 (b17b64f, May 2020) moved the fragment-state update to occur before processing complete messages — a TLS 1.3-driven refactor — but it added no length cap and does not invoke the state machine while a declared 16 MiB message remains incomplete. The attack still works against current upstream (v2.1.2).

Exponential path-building denial of service

The X.509 validator’s build_paths function builds all DN-consistent paths through the presented certificate chain before checking any signatures. With N certificates all sharing the same subject and issuer distinguished name, it enumerates factorially many paths — each one a list of certificate records allocated and traversed.

I generated eleven self-signed certificates with matching DNs, sent them as a client certificate chain to port 10000, and the unikernel stalled for over thirty seconds. Runtime grows factorially with the number of matching-DN certificates. Pre-authentication, triggers on any port that validates a certificate chain. Still present in current upstream (the attacker supplies the chain, so “real chains are short” does not help).

Missing KeyUsage and ExtendedKeyUsage checks (CVE-2026-45389)

After ordinary X.509 path validation — certificate parsing, validity checks, CA-constraint enforcement, chain construction, trust-anchor selection, and signature verification — the server-side TLS code performed no client-role KeyUsage or ExtendedKeyUsage check on the leaf certificate. It did not verify that KeyUsage included digitalSignature or that ExtendedKeyUsage included clientAuth.

The client side of the same library did check EKU for server certificates. The error constructors InvalidCertificateUsage and InvalidCertificateExtendedUsage existed in the code but were only wired up on the client path. The asymmetry is conspicuous once you see it.

This is an impersonation vulnerability: an attacker who legitimately owns the private key for a trusted but wrong-purpose certificate can authenticate as a client directly. For the Piñata specifically, the ephemeral CA means the only trusted certificates are the ones the unikernel generates at boot, so the practical impact is limited — you would still need to obtain a CA-signed certificate and its key. But in a less constrained deployment the missing check is a direct impersonation path.

Fixed in version 2.1.0 on 2026-05-19 (release tagged 2026-05-20), reported by Ben Smyth. The fix treats absent KU/EKU as unconstrained rather than requiring the extensions to be present.

The Piñata talks to itself

Because all three authenticated ports share a single CA, the Piñata’s own endpoints authenticate each other. Bridge port 10000 — which needs a client certificate — to port 10002 — where the Piñata is a TLS client presenting its own — with a TCP relay, and the handshake completes. The secret flows in both directions as encrypted application data. The same trick works through the port 10001 callback by relaying port 40001 back to port 10000.

Although the web page invites you to “enjoy watching it do so” and the relay operator sees only ciphertext, the maintainers’ retrospective describes the same observation: “we can observe the encrypted private key in transit.” The relay demonstrates that the Piñata’s security is one-deep: every endpoint trusts the same CA, so a compromise anywhere breaks the system.

What I did not find

I found no chain-validation bypass in the examined January 2018 TLS and X.509 code paths. Every presented certificate chain ends in a strict RSA-PKCS#1-v1.5 signature verification against the 4096-bit CA key. The parser enforces outer-signature-algorithm equals inner-TBS-signature-algorithm, requires strict DigestInfo parsing with a no-leftovers check, and does full-length hash comparison. The raw_cert_hack for TBS extraction — a hardcoded 15-byte arithmetic — is ugly but fails closed: the strict parser only admits RSA signature algorithms with NULL parameters, which are exactly 15 bytes, and ECDSA issuers are rejected anyway.

I found no certificate-verify bypass. The server binds session.peer_certificate to the head of the validated chain, and CertificateVerify is verified against exactly that key over the full handshake transcript, ClientHello through ClientKeyExchange.

Two boots of the Docker lab produced different CA key fingerprints, consistent with Fortuna seeded from /dev/urandom. This rules out exact deterministic repetition in the reproduced environment; it says nothing about entropy quality in the historical BHyve/Solo5 deployment.

The Marvin-class timing side-channel on port 443 is theoretically concerning. The RSA decryption uses Zarith powm with value-dependent timing. RSA blinding is intended to decorrelate the exponentiation from the attacker’s ciphertext, but Marvin-class leakage can still occur during deblinding, integer-to-byte conversion, or padding handling. A measurement campaign would be research-grade and I did not attempt one. The secret-bearing ports require client authentication before the RSA decryption step, so a direct oracle is not reachable there, but a successful private-key-operation attack against the :443 key — whose certificate is signed by the same CA — could potentially compose with the missing server-side EKU check on port 10000. I did not investigate this composition.

Reproducing the findings

The lab repository is at github.com/matthiasgoergens/btc-pinata-lab.

cd lab
./build.sh                           # Docker image (~5 min first time)
./scripts/run-lab.sh "my secret"     # boot unikernel, run legit test + short-circuit
./scripts/verify.sh                  # run all five vulnerability checks

The Docker image is about 3 GB. Rebuilds take about 30 seconds — the opam layer is heavily cached.

To try the five-byte crash yourself while the unikernel is running:

printf '\x14\x03\x03\x00\x00' | nc 127.0.0.1 443

The connection dies. The unikernel stays up. Five bytes, no authentication, nearly twelve years.

Audit result

The mirleft/ocaml-tls authors set out to build a TLS stack that was “not quite so broken.” The Piñata was their public bet that they had succeeded. After checking both models’ hypotheses against the source and reproducing each concrete finding, I found no chain-validation or CertificateVerify bypass in the pinned TLS and X.509 code.

The audit did find a crash bug present since the initial release, an exponential DoS still in current upstream, and a missing-purpose-check that shipped as a CVE, but none bypassed the mutual-TLS authentication protecting the key.

For a roughly 5,000-line TLS library written in a memory-safe language by a small team, that is a remarkable result. This was an empirical adversarial audit rather than a formal verification; against the concrete goal of bypassing client authentication, the stack held up.

The owners withdrew the 10 BTC in 2018. This 2026 audit provides stronger evidence that the pinned stack resisted the authentication bypass the challenge invited.