aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-08 15:26:32 +0200
committerDanilo M. <danix@danix.xyz>2026-09-08 15:26:32 +0200
commit4d65c2de0a64a663de4458f5cfdc6c59dac31ec6 (patch)
treec2f7c2da4b439c5b64fbed29e9fa0171dfa6a094
parent294009014d74a883112f13dbf48b7403ca79b012 (diff)
downloadabusectl-4d65c2de0a64a663de4458f5cfdc6c59dac31ec6.tar.gz
abusectl-4d65c2de0a64a663de4458f5cfdc6c59dac31ec6.zip
fix: prefer the observed address over an attacker's HELO literal
_extract_ip returned the FIRST bracketed IP in a Received header value. Postfix (and others) write the client's own HELO/EHLO argument first and the address it actually observed on the connection second: Received: from [198.51.100.7] (unknown [203.0.113.99]) by mx... The first bracket is entirely attacker-chosen; a client can HELO with any literal it likes. sending_ip() returned 198.51.100.7, reporting whoever the attacker named rather than 203.0.113.99, the address the accepting server itself wrote. This needs no forged extra hop, only a client that HELOs with an address literal, and the module's own docstring already stated the intended answer ("the bracketed literal after the connecting hostname") without the code implementing it. _extract_ip now collects every bracketed, ipaddress-valid literal with its position and, when there is more than one, prefers the last one appearing before " by " (the accepting server's own clause, and the one closest to it). A header with a single bracketed IP or no " by " token keeps the previous single-candidate behaviour, so simple.eml (203.0.113.42) and forged-chain.eml (203.0.113.99, item 3's own mutation-checked test) are unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KphFXTc2QajxXsHWyvGJ4R
-rw-r--r--abusectl/parse.py36
-rw-r--r--tests/test_parse.py22
2 files changed, 49 insertions, 9 deletions
diff --git a/abusectl/parse.py b/abusectl/parse.py
index 468e35e..da99cc5 100644
--- a/abusectl/parse.py
+++ b/abusectl/parse.py
@@ -84,18 +84,36 @@ def _message(raw: bytes):
def _extract_ip(received_value: str) -> str | None:
- # The bracketed literal after the connecting hostname is the only part
- # of a Received header the accepting server itself wrote; everything
- # else (the claimed hostname) is supplied by the connecting client and
- # cannot be trusted. Validate through ipaddress so a stray bracketed
- # token that isn't actually an IP is skipped rather than misreported.
- for candidate in _BRACKETED_IP.findall(received_value):
+ # Postfix (and others) write two bracketed literals: "from [HELO-CLAIM]
+ # (rdns-name [OBSERVED-ADDRESS])". The first is whatever the connecting
+ # client typed as its own HELO/EHLO argument and is entirely
+ # attacker-chosen; the second is what the accepting server itself saw
+ # on the connection. The bracketed literal is the only part of the
+ # header the accepting server wrote AT ALL, but only when there are
+ # two of them does "which bracket" become a real question, and the
+ # answer is the one closest to " by " (the accepting server's own
+ # clause), which is the LAST one appearing before it. A header with
+ # only one bracketed IP has no such ambiguity, so that single value is
+ # used regardless of where " by " falls, or whether it appears at all.
+ candidates = []
+ for match in _BRACKETED_IP.finditer(received_value):
try:
- ipaddress.ip_address(candidate)
+ ipaddress.ip_address(match.group(1))
except ValueError:
continue
- return candidate
- return None
+ candidates.append((match.start(), match.group(1)))
+
+ if not candidates:
+ return None
+ if len(candidates) == 1:
+ return candidates[0][1]
+
+ by_pos = received_value.find(" by ")
+ if by_pos == -1:
+ return candidates[0][1]
+
+ before_by = [ip for pos, ip in candidates if pos < by_pos]
+ return before_by[-1] if before_by else candidates[0][1]
def received_hops(raw: bytes) -> list[Hop]:
diff --git a/tests/test_parse.py b/tests/test_parse.py
index a29b7a1..49fc41e 100644
--- a/tests/test_parse.py
+++ b/tests/test_parse.py
@@ -63,6 +63,28 @@ class TestReceivedChain(unittest.TestCase):
)
self.assertIsNone(ip)
+ def test_a_helo_literal_does_not_beat_the_observed_address(self):
+ # Postfix writes the client's own HELO string first and the address
+ # it actually observed second. The first is attacker-chosen.
+ raw = (
+ b"Received: from [198.51.100.7] (unknown [203.0.113.99])"
+ b" by mx.example.org with ESMTP id X;"
+ b" Tue, 8 Sep 2026 10:00:00 +0200\r\n"
+ b"From: <a@evil.invalid>\r\nSubject: t\r\n\r\nbody\r\n"
+ )
+ self.assertEqual(parse.sending_ip(raw, trusted=["192.0.2.0/24"]),
+ "203.0.113.99")
+
+ def test_a_single_bracketed_address_still_works(self):
+ raw = (
+ b"Received: from x.invalid (x.invalid [203.0.113.5])"
+ b" by mx.example.org with ESMTP id Y;"
+ b" Tue, 8 Sep 2026 10:00:00 +0200\r\n"
+ b"From: <a@evil.invalid>\r\nSubject: t\r\n\r\nbody\r\n"
+ )
+ self.assertEqual(parse.sending_ip(raw, trusted=["192.0.2.0/24"]),
+ "203.0.113.5")
+
class TestSenderDomains(unittest.TestCase):
def test_the_three_sender_headers_are_collected(self):