{"slug": "proofs-are-cheap-now-specs-are-next", "title": "Proofs are cheap now. Specs are next", "summary": "QuickProof, a tool that lets developers write formal specifications for C code as small C programs, turns those specs into Rocq theorems that AI agents then prove, with the Rocq proof checker verifying every step so nothing the agents write needs to be trusted. The tool addresses the specification bottleneck in formal verification: an agent that cannot find a proof may weaken the statement or add an assumption, and QuickProof's `qp verify` step rebuilds the proof from a fresh clone to confirm the statement is unchanged and uses only standard axioms. The post also demonstrates that the same process finds bugs, including in libraries that were already verified.", "body_md": "# Proofs are cheap now. Specs are next\n\nFor decades, proving that software is correct had two problems that kept most developers away: the specification had to be written in the language of a proof assistant such as Lean or Rocq (formerly Coq), and the proof was long, often many times longer than the code. The second problem is going away. AI agents now write formal proofs, and a proof checker verifies every step, so a wrong proof can't get through.\n\nThe first problem is still there, and agents make it more important. The proof checker guarantees that the stated theorem is proved, not that it is the theorem you meant. An agent that can't find a proof may weaken the statement or add an assumption, and if you can't read the statement, you can't tell.\n\n[QuickProof](https://www.genproof.com/quickproof) solves the first problem for C. It lets you write the specification in the language of the code\nitself: a small C program that calls your library, much like a test. QuickProof turns it into a theorem, AI\nagents prove it, and Rocq checks the proof. Nothing the agents write needs to be trusted.\n\nThis post shows how to state and prove properties of production C code with QuickProof. The same process\nfinds bugs, even in libraries that are [already verified](#linux-a-two-decade-old-cve).\n\n## Specs in C\n\nLet's take the good old compression round-trip property: decompressing must give back what was compressed. In QuickProof, the spec is a C program:\n\n```\nint main(void) {\n    uint8_t data[4096], packed[8192], unpacked[4096];\n    size_t n = nondet_size();       // any length ...\n    qp_assume(n <= sizeof data);    // ... up to 4 KiB\n    nondet_bytes(data, n);          // any contents\n    size_t m = compress(data, n, packed);\n    size_t k = decompress(packed, m, unpacked);\n    // decompressing gives back the original data\n    qp_assert(k == n && memcmp(unpacked, data, n) == 0);\n    return 0;\n}\n```\n\nIt reads like a property-based test, with three kinds of special functions:\n\n- `nondet_*` (\"nondeterministic\") gives an arbitrary input;\n- `qp_assume` restricts the inputs;\n- `qp_assert` checks a result.\n\nA test checks the inputs it happens to try. A proof covers every input the spec allows: here, every byte string of up to 4 KiB.\n\nHere is what QuickProof does with this spec:\n\nFirst, `qp assemble` turns the spec and your library into a theorem in the Rocq proof assistant: every\n`qp_assert` holds for every allowed input, and the code has no undefined behavior (more on that below).\nThe theorem statement is fixed before any proof work starts.\n\nThen AI agents prove it in the [Verified Software Toolchain (VST)](https://vst.cs.princeton.edu/), a Rocq\nframework for C. They write the function specifications of `compress` and `decompress`, their loop invariants and the proofs, and they may\nchange only proof files. If `decompress` lost a byte on some input, the proof would get stuck, and the\nagent would look for that input: you would get a bug with a reproducer instead of a theorem.\n\nFinally, [`qp verify`](https://www.genproof.com/quickproof/docs/check-a-proof) rebuilds the proof from a fresh clone and checks two things: that the statement is\nunchanged, and that the proof uses only standard axioms. So an agent can't cheat by weakening the statement or\nby assuming what it has to prove.\n\n### How we translate specs to Rocq\n\nThe translation is simple, and this is what makes QuickProof trustworthy. We use VST, a Rocq framework\nfor reasoning about C programs, developed at Princeton for over a decade and used, for example, to verify\nOpenSSL's SHA-256. For the spec's `main`, the only thing we state in VST is that it has no\nundefined behavior (in particular, it is memory-safe).\n\nTo the environment of the program we add exactly three specifications, one for each kind of special function. They are what turn \"the property holds\" into \"no undefined behavior\".\n\n- `nondet_*` : the specification says only that the result has the right type. The proof therefore has to work for every value, which is exactly \"any input\".\n- `qp_assert(c)` : the specification requires`c` to hold before the call, so every assertion is something the proof must show.\n- `qp_assume(c)` : the specification promises that`c` holds after the call.\n\nSo the theorem states that, whatever values the `nondet_*` calls return, each run of the spec that passes\nits `qp_assume` s satisfies all its `qp_assert` s and has no undefined behavior. This rules out memory\nerrors (out-of-bounds accesses, use-after-free, double free, null dereferences, reads of uninitialized\nmemory), division by zero, bad shifts and signed overflow, in your library as well as in the spec.\n\n## Nowhere for bugs to hide\n\nWe took dozens of popular C projects and wrote specs for them. For some of them, we asked an AI to translate the\npromises made in the documentation into specs, and then reviewed the specs ourselves. That alone was enough:\ntrying to prove these specs, we found more than [300 bugs](https://bugs.genproof.com/) where the code violates\nthem.\n\n### Linux: a two-decade-old CVE\n\nTheorem and Anthropic recently [verified](https://www.anthropic.com/research/formal-verification) a core\ncomponent of the Linux kernel and concluded that it is \"almost fully correct\", apart from a few minor,\nunexploitable bugs in corner cases. When we verified the same code, we found a flaw that has been there for\ntwo decades and can be exploited remotely to switch a device off. We have reported it as a CVE.\n\n### Redis: an upgrade that crashes the server\n\n[Redis](https://github.com/redis/redis) stores small hashes, lists and streams in *listpacks*, a compact\nformat with its own [format specification](https://github.com/antirez/listpack). The format specification\ndefines what a valid listpack is, and Redis promises to accept valid listpacks. So our spec says: every\nlistpack that is valid by the format specification passes Redis's deep validation and reads back exactly.\n\n```\nint main(void) {\n    struct model m;\n    // any listpack, written by the rules of the listpack specification\n    unsigned char *lp = write_any_listpack(&m);\n    qp_assume(lp != NULL);\n    // Redis's deep validation accepts it ...\n    qp_assert(lpValidateIntegrity(lp, lpBytes(lp), 1, NULL, NULL) == 1);\n    // ... and reading it gives back every entry that was written\n    unsigned char *p = lpFirst(lp);\n    for (size_t i = 0; i < m.n; i++) {\n        qp_assert(entry_is(p, &m, i));\n        p = lpNext(lp, p);\n    }\n    qp_assert(p == NULL);\n    return 0;\n}\n```\n\n`write_any_listpack` and `entry_is` come from a C model of the format specification, a few hundred lines\nwritten from its tables: the writer uses `nondet_*` to choose the entries and encodes them in every way the\nformat specification allows.\n\nOn Redis 8.10, the proof of this spec got stuck. At three entry lengths (16,383, 2,097,151 and 268,435,455\nbytes) the listpack format allows two encodings of the entry's length field. Redis up to 8.6 wrote the longer\none. A fix in 8.8 switched to the shorter one, but the same function is also used to *read* existing entries.\nSo Redis 8.8 and later misread entries written by 8.6 and earlier. With default settings, the first command\nthat reads such an entry, say a stream field of 16,378 bytes, fails an assertion and stops the server. With\nfull payload sanitization, the server refuses to load the RDB snapshot at all. Routine operations are enough\nto hit it: upgrading a server, upgrading a replica before its primary, restoring a dump. We\n[reported it](https://github.com/redis/redis/issues/16078).\n\n#### Why didn't tests or fuzzing catch it?\n\nTests build listpacks with Redis's own writer, so they only check that a version reads back what that same version writes, and that round trip works in 8.6 and in 8.8 alike. The bug only shows up for an entry written by one version and read by another. A fuzzer would have to produce the longer encoding at one of three specific lengths, and even the smallest of these entries, at almost 16 KiB, is larger than the inputs fuzzers usually generate. Our spec doesn't depend on any version of Redis: it takes every listpack the format specification allows, in every encoding. The proof can't skip the boundary lengths, so it got stuck at those three, and the counterexample showed the bug.\n\n### Not just Linux and Redis\n\nThe same approach found bugs in [zstd](https://github.com/meta/zstd),\n[OpenSSL](https://github.com/openssl/openssl), [Mbed TLS](https://github.com/Mbed-TLS/mbedtls),\n[LZ4](https://github.com/lz4/lz4), [nginx](https://github.com/nginx/nginx),\n[c-ares](https://github.com/c-ares/c-ares), [nghttp2](https://github.com/nghttp2/nghttp2),\n[nanopb](https://github.com/nanopb/nanopb), [protobuf-c](https://github.com/protobuf-c/protobuf-c),\n[musl](https://musl.libc.org/) and many other libraries, and in the Linux kernel. Each bug is real and comes\nwith an input that triggers it, and some are serious. All the bugs, with their status upstream, are at\n[bugs.genproof.com](https://bugs.genproof.com/).\n\n## QuickProof vs. OSS-Verify\n\nLet's compare QuickProof with OSS-Verify, a recent project by Anthropic and Theorem. They\n[announced](https://www.anthropic.com/research/formal-verification) that Claude had verified 239 entry points\nof 123 open-source libraries. This is impressive work, and it shows that AI agents can now write formal\nproofs at a scale nobody could afford before. Unlike ours, their statements are about the compiled RISC-V\nbinary, so the compiler doesn't have to be trusted.\n\nWe wanted to see what such a proof tells you about a library. So we took six of the libraries whose proved entry points are in the OSS-Verify public release: zstd, OpenSSL, libpcap, zlib, Mbed TLS and coturn. We wrote QuickProof specs for them and found bugs in all six.\n\nThis doesn't mean their proofs are wrong: they are machine-checked, and none of our bugs contradicts any of their proved statements. It means that their statements say little about what the library computes. The statements are derived automatically from the libraries' fuzz targets, which are written to find crashes, not to check results.\n\nLet's look at zstd. Here is its `zstd_frame_info` fuzz target (abridged), one of those with proved\nstatements:\n\n``` js\nint LLVMFuzzerTestOneInput(const uint8_t *src, size_t size) {\n    ZSTD_FrameHeader zfh;\n    ZSTD_getFrameContentSize(src, size);\n    ZSTD_findFrameCompressedSize(src, size);\n    ZSTD_getDictID_fromFrame(src, size);\n    // ... six more functions\n    ZSTD_getFrameHeader(&zfh, src, size);\n    return 0;\n}\n```\n\nIt calls ten frame-inspection functions on arbitrary bytes and throws their results away. Its proof says\nthat none of them crashes, and nothing about what they return. When we wrote QuickProof specs for the same\nfunctions, checking their results against what `zstd.h` documents, we found that\n`ZSTD_getDictID_fromFrame` returns the wrong value for skippable frames\n([zstd#4881](https://github.com/meta/zstd/issues/4881)): a bug in a function their proof covers, but one\nthe proof can't see, because nothing looks at the result.\n\nOur stronger spec doesn't need a bigger proof, either. Their proof for `zstd_frame_info` is about 450,000\nlines of Rocq, since it reasons about every instruction of the binary. Our [zstd proof](https://www.genproof.com/quickproof/proofs/zstd) is about the same size,\nand it proves much more: 19 properties of zstd's decoder, including the one about\n`ZSTD_getDictID_fromFrame`, with each function's proof shared by every property that uses it. Along the way, we found more than 20 bugs and\ncases of undefined behavior in zstd.\n\n## Try it!\n\nYou can try QuickProof [here](https://github.com/genproof/quickproof-public). Write a spec for your own C library, let an\nagent prove it, and check the proof yourself with [`qp verify`](https://www.genproof.com/quickproof/docs/check-a-proof). We'd like to hear what you find.", "url": "https://wpnews.pro/news/proofs-are-cheap-now-specs-are-next", "canonical_source": "https://www.genproof.com/blog/quickproof", "published_at": "2026-11-04 14:06:32+00:00", "updated_at": "2026-11-04 14:25:25.333360+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools", "artificial-intelligence"], "entities": ["QuickProof", "Rocq", "Verified Software Toolchain", "Princeton", "OpenSSL", "Lean", "Coq"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/proofs-are-cheap-now-specs-are-next", "markdown": "https://wpnews.pro/news/proofs-are-cheap-now-specs-are-next.md", "text": "https://wpnews.pro/news/proofs-are-cheap-now-specs-are-next.txt", "jsonld": "https://wpnews.pro/news/proofs-are-cheap-now-specs-are-next.jsonld"}}