From 5e79bd9d8b13a8c0949ac4018c5b983c9bf54d24 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Tue, 28 Jul 2026 16:20:08 +0200 Subject: [PATCH] fix(engine): inject the RST the way loopback packets actually travel "Reset connections" did nothing to 127.0.0.1 connections. It blackholed them for the cooldown while reporting an RST as sent, so an application talking to a local service sat there until its own timeout instead of seeing a reset. Found by measuring what the APPLICATION sees rather than what a counter says: own echo server, connection established and exchanging, tool started afterwards -> TIMED OUT after 26 exchanges, rst=5/1. The same shape against 8.8.8.8:53 - not loopback - reset the connection at 6.6 s with WinError 10054, so the injection itself was never the problem. Two hypotheses, one measurement each, and the first was wrong. Carrying the Loopback address flag over changed nothing (identical baseline, to the exchange). Rather than guess again, the mechanism was measured: a sniff-only handle showed every packet of a real loopback conversation presented as outbound=1, loopback=1, exactly once each - the server's replies included. Loopback has no inbound presentation, so an RST injected as INBOUND went onto a path the stack never reads. The RST is now built to look exactly like those captured rows. Ordinary traffic keeps INBOUND, which is measured to work and must not change. Result: CONNECTION RESET at 6.5 s, and the tool's 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. Also closes the audit's oldest unverified claim on the way: rst_sent proves more than "send() did not raise" - Windows accepts the forged packet and the application's connection really dies. Three mutants, every one caught, including "every RST becomes a loopback one", which would have broken the ordinary path this must not touch. Co-Authored-By: Claude Opus 5 --- CHANGELOG-INTERNAL.md | 43 ++++++++++++++++++++++++++++++++++ CHANGELOG.md | 10 ++++++++ beantester/engine.py | 19 ++++++++++++++- tests/test_rst_local.py | 52 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) 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)