Skip to content

updates from codex security scan and testing#40

Merged
tridge merged 39 commits into
ArduPilot:mainfrom
tridge:pr-codex-security-fixes
Jul 19, 2026
Merged

updates from codex security scan and testing#40
tridge merged 39 commits into
ArduPilot:mainfrom
tridge:pr-codex-security-fixes

Conversation

@tridge

@tridge tridge commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Security hardening, robustness fixes and binlog reliability work, driven by a codex security scan, a full robustness review, and two field incidents (a web-UI "kill" that never came back, and an empty .bin after a preflight reboot).

Security fixes (codex scan)

  • websocket: bound the extended payload length before the completeness check (crafted 0x7f frame could walk past pending[])
  • mavlink: reject all-zero SETUP_SIGNING key rotation; safe-fail when the stored key is all-zero instead of wiring NULL signing
  • supportproxy: pre-auth deadlines on engineer conn2 slots and (bidi) user conn1, so unauthenticated clients can't camp on slots
  • supportproxy: bidi-sign conn1 now validates the signature before latching the UDP listener to a tuple, so unsigned/wrong-key senders can't deny the legitimate user; the validator is initialised once per child (no per-datagram key load + fork)

Robustness fixes

The headline bug: the parent only reaped children and reopened listeners when epoll_wait() returned 0 — a full second with no events. Children inherit the listening sockets, so their traffic kept waking the parent through registrations that survived close() after fork; with any active session the timeout never fired. A connection killed via the web UI stayed dead (UDP arriving, nothing bound) until every session went idle, and keys.tdb reloads stopped too.

  • run parent housekeeping on every epoll iteration; reload immediately after reaping so reopened listeners re-enter the epoll set
  • EPOLL_CTL_DEL a pair's sockets before the fork handoff closes them
  • handle fork() failure (previously stored -1 in p->pid, permanently killing the pair and arming a later kill(-1, SIGTERM))
  • never leave MAVLink::ws uninitialized/dangling (use-after-free child crash when a WebSocket engineer's slot was reused)
  • don't orphan live conn2 slots when a lower slot closes (watermark shrank on any close, silently freezing higher engineers)
  • ignore SIGPIPE (a peer-closed TCP/WS/SSL write could kill the whole session child)
  • never lose a webadmin drop request: the 5s connections.tdb snapshot preserves DROP_REQUESTED and the child rescans on the snapshot cadence
  • keydb: retry EBUSY opens; a transient keys.tdb failure during reload no longer exit(1)s the proxy
  • util: close the socket fd on bind()/listen() failure (slow leak toward EMFILE)
  • websocket: fail frames that can never fit in the buffer instead of wedging the stream; don't decode fragmented HTTP upgrade headers as frames; stop closing fds owned by the caller (recycled-fd double close); free SSL objects on teardown
  • make the log-cleanup child die with the parent (it loops forever and leaked an orphan process on every non-systemd shutdown)
  • Makefile: fix missing header dependencies (a BinlogWriter layout change could leave supportproxy.o with a stale object layout)

Binlog quota + reboot handling

Field incident: with LOG_DISARMED logging, old sessions consumed the whole 1 GiB per-port2 quota; after a preflight reboot the new session file was created but every block was dropped until the hourly cleanup pass, leaving an empty .bin.

  • per-port2 quota is now configurable via SUPPORTPROXY_PORT2_QUOTA_BYTES (plain bytes; strict parsing — malformed values fall back to the 1 GiB default)
  • quota counts allocated size (st_blocks), not sparse apparent size, on both the cleanup and write-gate sides; the active file is charged consistently, with growth projected as one filesystem block
  • a write-time quota breach triggers an immediate per-port2 cleanup pass (rate-limited), accounting for the headroom the write needs — including at the total==quota boundary
  • the cleanup pass frees down to 80% of the quota (freeing to the brim re-breached within minutes) and never deletes a live session's files (fresh mtime), which would strand the writer on an unlinked inode
  • rotate when the vehicle restarts its log mid-stream (block 0 with highest_seen far along), instead of racing SYSTEM_TIME reboot detection and corrupting the old log's head
  • STOP+START nudge when a vehicle streams mid-log at a closed file (proxy restarted mid-flight, or post-rotation): targeted at the sending vehicle, fired only after 3 gated blocks, shrinking the log hole from ~10–15s (vehicle client-timeout) to a second or two

Tests

Every behavioural fix that could be exercised end-to-end has a red/green regression test (killed-pair recovery under load, conn2 slot orphaning, lost drop requests, quota boundary/immediate-cleanup/active-file/sparse-accounting cases, in-stream rotation, STOP nudge and its threshold, malformed quota env). The test runner gains a Robustness phase so these run in CI. Also fixed test-harness races (lingering per-pair children holding ports between tests) and a flaky bidi ordering where the engineer connected 10s before signing and collided with the intended 5s pre-auth deadline.

tridge added 30 commits June 7, 2026 11:14
WebSocket::decode() read the 0x7f-encoded extended payload length as a
uint64 and then checked completeness with 'n < pos + 4 + payload_len'.
For payload_len near UINT64_MAX the right-hand side wraps below n, the
check passes, and the unmask loop / memmove walks over the fixed
1024-byte pending[] buffer. ASan confirmed the OOB.

Bound payload_len against the pending buffer space before any addition.
Add a regression test that fires three flavours of impossibly-large
0x7f frames at the engineer-side WS port and asserts the parent
supportproxy stays alive.
handle_setup_signing() previously copied the packet's secret_key and
initial_timestamp straight into keys.tdb. The combination of all-zero
key + zero timestamp is exactly what load_signing_key() detected as
'disable signing entirely', so a single signed engineer packet could
persistently turn off signature verification on a port pair: the next
load nulled status->signing, and the generated mavlink_signature_check
returns true when signing == NULL, treating subsequent IFLAG_SIGNED
frames as valid without checking any secret.

Two layered defences:

  * handle_setup_signing() now rejects the all-zero key + zero
    timestamp combination at the source, before any DB write.
  * load_signing_key() safe-fails on the same combination: leave
    key_loaded=false so receive_message() rejects every frame on the
    channel, rather than wiring NULL signing into status->signing.

The dead all_zero branch later in load_signing_key() is removed; the
early return makes the remaining branch unconditional.

Regression: a signed SETUP_SIGNING with zeros is rejected, the channel
keeps validating subsequent signed traffic with the original key, and
no 'Set new signing key' log appears.
The engineer-UDP idle-timeout close loop at supportproxy.cpp:446-454
called c2.close() without decrementing conn2_count or shrinking
max_conn2_count. Repeated unauthenticated UDP churn from varying
source tuples therefore monotonically grew max_conn2_count, and once
it crossed MAX_COMM2_LINKS the per-port-pair child hit the BUG guard
and exit(1)'d — DoSing the affected port pair until the parent forked
a fresh child.

Decrement the counters in the close loop, matching the pattern other
close sites in this file already use. As defence in depth, replace
the BUG exit(1) with a clamp + warning so an unexpected leak from
some other future path can't take the child down.

Regression: 50 unsigned UDP tuples → wait past the 10s idle threshold →
60 more tuples → child stays up. Pre-fix this drove max_conn2_count
to 110 and tripped exit(1).
A conn2 slot used to be allocated at TCP accept or first UDP datagram,
before any signed MAVLink packet had a chance to validate. An
unauthenticated client could camp on a slot indefinitely (TCP) or
keep one alive by spamming traffic (UDP), so MAX_COMM2_LINKS attackers
could deny a legitimate signed engineer.

Add a pre-auth deadline: any slot whose MAVLink channel hasn't
validated a signed packet within CONN2_PREAUTH_SECONDS (5 s) of
slot creation is closed and its counters returned to the pool. We
compare against connected_at, not last_pkt, so traffic-spam can't
refresh the deadline. Authenticated UDP retains the existing 10 s
idle close; authenticated TCP/WS continues to live until disconnect.

MAVLink::is_authenticated() exposes got_signed_packet for this check.

Regression: 60 unsigned TCP hogs + 7 s wait + signed engineer connects
and validates ('Got good signature' appears in proxy log).
In bidi-sign mode, validate the signature of the first user-side UDP
datagram before connect()ing the listener to that tuple, so unsigned or
wrong-key senders cannot latch conn1 and deny the legitimate signed
user. TCP/WS still latch on accept, so add a pre-auth deadline that
restarts the child if no signed user packet validates in time.
check_children() and reload_ports() only ran when epoll_wait() returned
0 - a full second with no events. Children inherit the listening
sockets, so the parent's epoll registrations survive its close() after
fork and fire for traffic on active sessions; with any busy session the
timeout never fired, exited children were never reaped and their
listeners never reopened. A connection killed via the web UI stayed
dead (UDP packets arriving, nothing bound) until every session went
quiet, and keys.tdb reloads stopped too.

Run housekeeping on every loop iteration and reload immediately after
reaping a child so its reopened listeners get straight back into the
epoll set. Add a regression test that keeps one pair streaming while
killing the other pair's user connection, and a Robustness phase to
the test runner so it runs in CI.
After fork the child holds references to the pair's socket
descriptions, so the parent's close() does not remove the epoll
registrations: every packet the child handled woke the parent for
nothing, and stale fd numbers could alias newly opened sockets and
trigger spurious forks. EPOLL_CTL_DEL the four fds while we still own
them, and close the inherited epoll fd in the children.
fork() returning -1 was stored in p->pid after the pair's sockets were
already closed: no child ever matches pid -1 in check_children and the
pid==0 checks skip reopening, so the pair went permanently dead. Worse,
the kill(p->pid, SIGTERM) calls in upsert_port/reload_ports guard only
against pid==0 - with pid==-1 they would SIGTERM every process we can
signal. Keep the sockets open and let the still-pending epoll event
retry the fork.
MAVLink::ws had no initializer and init() never reset it: a fresh
object relied on the fork-time stack happening to be zero, and when a
WebSocket engineer disconnected, Connection2::close() deleted the
wrapper but left mav.ws pointing at freed memory - the next plain
TCP/UDP engineer reusing that slot sent through it and crashed the
child, taking down the whole session. Default it to nullptr, reset it
in init(), and clear it in Connection2::close().
Every engineer-slot close path shrank the scan watermark with
'if (conn2_count == max_conn2_count) max_conn2_count--', which fires
whenever the slot table has no holes regardless of which slot closed.
With engineers in slots 0 and 1, an EOF on slot 0 dropped the
watermark to 1 and slot 1 fell out of the select/read/forward/idle
loops: that engineer silently stopped receiving until a new connection
happened to raise the watermark again.

Move all close paths to one helper that only shrinks the watermark
while the top slots are actually free. Add a regression test with two
signed TCP engineers.
Nothing handled SIGPIPE and no send uses MSG_NOSIGNAL, so a write to a
peer-closed TCP/WebSocket/SSL connection could terminate the per-pair
child - ending the session for the user and every engineer. The
socket_is_dead() precheck in send_message() is inherently racy and
mav_printf() writes with no check at all. Ignore the signal so writes
fail with EPIPE and go through the normal close paths.
The 5s connections.tdb snapshot deletes and rewrites every record for
its port2 with flags=0, so a CONN_FLAG_DROP_REQUESTED set by the
webadmin while a snapshot was in flight was wiped before the child's
SIGUSR1 scan ran - the kill silently did nothing and nothing retried.

Preserve DROP_REQUESTED across the snapshot rewrite (conn_drop_mask
collects flagged indices inside the same transaction) and rescan for
drop requests on the snapshot cadence as a safety net, so a request
whose signal raced the rewrite is honoured within a few seconds.
open_socket_in_udp/open_socket_in_tcp returned -1 on bind()/listen()
failure without closing the socket. The parent retries failed listeners
every 5s, so a port held by another process slowly leaked fds toward
EMFILE.
…failure

reload_ports() exited the whole proxy when db_open_transaction()
returned null, so one transient keys.tdb open failure at runtime took
down every session. Skip the reload cycle instead. Give db_open() the
same EBUSY retry/backoff conn_db_open() already has (keydb.py and the
webadmin hold the open lock briefly during transactions), and make
db_open_transaction() report a failed tdb_transaction_start() instead
of carrying on without a transaction.
… partial headers

decode() returned the same -1 for 'frame incomplete, wait for more'
and 'frame can never fit in pending[]', and recv() treated both as no
frame yet: a frame larger than the buffer (e.g. several MAVLink2
messages batched into one WS frame) stalled the connection forever
with no close and no progress. Return a distinct fatal code and fail
the connection so the owner closes it. The bound now accounts for the
NUL byte fill_pending() reserves, which made exactly-buffer-sized
frames uncompletable too.

recv() also ran decode() on partial HTTP upgrade headers when the
request arrived fragmented; the header bytes could parse as a bogus
frame and be consumed, breaking the handshake. Skip decoding until the
handshake is done.
WebSocket's error paths closed the socket fd it was given, but
Connection2 / listen_port own that fd and close it again later. In
between, the child can open new descriptors (lazy tlog/binlog session
files, tdb handles in forked writers) that reuse the number, and the
owner's later close then hits an unrelated file. Mark the wrapper dead
and let recv()/send() report failure so the owner does the single
close. Also add the missing destructor: SSL/SSL_CTX objects leaked on
every WebSocket teardown in a long-lived child.
log_cleanup_loop() never returns, and the child ignores parent exit:
every proxy shutdown outside systemd's cgroup kill left an orphan
supportproxy process looping forever. Request SIGTERM on parent death
and bail if the parent was already gone before prctl took effect.
The test pre-seeded an over-quota sparse file before starting the
proxy, but log_cleanup_once() now runs an immediate quota pass at
startup and deletes it, so the write-time gate saw an under-quota tree
and the test failed. Seed after the cleanup child has started (its
startup pass over the still-empty tree is instantaneous) so the
write-time gate is what gets exercised. Pre-existing failure, not
introduced by the robustness fixes.
Replace the hardcoded 1 GiB MAX_PER_PORT2_BYTES with
port2_quota_bytes(), overridable via SUPPORTPROXY_PORT2_QUOTA_BYTES.
One source of truth shared by the cleanup quota pass and binlog's
write-time gate. 1 GiB is small for a LOG_DISARMED aircraft
(~70 MB/hour observed), and the override also lets tests exercise
quota behaviour with small real files instead of giant sparse ones.
The quota passes summed st_size, but .bin files are sparse: a gappy
log's apparent size wildly overstates what it costs on disk, starving
later sessions of quota they actually have. Count st_blocks*512
instead, in both the cleanup quota pass and binlog's write-time
baseline.
The quota pass deleted oldest files only until total <= quota, so an
active session's growth re-breached the cap within minutes and binlog
writes stalled again until the next hourly pass (seen on a production
server: relief at 02:01, stalled again by 02:09). Free down to 80% of
the quota instead.
When the write-time quota gate tripped, every block was silently
dropped until the next *hourly* cleanup pass — on a production server
a preflight-rebooted aircraft got an empty .bin for exactly this
reason (old sessions held the whole quota). Age out the oldest
sessions for this port2 right at the gate (rate-limited to once per
30s), re-baseline, and only drop the block if the dir still can't get
under quota.
Reboot rotation relied on seeing a SYSTEM_TIME backward jump before
the vehicle resumed streaming. If the vehicle restarted first (our 5s
keep-alive START reaches it before SYSTEM_TIME re-streams), its new
log's block 0 overwrote the head of the old session file, and the
later SYSTEM_TIME rotation then waited for a fresh seqno=0 that never
comes - ArduPilot ignores redundant STARTs while streaming, so only
its 10s client timeout eventually broke the stall.

Detect the restart from the data itself: block 0 arriving while the
file is open with highest_seen past 200 cannot be a retransmission
(AP_Logger_MAVLink's resend window is ~32 blocks) - rotate on the
spot. SYSTEM_TIME detection stays as the fallback for a vehicle that
reboots and doesn't resume streaming.
supportproxy.o didn't list binlog.h, so a BinlogWriter layout change
rebuilt binlog.o but left supportproxy.o with the old object size -
the two then corrupted each other's stack (garbage session numbers,
mass test failures) until a clean rebuild. Add binlog.h there and
cleanup.h to binlog.o (newly included).
A per-pair child outlives the parent's SIGTERM by up to its 10s conn1
idle timeout while holding the fixed test ports; a test starting
inside that window can't bind its listeners and every packet goes to
the stale child, cascading failures through the file. Probe the TCP
listen port until it frees before returning from _terminate().
…file

When blocks are rejected by the strict-start gate (proxy restarted
mid-flight, or a rotation is armed), the vehicle can only recover by
restarting its log from seqno 0 - and ArduPilot ignores redundant
STARTs while streaming, so recovery waited on its 10s no-ACK client
timeout. Send the MAV_REMOTE_LOG_DATA_BLOCK_STOP magic (throttled to
2s) as soon as gated blocks arrive: the vehicle stops immediately and
the existing START logic restarts it from 0, shrinking the log hole
from ~10-15s to a second or two.
The write-time gate trips on prospective size, but the quota pass only
freed when the current on-disk total already exceeded the cap. With
old sessions sitting at or just under the quota, the inline cleanup
freed nothing, the block was dropped, and - since nothing ever gets
written - every retry hit the same wall forever, reproducing the
empty-.bin field symptom at the boundary. Pass the needed headroom
into the pass and free when total+needed exceeds the quota.

Found by review (codex).
The quota pass deleted oldest-first with no active-file protection: an
over-quota active session (made likelier by the write-time trigger and
the 80% hysteresis) had its own open .bin/.tlog unlinked - on Linux
the writer keeps appending to the invisible unlinked inode, the whole
log is lost on close, and the vanished path stops being counted so
real disk use can exceed the quota unseen. Skip (but still count)
files with mtime under 60s.

Found by review (codex).
The gate charged the active .bin's logical end offset while everything
else was counted by allocated blocks: a legitimate sparse forward jump
(allowed up to 100 MB) could falsely trip the quota, and the cleanup
pass - correctly seeing allocated usage under the cap - would free
nothing, leaving the stall permanent. fstat the open file and charge
st_blocks*512 plus the incoming block.

Found by review (codex).
The STOP nudge fired on the first gated block with target_system=0
(identity not yet latched): a broadcast STOP can stop a healthy
remote-log stream owned by another collector on a multiplexed link,
and right after a rotation a single delayed pre-reboot block could
stop the freshly restarted stream before its seqno 0 arrived. Latch
the sender's sysid/compid from the gated block so STOP is addressed,
and only fire once 3 blocks have been gated since the last
open/rotation - a lone straggler stays harmless while a genuinely
mid-log stream crosses the threshold in well under a second.

Found by review (codex).
tridge added 9 commits July 18, 2026 15:06
SUPPORTPROXY_PORT2_QUOTA_BYTES='1GB' was prefix-parsed by strtoll to a
1-byte quota, which blocks every binlog write and lets the cleanup
pass delete nearly the whole log tree; an out-of-range value became
LLONG_MAX and disabled the quota entirely. Require a full-string
positive parse and log the fallback to the default.

Found by review (codex).
A 200-byte write into an unallocated region allocates a whole
filesystem block, so projecting only BLOCK_BYTES let the total
overshoot the allocated-byte quota by the difference. Use st_blksize.
Also document the accepted residuals of the active-file mtime
heuristic and the single-stream STOP semantics.

Found by review (codex).
The bidi pre-latch path re-initialised mav1 for every unsigned
datagram: each init loads the signing key from keys.tdb and forks a
timestamp-save child, so a stream of unsigned traffic (an attacker,
or simply a not-yet-signed user) caused per-packet disk I/O, a fork
per packet, and tdb lock collisions that could stall the child's main
loop. Initialise once at child start - parser and signing state carry
across datagrams safely, and validation still gates the latch.
run_test_scenario connected the engineer at test start but only began
its signed traffic after wait_for_connection_user, which burns its
full 10s in the bidi negative tests (no conn1 latch by design). The
proxy's 5s conn2 pre-auth deadline then closed the idle engineer
socket and the client's handshake/reconnect raced the close - the
flaky bidi failures under -j 16. Connect and sign the engineer after
the wait so it authenticates well inside the pre-auth window, and stop
sender threads from the finally so a mid-setup exception can't leak a
running sender.
The connection phase runs at -j 16, which on a 2-core CI runner is 8x
oversubscription. WS/WSS (and TCP) construction connects and handshakes
synchronously; under that load the proxy child can be slow enough that
pymavlink's own connect retries are exhausted, leaving self.sock unset
- and older pymavlink then dereferences self.sock.fileno() and crashes
the test at construction (AttributeError), before the body even runs.
The four flaky bidi WS/WSS cases in CI were all this. Retry stream
construction a few times, and treat a returned fd==None (newer
pymavlink's graceful form of the same failure) as retryable.
WebSocket::send wrote MAVLink frames whenever the caller had data,
even before the HTTP upgrade 101 had been sent. When an engineer's
signed traffic was forwarded to a user whose WS/WSS handshake was
still in flight (a window that widens under load), the frame landed
ahead of the 'HTTP/1.1 101' status line and corrupted the handshake -
the peer's WS parser rejected the connection ('illegal status line'
starting with the MAVLink magic byte). Drop-but-report-success until
done_headers so forwarding resumes cleanly once the handshake
completes, without tearing down the session.

Also wire test_websocket_decode.py into the runner's Robustness phase
so these (and the existing decode-overflow) tests run in CI, and add a
deterministic regression test for the ordering.

Found via 2-core stress reproduction of a CI flake.
The WebSocket::send handshake guard didn't cover the earlier window:
between TCP accept (which latches have_conn1) and the first user data
event (which runs WebSocket detection and sets mav1's ws wrapper),
mav1 is still in raw-TCP mode. An engineer frame forwarded in that
window is written as plain MAVLink onto a socket that then turns out
to be WebSocket, landing ahead of the HTTP 101 and corrupting the
handshake — the field-observed 'illegal status line' starting with
the 0xfd MAVLink magic.

Gate the engineer->user forward on count1>0: WebSocket detection only
runs while count1==0, so count1>0 means the transport is decided (raw
or WS). For UDP conn1 have_conn1 already implies count1>0, so this only
affects the TCP accept-but-no-data window. Deterministic regression
test in its own isolated proxy (sticky conn1 makes a shared-fixture
version racy).

Found via 2-core stress reproduction of a CI flake.
WebSocket::detect classified a stream from its first bytes but had two
faults: ws_prefix is a char* so sizeof() was 8 not 14, and it returned
a plain bool with no 'need more data' state. A WS client whose first
TCP read delivered 8-13 bytes of 'GET / HTTP/1.1' (common under load)
was therefore misclassified as raw MAVLink: count1 advanced, conn1
stayed in raw mode, and the signed engineer's forwarded frame went out
as raw MAVLink ahead of the eventual HTTP 101 — the field-observed
handshake corruption, and the residual the count1 gate and send guard
couldn't cover.

Return a tri-state (WS_NO / WS_YES / WS_MORE): only commit to raw when
the bytes definitively can't be a handshake (a raw MAVLink frame never
starts with 'G' or 0x16), and wait for more when the prefix matches so
far. Callers hold conn1/conn2 undecided on WS_MORE. Regression test
covers the fragmented-prefix case alongside the two earlier windows.

Found via 2-core stress reproduction of a CI flake.
A bidi negative test (unsigned / wrong-key user) is dropped by the
proxy's 5s pre-auth deadline, which tears the child down and resets
the engineer socket while the test is still draining it (test_duration
sits on the deadline). recv_match then raised ConnectionResetError
uncaught. Treat a transport error in the drain loop as end-of-stream
(nothing was forwarded, which is exactly what the negative test
asserts) and make connection teardown tolerant of an already-reset
socket. A positive test never trips the deadline, so a spurious reset
there still surfaces as an under-count failure.
@tridge
tridge merged commit 19aa540 into ArduPilot:main Jul 19, 2026
2 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.

1 participant