RFC 8724-aligned SCHC header compression layered over DTLS 1.2, integrated via WolfSSL custom I/O callbacks. Built as part of a CRIME-style side-channel threat analysis for DTLS-over-SCHC.
- Overview
- Architecture
- SCHC Rule Design
- Prerequisites
- Building
- Running
- Running Tests
- How It Works
- Side-Channel Observations
- Known Limitations
- Project Structure
This project implements a minimal SCHC (Static Context Header Compression) compressor β schc-mini β that compresses DTLS 1.2 record headers before transmission and decompresses them on receipt. The compression is injected at the WolfSSL custom I/O layer, meaning Wireshark and any network observer sees only the compressed SCHC packet as the UDP payload, never the raw DTLS record header.
The project was built to investigate whether SCHC header compression over DTLS introduces a CRIME-style side-channel: an observer that can see compressed packet sizes and rule IDs can infer protocol state (handshake vs. application data), content type, epoch, and sequence number β without breaking encryption.
Why DTLS 1.2 and not 1.3?
DTLS 1.3 already encrypts the content type and uses a compact unified header after the key exchange phase, partially mitigating the side-channel by design. DTLS 1.2 has fully plaintext headers β making it the better target for demonstrating what SCHC leaks.
βββββββββββββββββββββββββββββββββββββββββ
β Application β
β wolfSSL_write / wolfSSL_read β
βββββββββββββββββ¬ββββββββββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββββββββββ
β WolfSSL DTLS 1.2 β
β Constructs full 13-byte DTLS record β
βββββββββββββββββ¬ββββββββββββββββββββββββ
β Custom I/O callback intercepts here
βββββββββββββββββΌββββββββββββββββββββββββ
β schc-mini β
β send: schc_compress() β
β recv: schc_decompress() β
β β
β Wire format: β
β [ RuleID (1B) | Residue | Payload ] β
βββββββββββββββββ¬ββββββββββββββββββββββββ
β
βββββββββββββββββΌββββββββββββββββββββββββ
β UDP Socket β
β Observer sees compressed packet only β
βββββββββββββββββββββββββββββββββββββββββ
The WolfSSL custom I/O callbacks (wolfSSL_CTX_SetIOSend / wolfSSL_CTX_SetIORecv) give access to the fully constructed DTLS record just before it hits the UDP socket. This is the hook point where schc_compress and schc_decompress are called.
The rule context (schc-mini/schc_mini.c) defines 4 rules targeting DTLS 1.2's fixed 13-byte header:
Byte offsets:
[0] Content Type (1 byte)
[1-2] Version (2 bytes) 0xFEFD = DTLS 1.2
[3-4] Epoch (2 bytes)
[5-10] Sequence Num (6 bytes)
[11-12] Length (2 bytes)
| Rule | Target | Fields Elided | Residue Size | Compression Delta |
|---|---|---|---|---|
| Rule 1 | Handshake, Epoch=0, Seq=0 | Type, Version, Epoch, Seq | 2 bytes (Length only) | β10 bytes |
| Rule 2 | Handshake, Epoch=0, Seq=1 | Type, Version, Epoch, Seq | 2 bytes (Length only) | β10 bytes |
| Rule 3 | Handshake, Epoch=1, Seq=0 | Type, Version, Epoch, Seq | 2 bytes (Length only) | β10 bytes |
| Rule 4 | Catch-all (type β 22) | Version only | 11 bytes (Type+Epoch+Seq+Length) | β2 bytes |
Matching Operators (MO):
MO_EQUALβ field must equal the Target Value (TV); matched fields are elidedMO_IGNOREβ field always passes; sent as residue (VALUE_SENT)
Compression/Decompression Actions (CDA):
NOT_SENTβ field elided; reconstructed from TV on decompressionVALUE_SENTβ field transmitted verbatim in the compression residue
Rules are evaluated in order (Rule 1 β Rule 4). The first match wins.
- CMake β₯ 3.20
- WolfSSL 5.8.4 (adjust path in
CMakeLists.txtif different) - C11 compatible compiler (clang or gcc)
- OpenSSL or self-signed certs for the server (PEM format)
brew install wolfsslFor other platforms, build from source:
git clone https://github.com/wolfSSL/wolfssl.git
cd wolfssl
./autogen.sh
./configure --enable-dtls
make
sudo make installThe server requires certs/server-cert.pem and certs/server-key.pem relative to the build directory (i.e., ../certs/ from build/):
mkdir -p certs
openssl req -x509 -newkey rsa:2048 -keyout certs/server-key.pem \
-out certs/server-cert.pem -days 365 -nodes \
-subj "/CN=localhost"git clone https://github.com/hashedalgorithm/dtls-schc.git
cd dtls-schc
mkdir build && cd build
cmake ..
makeThis produces three executables in build/:
| Binary | Description |
|---|---|
server |
DTLS 1.2 server with SCHC compression |
client |
DTLS 1.2 client with SCHC compression |
test |
Controlled header compression experiment |
If WolfSSL is not installed at /opt/homebrew/Cellar/wolfssl/5.8.4, edit CMakeLists.txt:
set(WOLFSSL_PREFIX /your/wolfssl/install/path)Open two terminals from the build/ directory.
Terminal 1 β Server:
./serverExpected output:
UDP Server listening on port 11111
Waiting for client...
Terminal 2 β Client:
./clientExpected output:
dtls_mini_compress 235 bytes
dtls_mini_decompress 60 bytes
...
Handshake complete!. Sending message...
dtls_mini_compress 59 bytes
Sent: "Hello from DTLS client!"
Closing connection and exiting.
Server output after a successful session:
Client connected from 127.0.0.1
dtls_mini_decompress 245 bytes
dtls_mini_compress 50 bytes
...
Handshake complete. Reading message...
dtls_mini_decompress 60 bytes
Received: "Hello from DTLS client!"
Session closed, returning to idle
Uncomment the print_dtls_record calls in client.c and server.c to see full hex dumps of each DTLS record before and after compression:
// In send_dtls_record():
print_dtls_record(SEND_DTLS_RECORD, buffer, size);
print_dtls_record(SEND_DTLS_RECORD, (char *)result_buffer, out_len);The compressed SCHC packets are visible as raw UDP payloads (port 11111) on the loopback interface. WolfSSL's DTLS dissector will not recognise them since the header is compressed.
# Filter in Wireshark:
udp.port == 11111The test binary runs a controlled compression experiment across 12 sections covering the full DTLS 1.2 message lifecycle using synthetic headers:
./testSample output:
=== DTLS RECORD HEADER SCHC COMPRESSION EXPERIMENT ===
--- [1] NORMAL HANDSHAKE FLOW (DTLS 1.2 / 0xFEFD) ---
1.01 ClientHello (handshake, epoch=0, seq=0) | rule=Rule-1 | in= 33 | out= 23 | diff=-10
1.02 HelloVerifyRequest (handshake, epoch=0, seq=0) | rule=Rule-1 | in= 33 | out= 23 | diff=-10
1.03 ClientHello+Cookie (handshake, epoch=0, seq=1) | rule=Rule-2 | in= 33 | out= 23 | diff=-10
...
1.13 ApplicationData (appdata, epoch=1, seq=1) | rule=Rule-4 | in= 33 | out= 32 | diff=-1
--- [2] RETRANSMISSION SCENARIOS ---
...
--- [7] VERSION FIELD VARIANTS ---
7.02 DTLS 1.0 (0xFEFF) - legacy | rule=Rule-4 | in= 33 | out= 32 | diff=-1
...
=== EXPERIMENT COMPLETE ===
Test sections:
| Section | Coverage |
|---|---|
| 1 | Normal DTLS 1.2 handshake flow |
| 2 | Retransmission scenarios |
| 3 | Alert messages (all types, both epochs) |
| 4 | ChangeCipherSpec variants |
| 5 | Epoch progression |
| 6 | Sequence number edge cases (0, max, near-rollover) |
| 7 | Version field variants (DTLS 1.0, TLS 1.2, unknown) |
| 8 | Content type variants (20β24, 0x00, 0xFF) |
| 9 | Strict rule boundary conditions |
| 10 | Renegotiation flow |
| 11 | Session resumption |
| 12 | Error and malformed scenarios |
Note: The test binary uses a fixed 20-byte dummy payload (
0xAB, 0x00...), soinis always 33 bytes (13-byte header + 20-byte payload). The compression is applied to the header only; payload passes through untouched.
- Parse the incoming DTLS record buffer (first 13 bytes = header, remainder = payload).
- Walk the rule context in order; for each rule, check all
MO_EQUALfields against the header β if all match, the rule is selected. - Write
RuleID(1 byte) to the output buffer. - For each
VALUE_SENTfield in the matched rule, copy the raw bytes from the header into the residue. - Append the payload verbatim.
- Return the total compressed length.
- Read the first byte as
RuleID; look up the rule by ID. - Calculate expected residue size (sum of
flfor allVALUE_SENTfields). - Reconstruct the 13-byte header:
NOT_SENTfields β write the Target Value from the rule.VALUE_SENTfields β read bytes sequentially from the residue stream.
- Copy reconstructed header + remaining payload to the output buffer.
- Return the decompressed total length.
[ RuleID : 1 byte ] [ Residue : variable ] [ Payload : variable ]
Rule 1/2/3 residue: [ Length (2 bytes) ] β 3 bytes total overhead
Rule 4 residue: [ Type(1) Epoch(2) Seq(6) Len(2) ] β 12 bytes total overhead
From live Wireshark captures and the controlled test:
| Packet | Role | Rule | Compressed Size | Delta |
|---|---|---|---|---|
| 907 | ClientHello | Rule-1 | 235 bytes | β10 |
| 908 | HelloVerifyRequest | Rule-1 | 50 bytes | β10 |
| 909 | ClientHello+Cookie | Rule-2 | 267 bytes | β10 |
| 910 | ServerHello+Cert | Rule-2 | 1268 bytes | β10 |
| 911 | ClientKeyExchange+CCS | Rule-3 | 165 bytes | β10 |
| 912 | CCS+Finished | Rule-3 | 74 bytes | β10 |
| 913 | Finished | Rule-4 | 59 bytes | β2 |
| 914 | Application Data | Rule-4 | 38 bytes | β2 |
What an observer can infer:
- The
RuleIDis the first byte of every UDP payload β transmitted in plaintext. - A jump from Rule 1/2/3 (β10 bytes) to Rule 4 (β2 bytes) precisely marks the handshakeβapplication-data transition.
- By correlating RuleID frequency and packet timing across multiple sessions, an observer can predict
content_type,epoch, andsequence_numberwithout breaking encryption. - This is analogous to CRIME but applies to protocol-state metadata rather than secret content β the leak is deterministic and structural, not probabilistic.
CRIME vs. SCHC Side-Channel:
| Aspect | CRIME | SCHC Side-Channel |
|---|---|---|
| Type | Active attack | Passive observation |
| Target | TLS/HTTPS secrets | Protocol state metadata |
| Compression | Dynamic (DEFLATE) | Static rule-based |
| Attacker requirement | MitM + victim-controlled input | Passive observer only |
| Mitigation | Disable compression | Padding / rule consolidation / header encryption |
Rule granularity leaks state. Each rule maps to a distinct handshake phase. The more rules, the more state an observer can fingerprint. A single catch-all rule would eliminate this, at the cost of compression efficiency.
RuleID is plaintext. The first byte of every compressed packet unambiguously identifies the protocol phase. Encrypting the RuleID with the handshake key would mitigate this but is only possible after key derivation.
DTLS 1.3 incompatibility. After the key exchange, DTLS 1.3 switches from the DTLS 1.2-compatible 13-byte header to a compact variable-length unified header. The fixed-offset rule logic in schc_mini.c breaks at this point β schc_compress returns -1 and the connection drops. Supporting DTLS 1.3 requires:
- Detection of the unified header format (first byte MSB flags).
- Separate rule sets and offset logic for the compact header.
No fragment reassembly. WolfSSL may fragment large handshake messages across multiple DTLS records. Each record is compressed independently; the compressor has no reassembly context.
Fixed payload size in tests. The test binary uses a 20-byte static dummy payload. Real sessions have variable-length payloads that dominate total packet size, making the header delta relatively smaller.
.
βββ CMakeLists.txt # Build configuration
βββ client.c # DTLS client with SCHC I/O callbacks
βββ server.c # DTLS server with SCHC I/O callbacks
βββ schc-mini/
β βββ schc_mini.h # Public API, types, rule IDs, field descriptors
β βββ schc_mini.c # Rule context, compress/decompress implementation
βββ test/
β βββ test.c # Controlled header compression experiment (12 sections)
βββ certs/ # Server certificate and key (not committed β generate locally)
βββ server-cert.pem
βββ server-key.pem
- Wireshark capture: Google Drive
- RFC 9147 β DTLS 1.3: https://datatracker.ietf.org/doc/html/rfc9147
- RFC 6347 β DTLS 1.2: https://datatracker.ietf.org/doc/html/rfc6347
- RFC 8724 β SCHC: https://datatracker.ietf.org/doc/rfc8724/
- RFC 8446 β TLS 1.3: https://datatracker.ietf.org/doc/html/rfc8446
- CVE-2012-4929 β CRIME: https://nvd.nist.gov/vuln/detail/cve-2012-4929
- WolfSSL: https://github.com/wolfSSL/wolfssl
- libschc: https://github.com/imec-idlab/libschc