From ab86b93678061d3d36dd15405211fab6dd5d9e2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Emin=20Ma=C4=9Fn=C4=B1sal=C4=B1o=C4=9Flu?= <138289033+memx13@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:21:29 +0300 Subject: [PATCH 1/2] fix: guard chunk loaders against cyclic offsets --- Evtx/Evtx.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Evtx/Evtx.py b/Evtx/Evtx.py index 261d184..d9022b1 100644 --- a/Evtx/Evtx.py +++ b/Evtx/Evtx.py @@ -360,7 +360,14 @@ def _load_strings(self): self._strings = {} for i in range(64): ofs = self.unpack_dword(0x80 + (i * 4)) + seen = set() while ofs > 0: + if ofs in seen: + # the on-disk next_offset chain points back to an offset we + # already followed; a malformed chunk can make this cyclic, + # so stop instead of looping forever. + break + seen.add(ofs) string_node = self.add_string(ofs) ofs = string_node.next_offset() @@ -394,7 +401,12 @@ def _load_templates(self): self._templates = {} for i in range(32): ofs = self.unpack_dword(0x180 + (i * 4)) + seen = set() while ofs > 0: + if ofs in seen: + # see _load_strings: guard against a cyclic next_offset chain. + break + seen.add(ofs) # unclear why these are found before the offset # this is a direct port from A.S.'s code token = self.unpack_byte(ofs - 10) From 545c3d6e68790bdb5b97e7d9c8513d111da646e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Emin=20Ma=C4=9Fn=C4=B1sal=C4=B1o=C4=9Flu?= <138289033+memx13@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:23:30 +0300 Subject: [PATCH 2/2] test: cover cyclic chunk offset chains --- tests/test_cyclic_offsets.py | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 tests/test_cyclic_offsets.py diff --git a/tests/test_cyclic_offsets.py b/tests/test_cyclic_offsets.py new file mode 100644 index 0000000..b31b2d3 --- /dev/null +++ b/tests/test_cyclic_offsets.py @@ -0,0 +1,80 @@ +import struct +import threading + +import Evtx.Evtx as evtx + + +def _finishes_within(func, timeout=10): + """Run func() in a daemon thread and report whether it returned in time. + + A cyclic offset chain makes the unpatched loader spin forever; the daemon + thread is abandoned when the test process exits, so a hang shows up as a + failed assertion rather than wedging the whole suite. + """ + done = threading.Event() + + def work(): + func() + done.set() + + threading.Thread(target=work, daemon=True).start() + done.wait(timeout) + return done.is_set() + + +def _chunk_with_cyclic_strings(): + """A single 64 KB chunk whose first string-hash bucket points at a cyclic + NameStringNode chain: A.next_offset -> B, B.next_offset -> A.""" + buf = bytearray(0x10000) + buf[0:8] = b"ElfChnk\x00" + a, b = 0x200, 0x210 + struct.pack_into(" A + struct.pack_into(" B + struct.pack_into(" A (A <-> B) + struct.pack_into(" T2, T2.next_offset -> T1. Each + template satisfies the token (0x0C) and self-pointer checks so the loader + actually follows next_offset.""" + buf = bytearray(0x10000) + buf[0:8] = b"ElfChnk\x00" + # keep the templates (and the token/self-pointer bytes they carry at + # ofs-10 / ofs-4) above the chunk's string- and template-bucket tables, + # which end at 0x200, so the fixture only exercises the cyclic chain. + t1, t2 = 0x400, 0x500 + struct.pack_into(" T1 + for this, nxt in ((t1, t2), (t2, t1)): + buf[this - 10] = 0x0C # token checked at ofs-10 + struct.pack_into(" other + struct.pack_into("