DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
PENTEST

Pentesting / Adversarial Suite

A separately-runnable harness that attacks the server the way a hostile client would, and asserts the guarantees that keep a deterministic device safe. It is not part of the per-commit unit-test run (test/run_tests.sh): it is heavier, randomized, and meant to be run on demand or on a schedule.

‍## ⚖️ Authorized use only

The live driver (pentesting/detws_pentest.py) and the on-device techniques below send real attack traffic. Use them only against a device you own or have explicit, written permission to test. Unauthorized access to or interference with computer systems is a crime in most jurisdictions (US CFAA, UK Computer Misuse Act, and equivalents). You, the operator, are solely responsible for staying within the law and the scope of your authorization. This tooling is provided as is, with no warranty and no liability. We build defensive tooling - use it for good. The live driver refuses to run until you pass --authorized.

There are three layers:

  1. Host fuzz (env:native_pentest) - throws malformed, oversized, partial, binary, and deterministically-random input at the untrusted-input parsers and checks the safety invariants. Fast, reproducible, CI-friendly.
  2. Live adversarial driver (pentesting/detws_pentest.py) - a feature-aware Python tool that drives a flashed board over the network, auto-detects the build's features, runs the applicable attacks, and checks liveness + heap-drift after each one. See pentesting/README.md.
  3. Manual on-device stress - ad-hoc curl/nc techniques for one-off probing.

The invariants under test

Every adversarial input must preserve all three. A violation is a finding.

Invariant What it means How it is checked
Fixed footprint No fixed buffer index ever exceeds its compile-time bound (no overflow). After every input: path_idx <= MAX_PATH_LEN, header_count <= MAX_HEADERS, body_len <= BODY_BUF_SIZE, response length <= cap, etc.
Fail-closed Bad input lands in a defined error state, never an undefined one. Parser state is always within the ParseState enum; bad requests reach PARSE_ERROR (400) / PARSE_ENTITY_TOO_LARGE (413) / PARSE_URI_TOO_LONG (414).
Liveness The parser always terminates - no hang, no over-read. The harness feeds bounded streams and returns; a hang/crash/sanitizer trip fails the run.

These follow from the library's core promise: no heap after begin(), all buffers statically sized, so the only way adversarial input can hurt the device is an overflow or an undefined state - exactly what this suite hunts.


1. Host fuzz (<tt>native_pentest</tt>)

Run it

pio test -e native_pentest

It is intentionally excluded from test/run_tests.sh, so a normal pio test / CI test run does not pay its cost. Run it before a release, nightly, or after touching any parser.

What it covers

Targets (every codec that faces untrusted bytes from the wire or the user):

  • HTTP request parser (http_parser_feed) - the primary attack surface.
  • multipart/form-data (multipart_parse) - in-place body parsing.
  • CoAP datagram (coap_server_process) - untrusted UDP, option parsing.
  • SNMP agent + ASN.1/BER (snmp_agent_process, ber_read_*) - untrusted UDP.
  • Modbus - slave ADU codec (modbus_process_adu) + master response parser (modbus_parse_response).
  • CBOR reader (cbor_read_*) and JSON reader (json_get_*).
  • base64 (base64_decode) and base32 (detws_base32_decode) decoders.
  • SQLite on-disk reader (sqlite_parse_*, sqlite_table_cursor_*, sqlite_read_payload) - a malicious .db file is wholly untrusted: random pages, a garbage b-tree the multi-page cursor must walk without hanging (bounded descent over cyclic interior pointers), a hostile overflow-page chain, and structure-aware mutation of a valid image built by the writer.
  • Redis RESP reply decoder (resp_parse) - a hostile server / MITM reply, with the classic lying $/* length-prefix that must never turn into an over-read.
  • OPC UA Binary parsers (opcua_parse_header / _hello / _open / _msg / _read / _write / _browse) - the length-prefixed strings / byte-strings and array counts of an untrusted UA-TCP peer: random bodies behind a valid UACP header, an OPN whose SecurityPolicyUri length lies (huge / negative / over-long), and size-field mismatches per message type. The captured NodesToRead/Write/Browse counts must stay clamped to their DETWS_OPCUA_*_MAX arrays. (This target's first run found three latent signed-overflow UBs in the shared number parsers - see docs/BUGS.md.)
  • DNS server (dns_server_build_response) - an untrusted query: the QNAME label parse must not over-read (label length past the buffer, missing root label, compression pointers rejected in a question) and the response builder must never write past a random out_cap (an exact-size buffer makes ASan enforce it).
  • DNP3 data-link frame (dnp3_parse_frame) - an untrusted SCADA frame: a lying LEN / block layout behind the 0x0564 start word must not over-read or overflow the user-data buffer.
  • STOMP frame (stomp_parse_frame) - every returned slice (command, header keys/values, body) must point inside the source buffer, and header_count stay clamped.
  • SMB2 client response parsers (smb2_parse_negotiate_response / _session_setup_response / _tree_connect_response / _create_response / _close_response / _read_response / _write_response, smb2_parse_header) - a malicious SMB server: random bodies behind a valid sync header exercise the StructureSize checks and the security-buffer / read-data offset+length bounds, and every returned pointer (the SPNEGO/NTLM security buffer, the read data) must stay inside the message.
  • SPNEGO DER walker + NTLMSSP CHALLENGE (spnego_parse_response, ntlmssp_parse_challenge) - a hostile authentication blob: the definite-length DER walk and the AV_PAIR / target-info fields must never over-read on a lying tag/length, and the returned responseToken / target-info slice must stay in bounds.
  • DNC block decoder (dnc_decode_feed) - a hostile drip-feed stream (ISO + EIA tape codes): the block reassembler must never overrun its line buffer (len <= DETWS_DNC_LINE_MAX) on any byte sequence.
  • FTP client reply parsers (ftp_parse_reply / ftp_parse_pasv / ftp_parse_epsv) - a malicious / MITM FTP server: a lying (h1,h2,h3,h4,p1,p2) PASV tuple or a bad multiline reply must not over-read, and consumed never exceeds the buffer.
  • Industrial client response parsers (fins_parse_response, melsec_parse_response, cip_parse_response, eip_parse_send_rr_data, df1_parse_frame) - a malicious PLC / controller reply: a lying length / item count must not over-read, every returned payload slice stays inside the frame, and DF1's DLE de-stuffing never writes past the caller's out_cap.
  • BACnet/IP + ISO-transport frames (bvlc_parse, npdu_parse, tpkt_parse, cotp_parse) - untrusted building-automation / SCADA framing: the BVLC length, the NPDU control-flag-driven layout, and the TPKT/COTP length fields must not over-read, and every returned NPDU / APDU / user-data slice stays inside the frame.
  • IEEE C37.118 + FINS command (c37118_parse_frame, c37118_parse_command, fins_parse_command) - an untrusted grid synchrophasor frame / PLC request: a lying FRAMESIZE or command-parameter length must not over-read; the returned data / parameter slice stays inside the frame.
  • JWT HS256 verifier (jwt_verify_hs256, jwt_time_valid) - a forged / malformed bearer token: the header.payload.sig split, the base64url decode, and the exp/nbf claim scan must stay bounded and reject cleanly (no over-read on a lying segment).
  • Fieldbus / message-bus frames (detws_dnet_data_parse, detws_cclink_parse, amqp_parse_frame, detws_mms_parse) - untrusted DirectNET / CC-Link / AMQP / MMS input: a lying length must not over-read, consumed never exceeds the buffer, and every returned payload / service-body slice stays inside the frame.
  • DDS / RTPS message walker (detws_rtps_parse) - an untrusted UDP multicast datagram: behind the RTPS magic, a lying submessageLength must not walk the submessage cursor out of bounds, and every submessage body surfaced to the callback stays inside the datagram.

Attack classes (see test/test_pentest/test_pentest.cpp):

  • Oversized - paths/queries/headers far past their bounds, giant Content-Length → must reach 414/413, never overflow.
  • Header flood - hundreds of headers → header_count clamps to MAX_HEADERS.
  • Malformed - empty input, missing method/version, bare LF, non-numeric Content-Length, high bytes in the path, the HTTP/2 PRI * HTTP/2.0 preface.
  • Request smuggling - duplicate Content-Length, CL.TE / TE.CL, obfuscated Transfer-Encoding, negative/hex/overflowing lengths, bad chunk sizes.
  • Header / target injection - CR/LF/NUL in values, percent-encoded traversal and NUL, absolute-form and authority-form request targets.
  • Protocol confusion - a TLS ClientHello fed to the cleartext parser.
  • Slowloris - a never-terminated request fed one byte at a time.
  • Random fuzz - tens of thousands of streams of random length and content, from a fixed PRNG seed (0x1234567), bounded-checked after each.
  • Structured fuzz - a valid protocol skeleton (HTTP request, CoAP header, SNMP SEQUENCE) with random bytes spliced into the deeper fields, so the option/length/tag walkers are driven past the outer frame.

Reproducing a finding

The fuzz PRNG is a deterministic xorshift32 seeded in setUp(), so a failure is fully reproducible: re-run native_pentest and it replays the identical streams. To explore a different corpus, change the seed in test_pentest.cpp.

Deeper: run under sanitizers

The host build can run under AddressSanitizer / UndefinedBehaviorSanitizer to turn a silent over-read or UB into a hard failure. Add to the env's build_flags / link flags:

build_flags = ... -fsanitize=address,undefined -fno-sanitize-recover=all

(Available on Linux/macOS clang or gcc; the same toolchain note as native_tsan applies - not available on the MSVC-target Windows host, so run it in CI/WSL.)

Adding a new target

The pattern is three lines per parser:

// feed random/malformed bytes, then assert the safety invariant
size_t n = xs32() % sizeof(buf);
for (size_t i = 0; i < n; i++) buf[i] = (uint8_t)(xs32() & 0xFF);
int rc = my_parser(buf, n, out, sizeof(out));
TEST_ASSERT_TRUE(rc <= (int)sizeof(out)); // never writes past the buffer

The HTTP, multipart, CoAP, SNMP/BER, Modbus (slave + master), CBOR, MessagePack, JSON, base64, base32, the SQLite on-disk reader, the Redis RESP decoder, the OPC UA Binary parsers, the base-10 number parsers (det_strtol/_strtoul/ _strtof), the GraphQL query parser, the DNS server query parse, the DNP3 data-link frame, and the STOMP frame parser are covered. Good candidates still open: the WebSocket frame reassembler (ws_parse) once a host harness for its rx ring exists (its per-byte core ws_feed_byte dispatches into the transport/session layer, so it needs those mocks wired in), and the WebDAV binary decoder.


2. Live adversarial driver (<tt>pentesting/detws_pentest.py</tt>)

The host fuzz proves the parsers are safe in isolation; the live driver proves the running device is - its connection pool caps, its throttles and lockout engage, it fails closed on the wire, and its free heap does not drift (the "no heap after `begin()`" promise). It is feature-aware: point it at a device and it reads /diag to learn which features are built in, then runs only the applicable attacks.

# auto-detect features from /diag and run the applicable suite
python3 pentesting/detws_pentest.py --host 192.168.1.85 --diag --authorized
# or declare your flags (for features /diag does not enumerate), and go hard
python3 pentesting/detws_pentest.py --host 192.168.1.85 \
--flags DETWS_ENABLE_AUTH,DETWS_ENABLE_ACCEPT_THROTTLE,DETWS_ENABLE_COAP \
--intensity high --json report.json --authorized
# list every attack and its required feature, no target needed
python3 pentesting/detws_pentest.py --list

It checks two oracles around every attack - liveness (a legitimate GET / still succeeds) and heap-drift (sampled from /health, /stats, /metrics, or /diag) - plus per-attack fail-closed assertions (414/413/401/429/403/416/...). The exit code is non-zero when any finding exists, so it drops into a CI gate. It is Python-3-stdlib-only (no pip installs). Full documentation, the attack catalog, and how to extend it: pentesting/README.md.


3. Manual on-device stress (live server)

Flash a build with the listeners and defenses you want to exercise, then drive it from a host on the same network. The goal: confirm the device stays up and fails closed under abuse, within its fixed footprint.

Enable the relevant defenses in ServerConfig.h / build flags:

  • DETWS_ENABLE_ACCEPT_THROTTLE (+ DETWS_ACCEPT_THROTTLE_MAX/_WINDOW_MS)
  • DETWS_ENABLE_PER_IP_THROTTLE
  • DETWS_ENABLE_AUTH_LOCKOUT
  • DETWS_ENABLE_IP_ALLOWLIST
  • DETWS_ENABLE_CSRF

Example attacks

IP=192.168.1.85
# Slowloris: many half-open connections that dribble headers forever.
for i in $(seq 1 50); do
(printf 'GET / HTTP/1.1\r\nHost: x\r\n'; sleep 60) | nc $IP 80 &
done
# Expect: connections capped at MAX_CONNS; the idle-timeout sweep reaps stalled
# ones; the device keeps serving fresh requests; heap never moves.
# Connection flood: rapid-fire accepts to trip the accept throttle.
for i in $(seq 1 500); do curl -s -o /dev/null --max-time 2 http://$IP/ & done; wait
# Expect: excess accepts dropped (RST) by the throttle; no crash.
# Oversized request line.
python3 -c "print('GET /' + 'a'*100000 + ' HTTP/1.1')" | nc $IP 80
# Expect: 414 URI Too Long, slot freed.
# Auth brute force (with DETWS_ENABLE_AUTH_LOCKOUT).
for i in $(seq 1 50); do curl -s -o /dev/null -u admin:wrong$i http://$IP/secure; done
# Expect: 429 + Retry-After after the threshold; later attempts locked out.

What to watch

  • Heap is flat. Poll a health endpoint (DETWS_ENABLE_GUARDRAILS/health, or DETWS_ENABLE_METRICS) before/during/after - free_heap must not drift.
  • The device keeps serving. A legitimate request still gets a normal response throughout the attack.
  • Defenses engage. 429/RST/414/413 where expected; the allowlist rejects off-list sources at accept time.

‍Modbus, SNMP, and CoAP have no authentication - only expose them on a trusted network, and pentest accordingly (malformed PDUs, oversized datagrams).

</blockquote>

Interpreting results

  • Host fuzz green = the parsers held every invariant across the corpus.
  • A crash / hang / sanitizer trip = a finding; capture the seed and the failing vector, fix the parser to fail closed, add the vector as a regression case.
  • On-device: any heap drift, a wedged server, or a defense that did not engage is a finding.

The suite is a floor, not a ceiling - widen the corpus, add targets, and run it under sanitizers in CI for the strongest assurance.