Skip to content

Quic server mode fixes#244

Merged
mmaehren merged 3 commits into
tls-attacker:mainfrom
severinsch:quic-server-mode-fixes
May 4, 2026
Merged

Quic server mode fixes#244
mmaehren merged 3 commits into
tls-attacker:mainfrom
severinsch:quic-server-mode-fixes

Conversation

@severinsch
Copy link
Copy Markdown
Contributor

This PR fixes two bugs when using TLS-Attacker as a QUIC server.

See notes for reproducing at the end.

Bug 1: Initial secrets derived from wrong DCID

QuicContext.init() always generates a random firstDestinationConnectionId and calls calculateInitialSecrets().
The RFC requires both peers to derive Initial secrets from the DCID in the client's first Initial packet.

The branch that "fixes" on receive in InitialPacketHandler.adjustContext() is not reached here, because it runs after the decryption (and the condition is false since isInitialSecretsInitialized is true).

Fixes for this are in commit 6f71eb5f6edf713d67e2af0b8e033256e7d5b444:

  • QuiContext.init(): when in SERVER mode skip calculating initial secrets
  • QuicPacketLayer.readPackets(): after parsing packets, if in server mode and secrets not yet initialized, adopt the first Initial's DCID and calculate secrets

Bug 2: CRYPTO frame offsets always zero on send path

sendDataInternal() uses a local offset each time it is called. This leads to all CRYPTO frames in the Handshake being emitted at stream offset 0, when they should be continuously increasing depending on message sizes.

Fixes for this are in commit 9659763ac5eb08151df1ba6209dbff0d1536b7f1:

  • add per-encryption-level send offset counters to QuicFrameLayer . Use tracked offset as base for each call and advance by data.length.

Reproduction

Prerequisites

  • Java 21, Maven
  • Python 3 with aioquic: pip install aioquic

Automated

Drop reproduce.sh and ServerModeQuicRepro.java into a directory, then:

./reproduce.sh /path/to/TLS-Attacker

The script generates a throwaway cert, compiles the Java repro against TLS-Attacker's classpath, starts the server, connects an aioquic client, and reports PASS/FAIL.

ServerModeQuicRepro.java
import de.rub.nds.tlsattacker.core.config.Config;
import de.rub.nds.tlsattacker.core.config.delegate.CertificateDelegate;
import de.rub.nds.tlsattacker.core.config.delegate.QuicDelegate;
import de.rub.nds.tlsattacker.core.config.delegate.ServerDelegate;
import de.rub.nds.tlsattacker.core.protocol.message.*;
import de.rub.nds.tlsattacker.core.protocol.message.extension.quic.QuicTransportParametersExtensionMessage;
import de.rub.nds.tlsattacker.core.state.State;
import de.rub.nds.tlsattacker.core.workflow.*;
import de.rub.nds.tlsattacker.core.workflow.action.GenericReceiveAction;
import de.rub.nds.tlsattacker.core.workflow.action.SendAction;

/**
 * Minimal reproduction for QUIC server-mode bugs.
 * Uses stepped execution so we can set original_destination_connection_id
 * from the received client DCID between receive and send.
 */
public final class ServerModeQuicRepro {
    public static void main(String[] args) throws Exception {
        String certPath = "certs/server.crt", keyPath = "certs/server.key";
        int port = 4433;
        for (int i = 0; i < args.length; i++) {
            switch (args[i]) {
                case "--cert": certPath = args[++i]; break;
                case "--key":  keyPath  = args[++i]; break;
                case "--port": port = Integer.parseInt(args[++i]); break;
            }
        }

        Config config = new Config();
        new QuicDelegate(true).applyDelegate(config);
        ServerDelegate srv = new ServerDelegate();
        srv.setPort(port);
        srv.applyDelegate(config);
        CertificateDelegate cert = new CertificateDelegate();
        cert.setCertificate(certPath);
        cert.setKey(keyPath);
        cert.applyDelegate(config);
        config.setDiscardPacketsWithMismatchedSCID(false);
        config.setRespectClientProposedExtensions(true);
        config.getDefaultServerConnection().setTimeout(5000);

        State state = new State(config, new WorkflowTrace());
        WorkflowExecutor exec = WorkflowExecutorFactory.createWorkflowExecutor(
                config.getWorkflowExecutorType(), state);
        exec.initAllLayer();

        System.out.println("[repro] QUIC server listening on port " + port);

        try {
            new GenericReceiveAction("server").execute(state);

            byte[] odcid = state.getContext("server").getQuicContext()
                    .getFirstDestinationConnectionId();
            if (odcid != null)
                config.getDefaultQuicTransportParameters()
                        .setOriginalDestinationConnectionId(odcid);

            EncryptedExtensionsMessage ee = new EncryptedExtensionsMessage();
            ee.addExtension(new QuicTransportParametersExtensionMessage(config));
            new SendAction("server",
                    new ServerHelloMessage(), ee, new CertificateMessage(),
                    new CertificateVerifyMessage(), new FinishedMessage())
                    .execute(state);

            new GenericReceiveAction("server").execute(state);

            System.out.println("[repro] executedAsPlanned=true");
        } catch (Exception e) {
            System.out.println("[repro] EXCEPTION: " + e.getClass().getSimpleName());
            for (Throwable c = e.getCause(); c != null; c = c.getCause())
                System.out.println("[repro]   caused by: " + c.getClass().getSimpleName()
                        + ": " + c.getMessage());
            System.out.println("[repro] executedAsPlanned=false");
        }
    }
}
reproduce.sh
#!/usr/bin/env bash
# Reproduces TLS-Attacker QUIC server-mode bugs.
# Run from the TLS-Attacker repo root after `mvn install -DskipTests`.
#
# Prerequisites: Java 21, Maven, Python 3 + aioquic (pip install aioquic)
#
# Usage:
#   ./reproduce.sh                          # uses cwd as TLS-Attacker root
#   ./reproduce.sh /path/to/TLS-Attacker    # explicit path
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
TA_DIR="${1:-$(pwd)}"
PORT=4433
SERVER_PID=""
TMPDIR=""

cleanup() {
    [[ -n "$SERVER_PID" ]] && kill "$SERVER_PID" 2>/dev/null; wait "$SERVER_PID" 2>/dev/null || true
    [[ -n "$TMPDIR" ]] && rm -rf "$TMPDIR"
}
trap cleanup EXIT

die() { echo "FATAL: $*" >&2; exit 2; }

# --- prerequisites ---

command -v java >/dev/null || die "java not found"
command -v mvn  >/dev/null || die "mvn not found"

PYTHON="${PYTHON:-}"
if [[ -z "$PYTHON" ]]; then
    for p in python3 python; do
        if command -v "$p" >/dev/null && "$p" -c "import aioquic" 2>/dev/null; then
            PYTHON="$p"; break
        fi
    done
fi
[[ -n "$PYTHON" ]] && "$PYTHON" -c "import aioquic" 2>/dev/null || \
    die "No Python with aioquic found. Install: pip install aioquic (or set PYTHON=...)"

[[ -f "$TA_DIR/pom.xml" ]] || die "$TA_DIR does not look like a TLS-Attacker checkout"

echo "TLS-Attacker: $TA_DIR ($(cd "$TA_DIR" && git log --oneline -1 2>/dev/null || echo 'no git'))"
echo "Java: $(java -version 2>&1 | head -1)"
echo ""

# --- setup ---

TMPDIR=$(mktemp -d)

# certs
mkdir -p "$TMPDIR/certs"
openssl req -x509 -newkey rsa:2048 -keyout "$TMPDIR/certs/server.key" \
    -out "$TMPDIR/certs/server.crt" -days 1 -nodes -subj "/CN=localhost" 2>/dev/null

# classpath from TLS-Attacker's local install
CP_FILE="$TMPDIR/cp.txt"
(cd "$TA_DIR" && mvn -q -pl TLS-Core dependency:build-classpath -Dmdep.outputFile="$CP_FILE" 2>&1) || \
    die "mvn dependency:build-classpath failed — did you run 'mvn install -DskipTests'?"
# include TLS-Core's own classes
TA_CLASSES="$TA_DIR/TLS-Core/target/classes"
[[ -d "$TA_CLASSES" ]] || die "$TA_CLASSES not found — run 'mvn compile' first"
CP="$TA_CLASSES:$(cat "$CP_FILE")"

# compile repro
javac -d "$TMPDIR" -cp "$CP" "$SCRIPT_DIR/ServerModeQuicRepro.java" \
    -Xlint:-options -proc:none || die "Compilation failed"

# --- run ---

echo "Starting server on port $PORT..."
java -Djava.net.preferIPv4Stack=true -cp "$TMPDIR:$CP" \
    ServerModeQuicRepro --port "$PORT" \
    --cert "$TMPDIR/certs/server.crt" --key "$TMPDIR/certs/server.key" \
    >"$TMPDIR/server.log" 2>&1 &
SERVER_PID=$!
sleep 1

if ! kill -0 "$SERVER_PID" 2>/dev/null; then
    echo "Server exited early:"
    grep -E '^\[repro\]|Exception' "$TMPDIR/server.log"
    SERVER_PID=""
    die "Server failed to start"
fi

echo "Running aioquic client..."
"$PYTHON" -c "
import asyncio
from aioquic.asyncio.client import connect
from aioquic.quic.configuration import QuicConfiguration
async def main():
    cfg = QuicConfiguration(is_client=True, alpn_protocols=['hq-interop'])
    cfg.verify_mode = 0
    async with connect('127.0.0.1', $PORT, configuration=cfg):
        print('OK handshake_completed')
asyncio.run(asyncio.wait_for(main(), timeout=10))
" >"$TMPDIR/client.log" 2>&1 || true

# wait for server to exit
for _ in $(seq 15); do kill -0 "$SERVER_PID" 2>/dev/null || break; sleep 1; done
kill "$SERVER_PID" 2>/dev/null || true; wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""

# --- results ---

echo ""
echo "=== Server ==="
grep '^\[repro\]' "$TMPDIR/server.log" || echo "(no output)"
echo ""
echo "=== Client ==="
cat "$TMPDIR/client.log"
echo ""

if grep -q 'executedAsPlanned=true' "$TMPDIR/server.log" && \
   grep -q 'OK handshake_completed' "$TMPDIR/client.log"; then
    echo "RESULT: PASS"
    exit 0
else
    echo "RESULT: FAIL"
    if grep -q 'AEADBadTagException' "$TMPDIR/server.log"; then
        echo "  -> Bug 1: Initial secrets derived from wrong DCID (AEADBadTagException)"
    fi
    exit 1
fi
Output before fix
TLS-Attacker: ../TLS-Attacker (1d2a3dffa release: v7.7.0)
Java: openjdk version "21.0.11" 2026-04-21

Starting server on port 4433...
Running aioquic client...

=== Server ===
[repro] QUIC server listening on port 4433
[repro] EXCEPTION: WorkflowExecutionException
[repro]   caused by: CryptoException: Could not decrypt packet
[repro]   caused by: CryptoException: Could not decrypt INITIAL_PACKET
[repro]   caused by: AEADBadTagException: Tag mismatch
[repro] executedAsPlanned=false

=== Client ===
ConnectionError exception in shielded future
future: <Future finished exception=ConnectionError()>
ConnectionError
Traceback (most recent call last):
  File "/usr/lib/python3.14/asyncio/tasks.py", line 488, in wait_for
    return await fut
           ^^^^^^^^^
  File "<string>", line 8, in main
    async with connect('127.0.0.1', 4433, configuration=cfg):
               ~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.14/contextlib.py", line 214, in __aenter__
    return await anext(self.gen)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/home/sev/projects/quicSML/.venv-sul/lib/python3.14/site-packages/aioquic/asyncio/client.py", line 93, in connect
    await protocol.wait_connected()
  File "/home/sev/projects/quicSML/.venv-sul/lib/python3.14/site-packages/aioquic/asyncio/protocol.py", line 146, in wait_connected
    await asyncio.shield(self._connected_waiter)
asyncio.exceptions.CancelledError

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "<string>", line 10, in <module>
    asyncio.run(asyncio.wait_for(main(), timeout=10))
    ~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/lib/python3.14/asyncio/runners.py", line 204, in run
    return runner.run(main)
           ~~~~~~~~~~^^^^^^
  File "/usr/lib/python3.14/asyncio/runners.py", line 127, in run
    return self._loop.run_until_complete(task)
           ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^
  File "/usr/lib/python3.14/asyncio/base_events.py", line 719, in run_until_complete
    return future.result()
           ~~~~~~~~~~~~~^^
  File "/usr/lib/python3.14/asyncio/tasks.py", line 487, in wait_for
    async with timeouts.timeout(timeout):
               ~~~~~~~~~~~~~~~~^^^^^^^^^
  File "/usr/lib/python3.14/asyncio/timeouts.py", line 114, in __aexit__
    raise TimeoutError from exc_val
TimeoutError

RESULT: FAIL
  -> Bug 1: Initial secrets derived from wrong DCID (AEADBadTagException)
Output after fix
TLS-Attacker: ../TLS-Attacker (34fc23088 add tests)
Java: openjdk version "21.0.11" 2026-04-21

Starting server on port 4433...
Running aioquic client...

=== Server ===
[repro] QUIC server listening on port 4433
[repro] executedAsPlanned=true

=== Client ===
OK handshake_completed

RESULT: PASS

Note on ODCID

TLS-Attacker does not set the original destination connection ID by itself. I was not sure whether this is intended behavior so a fix for that is not included here.

Copy link
Copy Markdown
Contributor

@mmaehren mmaehren left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR!
We have an internal development branch for QUIC in which we also addressed these issues. However, we are not sure when it will be released to public so implementing these fixes now is probably the best way.

Regarding the initial connection ID, in our branch we opted for creating a random value as part of the QUIC init code in QuicContext so peers never associate our new connections with older sessions.

Feel free to tag me and @NErinola as reviewers for any upcoming PRs concerning QUIC.

@mmaehren mmaehren merged commit 19d0f88 into tls-attacker:main May 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants