diff --git a/CHANGELOG-INTERNAL.md b/CHANGELOG-INTERNAL.md index 9633a2f..22fbd50 100644 --- a/CHANGELOG-INTERNAL.md +++ b/CHANGELOG-INTERNAL.md @@ -97,6 +97,49 @@ a `### BREAKING` section placed FIRST in that version, and each such line is pre - Help text and the flag tables in both READMEs now state that the flag is valid on its own. - Version bump deliberately NOT taken (convention 34): the owner closes it in `VERSION.txt`. +### Fixed: the forged RST now lands on loopback connections too (audit F15) + +Measured on the owner's machine (2026-07-28, elevated, real driver) by watching what the +APPLICATION sees rather than what a counter says: + +- **Non-loopback: the RST works.** DNS over TCP to 8.8.8.8:53, connection established and + exchanging, tool started after 6 s -> the client got `ConnectionResetError` (WinError 10054) at + **6.6 s** after 26 answered queries, with `rst=1/1`. This closes the audit's oldest unverified + claim, that `rst_sent` only proves `send()` did not raise. It proves more than that: Windows + accepts the packet `_build_rst_packet` forges, and the application's connection really dies. +- **Loopback: it does not.** The same shape against an own echo server on 127.0.0.1 gave 26 + exchanges, then silence, then the client's own 3 s timeout - `TIMED OUT`, never a reset, while + `rst=5/1` (five packets blackholed during the cooldown, one RST "sent"). So with the `loopback` + traffic filter - which the GUI offers - "Reset connections" silently degrades to a blackhole and + still reports an RST as sent. +- **A measurement trap worth keeping.** The first attempt fired on the SYN, because `--rst-prob 100` + catches the first packet of a flow, and a bare RST with `seq=0` and no ACK is ignored in SYN_SENT + (RFC 793). That run measured the SYN case, not the established one. When testing behaviour ON a + connection: establish it, pass some traffic, and only then switch the impairment on. + +**Two hypotheses, one measurement each, and the first was wrong.** + +1. `_build_rst_packet` builds a fresh `pydivert.Packet`, whose address starts with `Loopback=0`, so + the flag was carried over from the provoking packet. **One line on purpose** - changing the + direction at the same time would have made a success ambiguous. Re-ran the probe: + `TIMED OUT at 9.5s after 26 exchanges`, byte for byte the baseline. **Falsified.** +2. Rather than guess again, the mechanism was measured: a sniff-only handle on `loopback and tcp` + printed every packet of a real 127.0.0.1 conversation as **`outbound=1, loopback=1`, exactly + once each - the server's replies included.** Loopback has no inbound presentation at all, so an + RST injected as `Direction.INBOUND` was put on a path the stack never reads. The RST is now + built to look exactly like those captured rows: OUTBOUND, loopback flag set, for loopback + packets only. Ordinary traffic keeps INBOUND, which is measured to work. + +**Result:** `CONNECTION RESET at 6.5s after 26 exchanges` (WinError 10054), and the tool's own +counters moved from `rst=5/1` to `rst=1/1` - independent confirmation, since a connection that dies +at once has no further packets to swallow during the cooldown. + +New test: `tests/test_rst_local.py::test_a_loopback_rst_is_injected_the_way_loopback_packets_travel`, +asserting direction and flag for both cases against a hand-built IPv4+TCP ACK. Conditional on +pydivert (win32-only), the same shape as `test_the_driver_queue_param_numbers_match_pydivert`. +Three mutants, every one caught - including "every RST becomes a loopback one", which would have +broken the ordinary path this fix must not touch. + ### Added: the wait inside the driver is measured, not guessed (audit F10, part b) - **The audit asked for an "overload heuristic". None was written, because the dependency already diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a07027..e8b8d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/); versions fol ### Fixed +- **"Reset connections" did nothing to local (loopback) connections - it just made them go + quiet.** Aim the tool at `127.0.0.1` traffic with resets switched on and the connection was not + reset: it stopped carrying anything for the length of the cooldown, so the application sat there + until its own timeout expired, while the tool reported an RST as sent. Anything talking to a + service on your own machine - a local server, a database, a dev proxy - was affected. It now + resets for real, which for a test that expects a broken connection is the difference between + "failed as designed" and "hung". Connections to other machines were never affected, and are + unchanged: that path was measured working (a connection reset 6.6 s after the tool was pointed + at it). + - **A connection's "dropped" count ignored the packets the tool's own queue threw away.** The figure was recorded a step too early - before the packet was even queued - so a session that dropped 5 500 packets to a full delay queue could show `dropped = 0` on the very row it happened diff --git a/beantester/engine.py b/beantester/engine.py index 1d6dd85..747ba98 100644 --- a/beantester/engine.py +++ b/beantester/engine.py @@ -1325,14 +1325,31 @@ def _build_rst_packet(self, packet, fields): try: import pydivert raw = bytearray(packet.raw) + # INBOUND aims the RST at the local end, and that is MEASURED to work + # on an ordinary connection (2026-07-28: DNS over TCP to 8.8.8.8:53, + # established, tool started after 6 s -> the client got WinError 10054 + # at 6.6 s). LOOPBACK has no inbound path to aim at: sniffing + # 127.0.0.1 traffic showed every packet presented exactly once with + # `outbound=1, loopback=1` - BOTH directions of the conversation, the + # server's replies included. An RST injected as INBOUND there is put + # on a path the stack never reads, which is why a loopback connection + # went silent for the cooldown instead of being reset. + loopback = bool(getattr(packet, "is_loopback", False)) rst = pydivert.Packet(memoryview(raw), packet.interface, - pydivert.Direction.INBOUND) + pydivert.Direction.OUTBOUND if loopback + else pydivert.Direction.INBOUND) rst.src_addr, rst.dst_addr = fields["src_ip"], fields["dst_ip"] rst.src_port, rst.dst_port = fields["src_port"], fields["dst_port"] rst.tcp.rst = True rst.tcp.syn = rst.tcp.fin = rst.tcp.psh = rst.tcp.ack = False rst.tcp.seq_num = fields["seq_num"] rst.payload = b"" + # ...and marked as loopback, like every real packet on that path. The + # flag alone was tried first and measured to change nothing (the + # client still timed out), which is what sent the question back to the + # direction above. Both are needed: the forged RST has to look exactly + # like the server's own replies did in the capture. + rst.is_loopback = loopback return rst except Exception as e: if self._running: diff --git a/tests/test_rst_local.py b/tests/test_rst_local.py index ec2660d..fe5d6a1 100644 --- a/tests/test_rst_local.py +++ b/tests/test_rst_local.py @@ -111,6 +111,58 @@ def test_rst_cooldown_sends_once_then_drops_silently(): assert m["rst_sent"] == 1, m["rst_sent"] +def _raw_tcp_ack(sport=54321, dport=39854, seq=1000, ack=2000): + """A minimal, valid IPv4+TCP ACK - enough for _build_rst_packet to work on.""" + import struct + + ip = struct.pack(">BBHHHBBH4s4s", 0x45, 0, 40, 1, 0, 64, 6, 0, + bytes([127, 0, 0, 1]), bytes([127, 0, 0, 1])) + tcp = struct.pack(">HHIIBBHHH", sport, dport, seq, ack, 0x50, 0x10, 8192, 0, 0) + return bytearray(ip + tcp) + + +def test_a_loopback_rst_is_injected_the_way_loopback_packets_travel(): + """Loopback has no inbound path to aim an RST at. + + MEASURED (2026-07-28, real driver, own echo server): sniffing 127.0.0.1 TCP + showed every packet presented exactly once as `outbound=1, loopback=1` - both + directions of the conversation, the server's replies included. An RST injected + as INBOUND there goes onto a path the stack never reads, and the connection + just went silent for the cooldown: `TIMED OUT after 26 exchanges`, twice, with + `rst=5/1`. Injected the way real loopback packets travel it resets in 6.5 s + with WinError 10054 and `rst=1/1`. + + An ordinary connection keeps INBOUND, which is measured to work (DNS over TCP + to 8.8.8.8:53, reset at 6.6 s). Conditional on pydivert, a win32-only + dependency - the same shape as the driver-queue ABI check. + """ + import importlib.util + + if importlib.util.find_spec("pydivert") is None: + return + import pydivert + from beantester.core import BeanCore + + eng = BeanEngine() # no divert: takes the real pydivert path + src = pydivert.Packet(memoryview(_raw_tcp_ack()), (0, 0), + pydivert.Direction.OUTBOUND) + fields = BeanCore.build_rst_fields(src) + assert fields, "the probe packet is not usable as an RST source" + + src.is_loopback = True + rst = eng._build_rst_packet(src, fields) + assert rst is not None, "no RST was built for a loopback packet" + assert rst.is_loopback is True, "the RST is not marked as loopback" + assert rst.direction == pydivert.Direction.OUTBOUND, ( + "a loopback RST must travel the way loopback packets do", rst.direction) + + src.is_loopback = False + plain = eng._build_rst_packet(src, fields) + assert plain.is_loopback is False, plain.is_loopback + assert plain.direction == pydivert.Direction.INBOUND, ( + "an ordinary RST must still be aimed at the local end", plain.direction) + + def test_simulate_mode_exercises_rst(): eng = BeanEngine() eng.set_rst(80, 3.0)