Skip to content

Harden pre-latch ANNOUNCE handling and bound resend/ttl edge cases - #40

Merged
gistrec merged 3 commits into
masterfrom
fix/announce-dos-and-hardening
Jul 6, 2026
Merged

Harden pre-latch ANNOUNCE handling and bound resend/ttl edge cases#40
gistrec merged 3 commits into
masterfrom
fix/announce-dos-and-hardening

Conversation

@gistrec

@gistrec gistrec commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

Five robustness fixes from a pre-release audit. Each was reproduced locally and is covered by a test that runs on Linux/macOS. Build is clean under -Wall -Wextra; all 10 ctest cases pass.

The headline fix is a one-packet DoS: before a receiver latched a session, a single forged ANNOUNCE could kill filecast receive while it was still waiting for a sender.

Fixes

🔴 Pre-latch ANNOUNCE DoS (Receiver.cpp)

Before a receiver latched a session, a forged ANNOUNCE with an invalid file_size/chunk drove announceValid() to a fatal exit(2) — so one unauthenticated datagram killed filecast receive in the very window it spends "Waiting for a sender...". The post-latch path already guarded against this (see e2e_hijack); the pre-latch window did not.

  • Validate with the side-effect-free Protocol::announceInRange() before latching or refreshing the deadline.
  • Ignore an invalid pre-latch ANNOUNCE (warn + keep waiting) instead of exiting, mirroring the same-session guard.
  • Remove the now-unused announceValid().

Reproduction: a single file_size=0 ANNOUNCE made the receiver exit 2 in ~0.1s; it now logs a warning and waits out its full ttl.

🟡 RESEND burst ignored Ctrl+C / deadline (Receiver.cpp)

The recovery re-request loop checked g_interrupted and the wall-clock deadline only in its outer loop, so re-requesting a large missing set (with --rate/--delay-ms pacing) left the receiver unresponsive for many seconds. Now checked inside the burst too.

🟡 32-bit size_t overflow on max-size files (Receiver.cpp, Protocol.hpp, Sender.cpp)

Two overflows on the same 4 GiB boundary, both harmless on a 64-bit size_t:

  • MAX_FILE_LENGTH: 4ULL*1024*1024*1024 wraps to 0 on a 32-bit size_t, making the receiver reject every non-empty file. Use the exact v3 wire limit 0xFFFFFFFF, which fits size_t on every target.
  • totalParts() (follow-up from review): with the limit fixed a max-size ANNOUNCE is now accepted, but (file_length + chunk_size - 1) / chunk_size still wraps a 32-bit size_t for file_length in the top chunk_size bytes of the range, collapsing the part count to ~0 — the receiver then rejects every data part (part >= 0) and calls the transfer 0/0 complete, and the sender (same inlined expression) ships 0 parts. Switched to overflow-safe ceiling division (file_length / chunk_size + (remainder != 0)) in the shared helper, with Sender routed through it. Contained by the existing part >= total bound, so a correctness bug, not OOB/DoS.

🟢 --ttl overflow guard (Main.cpp)

--ttl was only checked for > 0; ttl*10 seconds (the sender's resend-phase deadline) overflows a steady_clock time_point in nanoseconds at absurd values. Capped at 86400 (24h).

🟢 Zero-length datagram drained the resend phase (Sender.cpp)

serveResends() treated a zero-length datagram as a timeout (result <= 0), letting a peer spraying empty packets drain the resend phase early. Split result < 0 (timeout) from result == 0 (ignore), mirroring the receiver's length == 0 handling.

Tests

  • New e2e_prelatch: fires two poison ANNOUNCEs (empty file, out-of-range chunk) at a receiver before the real sender starts, checks it survives, then requires the real transfer to complete and verify.
  • Updated comments in e2e_hijack.sh / hijack_proxy.py that referenced the removed announceValid().
  • TotalPartsBoundaries unit test now pins the 0xFFFFFFFF max-wire-size counts (64- and 65489-byte chunks) — a real regression guard on 32-bit builds, a smoke test on 64-bit.
  • ctest: 10/10 pass (was 9, plus e2e_prelatch).

Deferred (follow-up issues)

Unbounded sent_part growth under a full-loss RESEND storm, and three Windows-only items (WSAGetLastError in send diagnostics, WSAStartup ordering vs inet_pton, symlink guard on .part.idx) that need CI/Windows to verify.

Five robustness fixes surfaced by a pre-release audit; each was reproduced
and is covered by tests that run on Linux/macOS.

* Pre-latch ANNOUNCE DoS. Before a receiver latched a session, a single
  forged ANNOUNCE with an invalid file_size/chunk drove announceValid() to
  a fatal exit, so one unauthenticated datagram killed `filecast receive`
  while it was still "Waiting for a sender...". The post-latch path already
  guarded against this (see e2e_hijack); the pre-latch window did not.
  Validate with the side-effect-free Protocol::announceInRange() *before*
  latching or refreshing the deadline, and ignore an invalid one (warn and
  keep waiting) instead of exiting. Drops the now-unused announceValid().
  New e2e_prelatch test fires two poison ANNOUNCEs, checks the receiver
  survives, then requires the real transfer to complete and verify.

* Recovery RESEND burst ignored Ctrl+C and the wall-clock deadline for its
  whole duration. Re-requesting a large missing set (with --rate/--delay-ms
  pacing) left the receiver unresponsive for many seconds. Check
  g_interrupted and the deadline inside the re-request loop too, not just in
  the outer loop.

* MAX_FILE_LENGTH (4ULL*1024*1024*1024) wraps to 0 on a 32-bit size_t,
  making the receiver reject every non-empty file. Use the exact v3 wire
  limit 0xFFFFFFFF, which fits size_t on every target.

* --ttl was only checked for > 0. ttl*10 seconds (the sender's resend-phase
  deadline) overflows a steady_clock time_point in nanoseconds at absurd
  values. Cap it at 86400 (24h).

* serveResends() treated a zero-length datagram as a timeout (result <= 0),
  letting a peer spraying empty packets drain the resend phase early. Split
  result < 0 (timeout, count ttl down) from result == 0 (ignore), mirroring
  the receiver's length==0 handling.

Build is clean under -Wall -Wextra; all 10 ctest cases pass.
@gistrec gistrec added bug Something isn't working security Security-related fix or vulnerability labels Jul 6, 2026
gistrec added 2 commits July 7, 2026 01:18
Condense the five explanatory comment blocks to one or two lines each,
matching the surrounding code's terse style. No code changes.
The MAX_FILE_LENGTH = 0xFFFFFFFF fix let a receiver accept a max-size
ANNOUNCE, but totalParts() still computed (file_length + chunk_size - 1)
/ chunk_size. On a 32-bit size_t that add wraps for file_length near the
0xFFFFFFFF wire limit, collapsing the part count to ~0: the receiver
then rejects every data part (part >= 0) and treats the transfer as
0/0 complete, and the sender (same inlined expression) ships 0 parts.
64-bit targets are unaffected; the top chunk_size bytes of the 4 GiB
range were the only broken band on 32-bit.

Use overflow-safe ceiling division (file_length / chunk_size + remainder
!= 0) in the shared helper and route Sender through it. Pin the boundary
in the TotalPartsBoundaries unit test.
@gistrec
gistrec merged commit 508809b into master Jul 6, 2026
6 checks passed
@gistrec
gistrec deleted the fix/announce-dos-and-hardening branch July 6, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working security Security-related fix or vulnerability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant