*The job of an iSCSI target is to write the bytes you send it to the block you
name. tgtd does exactly that — it just never checks that the block is on the
disk. Name an offset past the buffer and it writes your bytes into the heap
instead. It has done this since 2007, it listens on 3260, and it runs as root.*
tgtd, the daemon of the Linux SCSI Target Framework (fujita/tgt —
shipped as the tgt / scsi-target-utils package by essentially every
distribution, and the storage backend behind countless SAN appliances, libvirt
iSCSI pools, Ceph/OpenStack gateways and home-lab NAS boxes), contains an
unauthenticated, remote, arbitrary heap write reachable with a single
iSCSI data transfer.
The iSCSI Data-Out PDU carries a 32-bit Buffer Offset telling the
target where in the command's data buffer to place this chunk. tgtd
computes the destination as task->data + offset and receives the segment
there with no bounds check whatsoever. Point the offset past the buffer and
you write attacker-controlled bytes at an attacker-controlled distance into the
heap. This code has been in the tree since 2007.
Two further bugs — also in the shipped binary, also unfixed — turn that write
into full RCE by defeating ASLR entirely off the wire: an R2T response leaks a
live heap pointer (since 2006), and the same unchecked-length logic lets a READ
over-read adjacent heap, disclosing the program (.text) and libc bases. With
heap + image + libc known, we overwrite a victim connection's transport function
table, point it at a buffer we filled, and ride setcontext into
system().
tgtd runs as root in every default deployment, and iSCSI is
unauthenticated unless CHAP is explicitly configured (it is not, by
default). The result is remote unauthenticated root code execution against
any exposed target.
| Software | Linux SCSI Target Framework — tgt / tgtd (fujita/tgt) |
|---|---|
| Vulnerable | All versions through HEAD — v1.0.97 (commit f0dc199). Write primitive present since 2007; pointer leak since 2006. |
| Fixed in | Not fixed — 0day at time of publication. No CVE, no vendor advisory. |
| Auth required | No — unauthenticated whenever CHAP is not configured (the default). Gated only by the initiator-address ACL, whose documented/common value is ALL. |
| Privilege | tgtd runs as root → remote unauthenticated root |
| Tested on | Arch Linux tgt 1.0.97-3 (/usr/bin/tgtd), glibc 2.44, ASLR on — end-to-end RCE, reliable |
| CVE | None assigned at time of publication |
A SCSI WRITE can deliver its payload in one or more Data-Out PDUs. Each
Data-Out carries a Buffer Offset — where in the command's buffer this
segment belongs. iscsi_data_out_rx_start() resolves the destination
pointer like this, and the incoming data segment is then read straight into it:
// usr/iscsi/iscsid.c — iscsi_data_out_rx_start() (unchanged since 2007) found: conn->req.data = task->data + be32_to_cpu(req->offset); // ← offset is attacker-controlled, unchecked task->offset += ntoh24(req->dlength); task->r2t_count -= ntoh24(req->dlength); // (only ever decremented; may go negative) conn->rx_task = task; return 0;
There is no check that offset — or offset + dlength — lies within
task->data_len. The Buffer Offset is a full 32-bit field taken
verbatim from the wire. The subsequent receive copies the PDU's data segment to
task->data + offset, so an attacker writes dlength bytes of
chosen content at a chosen forward distance from a heap buffer they also chose
the size of — a clean, repeatable, arbitrary heap write.
Buffer Offset had one job: say where in the buffer the
data goes. tgtd reads it as a suggestion about where in the
heap the data goes — and the field is a full 32 bits wide, so there are
roughly four billion places to put your write, exactly one of which is the
buffer.
To keep a write anchor resident, send a WRITE with the Final bit clear
(F=0): the task and its task->data buffer stay alive awaiting more
data, and every subsequent Data-Out re-runs the code above. With
InitialR2T=No / ImmediateData=Yes (both freely negotiable by the
initiator) no R2T handshake is required first.
When tgtd solicits write data it emits a Ready-To-Transfer (R2T) PDU.
The Target Transfer Tag is built by casting the task object's address to 32 bits:
// usr/iscsi/iscsid.c — iscsi_r2t_build() (unchanged since 2006) rsp->ttt = (unsigned long) task; // ← low 32 bits of a live heap pointer, straight onto the wire
Every R2T hands the initiator the low 32 bits of a struct iscsi_task
heap address. That is the bottom half of the heap ASLR slide, for free, before
authentication. It pins where our controlled objects live. They cast a live heap
pointer to an integer and mail it to the attacker in a field literally named
Tag — Ready To Transfer, indeed.
.text + libc
The missing-bounds disease is systemic. The same unchecked length arithmetic lets
Bug 1 corrupt a parked READ's residual transfer length
(scmd.in_sdb.transfer_len). The target then builds its Data-In response
from that length and reads past the end of the READ buffer, streaming
~64 KB of adjacent heap back to the initiator. That window is full of pointers:
conn->tp == &iscsi_tcp — a pointer into the program's own .data, which yields the (PIE) image / .text base.fd pointing at glibc's main_arena — which yields the libc base.So all three ASLR domains — heap (Bug 2), image and libc (Bug 3) — collapse to bugs already present in the shipped binary. No brute force, no guessing, no secondary vulnerability required.
T we own completely. Its heap address
comes from the Bug 2 R2T leak.
T — lay down a fake
iscsi_transport vtable plus a setcontext argument
frame (a ucontext with RDI = &cmd, RIP = system),
all filled in-bounds with ordinary Data-Out writes.
conn->tp so it points at
T.
do_recv()
performs conn->tp->ep_read(conn, …); our fake
ep_read slot is a mov rdi, rax ; call [rax] gadget
in tgtd itself → setcontext(T) →
system(cmd). Code runs as root.
Against the stock Arch package /usr/bin/tgtd (tgt 1.0.97-3, glibc 2.44),
ASLR on, no debugger, every address recovered live from the socket:
The exploit is self-calibrating: it reads the libc and tgt build out of the low (ASLR-invariant) bits of the leaked pointers and selects the matching offset set, so a single binary targets a known build with no per-run tuning.
iSCSI targets live on TCP 3260. A tgtd is exploitable when:
tgtadm --op bind --mode target -I ALL, as in the project's own documentation and most how-tos) accepts any address.
tgtd is almost universally run as root (it needs raw block-device and
large-page access). Compromise is immediate, total, and as uid 0. Exposure is
common: iSCSI is frequently left reachable on management/storage networks under
the assumption that "it's just block storage." Authentication in tgt is opt-in.
Exploitation is not.
The vulnerable code is version-independent — the write, the pointer leak and
the over-read are identical across the tree. The published PoC ships the offset
set for the Arch tgt 1.0.97-3 build and a documented recipe for adding
others; the technique itself is not tied to any particular release.
Pure Python 3 standard library — no pip, no sibling files. Lists its
known targets, takes an arbitrary command, and proves execution with a
target-side sentinel file.
python3 poc_tgt_iscsi_rce.py --list python3 poc_tgt_iscsi_rce.py --target arch-pkg --host 10.0.0.5 --cmd 'id > /tmp/proof 2>&1'
It logs in unauthenticated, leaks the three bases off the wire, hijacks a connection's transport pointer, and runs the command as root on the target.
On the bright side: it's always DNS — except this time it's storage, nineteen years old, running as root, and the backups are on the same box you just rooted. Somewhere a compliance checkbox reads "iSCSI restricted to the storage VLAN"; the storage VLAN is where the crown jewels live and the door is a 32-bit integer.
The vendor will not fix this on any timeline we control, so the only reliable mitigations are operational:
-I ALL. Bind targets to specific initiator addresses/names.tgtd as root where avoidable; drop capabilities.
The actual fixes are one-liners and we wrote them: bounds-check the Data-Out
offset/length against task->data_len before computing
conn->req.data; make the R2T Target Transfer Tag an opaque per-task
counter instead of a cast pointer; and validate transfer_len on the
READ path. None of these are hard. They have simply never been done.
| 2006 | R2T Target-Transfer-Tag pointer leak introduced (rsp->ttt = (unsigned long) task) |
| 2007 | Unchecked Data-Out Buffer Offset write introduced (task->data + be32_to_cpu(req->offset)) |
| 2026-08-02 | Rediscovered via automated source audit of the tgt iSCSI front-end |
| 2026-08-02 | End-to-end unauthenticated root RCE confirmed on stock Arch tgt 1.0.97-3, ASLR on |
| — | No CVE assigned. No vendor contact. No patch available. Present in HEAD (v1.0.97, f0dc199). |