mirror of
https://github.com/lux-apps/papers.git
synced 2026-11-02 11:39:52 +00:00
123 lines
9.8 KiB
TeX
123 lines
9.8 KiB
TeX
%% ====================================================================
|
|
\section{Fuzz-Discovered Vulnerability: lattigo \texttt{ReadUint64Slice} DoS}
|
|
\label{sec:fuzz-vuln}
|
|
%% ====================================================================
|
|
|
|
The Warp 2.0 implementation went through a constant-time review and a fuzz harness pass before the Mar-3 freeze. The fuzz harness (\texttt{warp/pulsar/fuzz\_pulse\_test.go}, \texttt{warp/pulsar/fuzz\_horizon\_cert\_test.go}, \texttt{warp/fuzz\_envelope\_test.go}) surfaced one production-relevant vulnerability in the \texttt{luxfi/lattice/v7} \cite{lattigov7} deserializer that the Pulsar wire format depends on. This section documents the finding, the exploit shape, and the fix.
|
|
|
|
\subsection{The bug}
|
|
|
|
The lattigo v7 fork carries \texttt{utils/buffer/reader.go} \texttt{ReadUint64Slice}, which recursively reads a uint64 length-prefix followed by a uint64 slice of the declared length. The recursion happens via a partial-peek buffer call: the reader peeks at the length, allocates a slice, and recurses to fill it. If the declared length is unbounded by the available bytes, the recursion descends without making progress.
|
|
|
|
\paragraph{The exploit.} An attacker constructs a wire-format Poly with an inner length-prefix of \texttt{0xFFFFFFFFFFFFFFFF} (or any large value) and only a few bytes of payload. The lattigo deserializer reads the length, allocates a slice of the declared size, and recurses to fill it. The recursion descends without resolving; the Go runtime stack overflows; the process dies.
|
|
|
|
\paragraph{Where it lands.} On the destination chain. Specifically: a destination receiving a Warp 2.0 envelope with an attacker-crafted Pulse byte stream, calling \texttt{warp/pulsar.DeserializePulse}, which delegates to the lattigo deserializer. The destination chain's verifier process panics; the chain's verifier loop crashes. Repeated envelopes denial-of-service the destination's Warp message ingress.
|
|
|
|
\paragraph{Severity.} High. A non-validator attacker can DoS arbitrary destination chains by sending malformed Warp 2.0 envelopes. The Beam aggregate inside the envelope is irrelevant to the attack; the lattigo deserializer is invoked before the envelope is structurally valid. The attack surface is the public Warp 2.0 message channel.
|
|
|
|
\subsection{The fuzz harness that found it}
|
|
|
|
The fuzz harness at \texttt{warp/pulsar/fuzz\_pulse\_test.go} (\cite{warpgo}, file head):
|
|
|
|
\begin{verbatim}
|
|
// FuzzPulseDeserialize runs DeserializePulse over arbitrary bytes and
|
|
// confirms it never panics.
|
|
func FuzzPulseDeserialize(f *testing.F) {
|
|
// Seed: a real pulse from a 3-of-2 ceremony.
|
|
good := mustGoodPulseSeed()
|
|
f.Add(good)
|
|
f.Add([]byte{})
|
|
f.Add([]byte{0x00})
|
|
f.Add(bytes.Repeat([]byte{0xFF}, 1024))
|
|
// ...
|
|
f.Fuzz(func(t *testing.T, raw []byte) {
|
|
_, err := DeserializePulse(raw)
|
|
// Property: errors are fine; panics are not.
|
|
// (The defer-recover in DeserializePulse turns lattigo
|
|
// panics into clean errors.)
|
|
_ = err
|
|
})
|
|
}
|
|
\end{verbatim}
|
|
|
|
The harness ran for 60 s under \texttt{go test -fuzz=FuzzPulseDeserialize} and quickly produced a corpus entry of $\sim 30$ bytes that triggered the lattigo recursion. The diagnostic was a stack overflow in \texttt{lattigo/v7/utils/buffer/reader.go:ReadUint64Slice}; the stack trace showed unbounded recursion through the length-prefix code path.
|
|
|
|
\subsection{The fix}
|
|
|
|
The fix lives in \texttt{warp/pulsar/pulsar.go} (\cite{warpgo} L257--L327 in the canonical commit) in three layers:
|
|
|
|
\paragraph{Layer 1: outer wire-size cap.}
|
|
\begin{verbatim}
|
|
const MaxPulseWireSize = 64 * 1024
|
|
\end{verbatim}
|
|
A real Pulsar lattice threshold signature is $\sim 33\,\mathrm{KB}$ (LP-073 \cite{lp073} \S``Wire Format''); the cap is $64\,\mathrm{KB}$ to leave headroom for ring-parameter changes. The cap doubles as a recursion bound: lattigo's $\mathsf{ReadUint64Slice}$ recurses by length field, so bounding the byte stream bounds the recursion depth ($64\,\mathrm{KB} \div 8 = 8192$ frames worst case, well under Go's default $8\,\mathrm{MB}$ goroutine stack at $64\,\mathrm{B}$ per frame).
|
|
|
|
\paragraph{Layer 2: per-lane frame caps.}
|
|
\begin{verbatim}
|
|
const MaxPulseFrameSize = 32 * 1024
|
|
const MaxLatticeUintSliceLen = 4096
|
|
\end{verbatim}
|
|
$\mathsf{MaxPulseFrameSize}$ bounds each individual lane (C, Z, $\Delta$) inside a serialized pulse. $\mathsf{MaxLatticeUintSliceLen}$ bounds the largest uint64 slice inside any lattigo wire frame. A canonical Pulsar Poly has 256 coefficients per level; a Vector / Matrix has at most $M \cdot N = 8 \cdot 32 = 256$ polys; every inner length-prefix should fit comfortably under 4096. A frame whose declared inner length exceeds this is a structural red flag and is rejected before lattigo's $\mathsf{ReadFrom}$ can recurse.
|
|
|
|
\paragraph{Layer 3: end-to-end frame walker.} \texttt{validatePolyFrame} and \texttt{validateVectorPolyFrame} parse the Poly / Vector[Poly] wire format byte-by-byte before calling lattigo. Every length-prefix is checked against $\mathsf{MaxLatticeUintSliceLen}$; every nested coefficient slice is checked against the available bytes. If the frame is malformed (too short, length-prefix exceeds bound, trailing bytes), the validator returns a clean error and the lattigo deserializer is never invoked.
|
|
|
|
\paragraph{Layer 4: defer-recover boundary.} The \texttt{DeserializePulse} entry point installs a \texttt{defer recover()} handler that converts any lattigo panic that escapes the validators into a clean error (\cite{warpgo} \texttt{warp/pulsar/pulsar.go} L389--L398):
|
|
|
|
\begin{verbatim}
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
sig = nil
|
|
err = fmt.Errorf("warp pulsar: deserialize panic recovered: %v", r)
|
|
}
|
|
}()
|
|
\end{verbatim}
|
|
|
|
This is defense in depth: the frame walker should catch all malformed inputs, but if a future lattigo change introduces a new panic path, the recover boundary ensures the destination chain's verifier process does not crash.
|
|
|
|
\subsection{Why all four layers}
|
|
|
|
The combination is belt-and-suspenders by design:
|
|
\begin{itemize}[leftmargin=2em,nosep]
|
|
\item Layer 1 (wire-size cap) is the cheapest check; rejects 99\% of attack inputs in O(1).
|
|
\item Layer 2 (per-lane caps) catches inputs that fit under the wire cap but blow up an inner frame.
|
|
\item Layer 3 (frame walker) catches inputs whose lengths add up but whose internal structure recurses unboundedly.
|
|
\item Layer 4 (defer-recover) catches any panic that escapes the validators (defense against future lattigo changes).
|
|
\end{itemize}
|
|
|
|
The performance cost is negligible: the frame walker walks the wire bytes once, in $\mathcal{O}(n)$ where $n$ is the byte count, and the lattigo deserializer would walk the same bytes anyway. The only added overhead is the explicit length-prefix bound checks.
|
|
|
|
\subsection{Upstream reporting}
|
|
|
|
The bug is in \texttt{luxfi/lattice} (Lux's pinned fork of \texttt{tuneinsight/lattigo} v7). On 2026-03-03 the Lux team filed the disclosure upstream as \texttt{luxfi/lattice} issue \href{https://github.com/luxfi/lattice/issues/2}{\#2} (``DoS: ReadUint64Slice unbounded recursion + missing length cap'') with the regression seed \texttt{testdata/fuzz/FuzzPulseDeserialize/ccdb090e0ca0007b} from \texttt{github.com/luxfi/warp}. The fix landed in PR \href{https://github.com/luxfi/lattice/pull/3}{\#3} (``security(buffer): bound ReadUintNSlice + iterative rewrite''), which:
|
|
\begin{itemize}[leftmargin=2em,nosep]
|
|
\item replaces the recursion in \texttt{ReadUint\{16,32,64\}Slice} with an O(1)-stack iterative loop;
|
|
\item returns a new sentinel \texttt{ErrZeroProgress} when a Peek decodes zero elements while the destination still has capacity (the exact zero-progress recursion identified in \S\ref{sec:fuzz-vuln});
|
|
\item adds \texttt{Read\{Uint16,Uint32,Uint64\}SliceBounded} length-prefix-aware wrappers gated by \texttt{MaxSliceLen()} (default $1\,\mathrm{MiB}$, configurable via the \texttt{LUX\_LATTICE\_MAX\_UINT64\_SLICE\_LEN} environment variable).
|
|
\end{itemize}
|
|
|
|
Production deployments use the hardened \texttt{warp/pulsar/pulsar.go} validator stack regardless of upstream lattigo behavior, so a downstream lattigo change cannot regress the fix; the upstream patch is defense-in-depth for non-warp callers (ringqp deserialization, ciphertext unmarshalling, multiparty key shares).
|
|
|
|
\subsection{Test surface}
|
|
|
|
The fuzz harness continues to run in CI on every commit. The seed corpus includes:
|
|
\begin{itemize}[leftmargin=2em,nosep]
|
|
\item A valid Pulse from a real $3$-of-$2$ ceremony.
|
|
\item Empty bytes.
|
|
\item Single \texttt{0x00} byte.
|
|
\item Repeated \texttt{0xFF} bytes ($1024$ bytes).
|
|
\item Truncated Pulse prefixes ($1$, $7$, $15$ bytes from a real Pulse).
|
|
\item The original $\sim 30$-byte exploit input.
|
|
\end{itemize}
|
|
|
|
\texttt{FuzzPulseDeserialize}, \texttt{FuzzPulseSerialize}, \texttt{FuzzHorizonCertificate}, and \texttt{FuzzEnvelope} run for 60 s in CI; all four return zero panics on the v1.22.0 tag. The 30-byte exploit is in the corpus as a regression test; it returns a clean error and does not panic.
|
|
|
|
\subsection{Lessons}
|
|
|
|
\begin{itemize}[leftmargin=2em,nosep]
|
|
\item \textbf{Cross-language deserializers are a structural risk.} The lattigo wire format originated in a research library; the destination chain runs untrusted bytes through it. Bound everything; trust nothing.
|
|
\item \textbf{Fuzz harnesses pay for themselves.} The cost of writing the harness was hours; the cost of a destination-chain DoS in production would be days of incident response plus reputation damage.
|
|
\item \textbf{Domain separation is necessary but not sufficient.} The fuzz finding is unrelated to the cryptographic soundness of the Warp 2.0 envelope shape (Section~\ref{sec:soundness}); it is a wire-format DoS. Both surfaces need attention.
|
|
\end{itemize}
|
|
|
|
The Mar-3 freeze ships with all four layers active. Subsequent fuzz finds are tracked in the same harness; new corpus entries are added on every find.
|