# Proofs are cheap now. Specs are next

> Source: <https://www.genproof.com/blog/quickproof>
> Published: 2026-11-04 14:06:32+00:00

# Proofs are cheap now. Specs are next

For 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.

The 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.

[QuickProof](https://www.genproof.com/quickproof) solves the first problem for C. It lets you write the specification in the language of the code
itself: a small C program that calls your library, much like a test. QuickProof turns it into a theorem, AI
agents prove it, and Rocq checks the proof. Nothing the agents write needs to be trusted.

This post shows how to state and prove properties of production C code with QuickProof. The same process
finds bugs, even in libraries that are [already verified](#linux-a-two-decade-old-cve).

## Specs in C

Let's take the good old compression round-trip property: decompressing must give back what was compressed. In QuickProof, the spec is a C program:

```
int main(void) {
    uint8_t data[4096], packed[8192], unpacked[4096];
    size_t n = nondet_size();       // any length ...
    qp_assume(n <= sizeof data);    // ... up to 4 KiB
    nondet_bytes(data, n);          // any contents
    size_t m = compress(data, n, packed);
    size_t k = decompress(packed, m, unpacked);
    // decompressing gives back the original data
    qp_assert(k == n && memcmp(unpacked, data, n) == 0);
    return 0;
}
```

It reads like a property-based test, with three kinds of special functions:

- `nondet_*` ("nondeterministic") gives an arbitrary input;
- `qp_assume` restricts the inputs;
- `qp_assert` checks a result.

A 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.

Here is what QuickProof does with this spec:

First, `qp assemble` turns the spec and your library into a theorem in the Rocq proof assistant: every
`qp_assert` holds for every allowed input, and the code has no undefined behavior (more on that below).
The theorem statement is fixed before any proof work starts.

Then AI agents prove it in the [Verified Software Toolchain (VST)](https://vst.cs.princeton.edu/), a Rocq
framework for C. They write the function specifications of `compress` and `decompress`, their loop invariants and the proofs, and they may
change only proof files. If `decompress` lost a byte on some input, the proof would get stuck, and the
agent would look for that input: you would get a bug with a reproducer instead of a theorem.

Finally, [`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
unchanged, and that the proof uses only standard axioms. So an agent can't cheat by weakening the statement or
by assuming what it has to prove.

### How we translate specs to Rocq

The translation is simple, and this is what makes QuickProof trustworthy. We use VST, a Rocq framework
for reasoning about C programs, developed at Princeton for over a decade and used, for example, to verify
OpenSSL's SHA-256. For the spec's `main`, the only thing we state in VST is that it has no
undefined behavior (in particular, it is memory-safe).

To 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".

- `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".
- `qp_assert(c)` : the specification requires`c` to hold before the call, so every assertion is something the proof must show.
- `qp_assume(c)` : the specification promises that`c` holds after the call.

So the theorem states that, whatever values the `nondet_*` calls return, each run of the spec that passes
its `qp_assume` s satisfies all its `qp_assert` s and has no undefined behavior. This rules out memory
errors (out-of-bounds accesses, use-after-free, double free, null dereferences, reads of uninitialized
memory), division by zero, bad shifts and signed overflow, in your library as well as in the spec.

## Nowhere for bugs to hide

We took dozens of popular C projects and wrote specs for them. For some of them, we asked an AI to translate the
promises made in the documentation into specs, and then reviewed the specs ourselves. That alone was enough:
trying to prove these specs, we found more than [300 bugs](https://bugs.genproof.com/) where the code violates
them.

### Linux: a two-decade-old CVE

Theorem and Anthropic recently [verified](https://www.anthropic.com/research/formal-verification) a core
component of the Linux kernel and concluded that it is "almost fully correct", apart from a few minor,
unexploitable bugs in corner cases. When we verified the same code, we found a flaw that has been there for
two decades and can be exploited remotely to switch a device off. We have reported it as a CVE.

### Redis: an upgrade that crashes the server

[Redis](https://github.com/redis/redis) stores small hashes, lists and streams in *listpacks*, a compact
format with its own [format specification](https://github.com/antirez/listpack). The format specification
defines what a valid listpack is, and Redis promises to accept valid listpacks. So our spec says: every
listpack that is valid by the format specification passes Redis's deep validation and reads back exactly.

```
int main(void) {
    struct model m;
    // any listpack, written by the rules of the listpack specification
    unsigned char *lp = write_any_listpack(&m);
    qp_assume(lp != NULL);
    // Redis's deep validation accepts it ...
    qp_assert(lpValidateIntegrity(lp, lpBytes(lp), 1, NULL, NULL) == 1);
    // ... and reading it gives back every entry that was written
    unsigned char *p = lpFirst(lp);
    for (size_t i = 0; i < m.n; i++) {
        qp_assert(entry_is(p, &m, i));
        p = lpNext(lp, p);
    }
    qp_assert(p == NULL);
    return 0;
}
```

`write_any_listpack` and `entry_is` come from a C model of the format specification, a few hundred lines
written from its tables: the writer uses `nondet_*` to choose the entries and encodes them in every way the
format specification allows.

On Redis 8.10, the proof of this spec got stuck. At three entry lengths (16,383, 2,097,151 and 268,435,455
bytes) the listpack format allows two encodings of the entry's length field. Redis up to 8.6 wrote the longer
one. A fix in 8.8 switched to the shorter one, but the same function is also used to *read* existing entries.
So Redis 8.8 and later misread entries written by 8.6 and earlier. With default settings, the first command
that reads such an entry, say a stream field of 16,378 bytes, fails an assertion and stops the server. With
full payload sanitization, the server refuses to load the RDB snapshot at all. Routine operations are enough
to hit it: upgrading a server, upgrading a replica before its primary, restoring a dump. We
[reported it](https://github.com/redis/redis/issues/16078).

#### Why didn't tests or fuzzing catch it?

Tests 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.

### Not just Linux and Redis

The same approach found bugs in [zstd](https://github.com/meta/zstd),
[OpenSSL](https://github.com/openssl/openssl), [Mbed TLS](https://github.com/Mbed-TLS/mbedtls),
[LZ4](https://github.com/lz4/lz4), [nginx](https://github.com/nginx/nginx),
[c-ares](https://github.com/c-ares/c-ares), [nghttp2](https://github.com/nghttp2/nghttp2),
[nanopb](https://github.com/nanopb/nanopb), [protobuf-c](https://github.com/protobuf-c/protobuf-c),
[musl](https://musl.libc.org/) and many other libraries, and in the Linux kernel. Each bug is real and comes
with an input that triggers it, and some are serious. All the bugs, with their status upstream, are at
[bugs.genproof.com](https://bugs.genproof.com/).

## QuickProof vs. OSS-Verify

Let's compare QuickProof with OSS-Verify, a recent project by Anthropic and Theorem. They
[announced](https://www.anthropic.com/research/formal-verification) that Claude had verified 239 entry points
of 123 open-source libraries. This is impressive work, and it shows that AI agents can now write formal
proofs at a scale nobody could afford before. Unlike ours, their statements are about the compiled RISC-V
binary, so the compiler doesn't have to be trusted.

We 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.

This 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.

Let's look at zstd. Here is its `zstd_frame_info` fuzz target (abridged), one of those with proved
statements:

``` js
int LLVMFuzzerTestOneInput(const uint8_t *src, size_t size) {
    ZSTD_FrameHeader zfh;
    ZSTD_getFrameContentSize(src, size);
    ZSTD_findFrameCompressedSize(src, size);
    ZSTD_getDictID_fromFrame(src, size);
    // ... six more functions
    ZSTD_getFrameHeader(&zfh, src, size);
    return 0;
}
```

It calls ten frame-inspection functions on arbitrary bytes and throws their results away. Its proof says
that none of them crashes, and nothing about what they return. When we wrote QuickProof specs for the same
functions, checking their results against what `zstd.h` documents, we found that
`ZSTD_getDictID_fromFrame` returns the wrong value for skippable frames
([zstd#4881](https://github.com/meta/zstd/issues/4881)): a bug in a function their proof covers, but one
the proof can't see, because nothing looks at the result.

Our stronger spec doesn't need a bigger proof, either. Their proof for `zstd_frame_info` is about 450,000
lines 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,
and it proves much more: 19 properties of zstd's decoder, including the one about
`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
cases of undefined behavior in zstd.

## Try it!

You can try QuickProof [here](https://github.com/genproof/quickproof-public). Write a spec for your own C library, let an
agent 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.
