Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG-INTERNAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 18 additions & 1 deletion beantester/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
52 changes: 52 additions & 0 deletions tests/test_rst_local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down