iSCSI CHAP: Heap Buffer Overflow in the Linux Kernel

A heap buffer overflow in the iSCSI target authentication code, reachable before password validation. The BASE64 branch of chap_server_compute_hash() passes attacker-controlled input directly to chap_base64_decode() without checking whether it can produce more output than the destination buffer holds. 127 base64 characters decode to 95 bytes, overflowing a 16-byte or 32-byte heap allocation by up to 79 bytes. An attacker only needs the CHAP username, not the password.

1. Background

I was reading through the iSCSI target authentication code in drivers/target/iscsi/iscsi_target_auth.c, looking at what an unauthenticated initiator can reach before credentials are validated. The 2022 commit 1e5733883421 ("scsi: target: iscsi: Support base64 in CHAP") stood out — it extended the login path with a new decoding branch, so I read through chap_server_compute_hash() to see how the new BASE64 case compared to the existing HEX branch.

The difference was immediate. The HEX branch validates length before touching the destination. The BASE64 branch does not.

2. The vulnerable code

Inside chap_server_compute_hash(), after extracting the client's CHAP response into chap_r, the code branches on encoding type. The HEX branch checks the length first:

case HEX:
    if (strlen(chap_r) != chap->digest_size * 2) {
        pr_err("Malformed CHAP_R\n");
        goto out;
    }
    if (hex2bin(client_digest, chap_r, chap->digest_size) < 0) {
        pr_err("Malformed CHAP_R: invalid HEX\n");
        goto out;
    }
    break;

The BASE64 branch, added in that 2022 commit, does not:

/* iscsi_target_auth.c:343 */
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;
    }
    break;

client_digest is allocated at line 276 as kzalloc(chap->digest_size, GFP_KERNEL). For SHA-256 that is 32 bytes; for MD5 it is 16 bytes. chap_base64_decode() writes into that buffer character by character with no bounds check on the destination:

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

    for (i = 0; i < len; i++) {
        ...
        if (bits >= 8) {
            *cp++ = (ac >> (bits - 8)) & 0xff;  /* no bounds check */
            ...
        }
    }
    return cp - dst;
}

The length check at line 344 fires on the return value — after the write has already happened. By the time the comparison != chap->digest_size is evaluated, the destination buffer has already been overflowed.

3. How far it overflows

MAX_RESPONSE_LENGTH is 128. The extract_param() call at line 326 strips the "0b" prefix from a BASE64-encoded value and returns the rest in chap_r, so up to 127 characters can reach chap_base64_decode().

Each base64 character contributes 6 bits. A byte is written to the destination whenever the accumulator holds at least 8 bits, so 4 characters produce exactly 3 bytes. For 127 characters:

127 chars * 6 bits = 762 bits
762 / 8 = 95 bytes written

Against a SHA-256 target (digest_size = 32), the overflow is 63 bytes past the end of a kmalloc-32 object. Against an MD5 target (digest_size = 16), it is 79 bytes past the end of a kmalloc-16 object.

The HEX branch would have caught the same input immediately: strlen(chap_r) != digest_size * 2 rejects anything that is not exactly 2 * digest_size characters. The BASE64 branch has no equivalent guard.

4. Reachability

The overflow is reachable before password validation completes. Looking at the call order inside chap_server_compute_hash():

  • Line 318: username check - strncmp(chap_n, auth->userid, compare_len)
  • Line 326: extract_param() reads CHAP_R from the login PDU
  • Line 344: chap_base64_decode() - the overflow happens here
  • Line 402: password check - memcmp(server_digest, client_digest, chap->digest_size)

The username is checked first and must match a configured CHAP user. That is the only gate before the overflow. An attacker who knows the CHAP username on the target can send any value for CHAP_R and trigger the write at line 344. The password hash comparison at line 402 never executes - the goto out at line 347 fires first, or the overflow corrupts memory before control reaches it.

iSCSI targets that are exposed on the network with CHAP enabled and a known or guessable username are directly reachable. The overflow happens during the login phase, before a session is established.

5. KASAN confirmation

I tested on linux-next next-20260508 (7.1.0-rc2) with a minimal LIO target configured via configfs and a Python script that goes through the full iSCSI login sequence: SecurityNegotiation to negotiate CHAP_A=7, then an authentication PDU with CHAP_R = "0b" + "A" * 127.

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

The target responds with a 0x2/0x1 (authentication failure) PDU. The write has already happened at that point.

I also wrote a userspace reproducer (vuln_sim.c) that replicates the allocation and the decode call in isolation. Compiled with -fsanitize=address it reports a heap-buffer-overflow write of size 1, 0 bytes past a 16-byte region, which is the MD5 case:

==ASAN: heap-buffer-overflow WRITE of size 1
  #0 chap_base64_decode  vuln_sim.c:36
  #1 main               vuln_sim.c:49
allocated 16-byte region here:
  #0 calloc
  #1 main               vuln_sim.c:44

6. Exploitation primitive

The overflow writes 95 bytes starting at client_digest, which comes from kmalloc-16 or kmalloc-32 depending on the hash algorithm. Anything allocated from the same slab cache that lands adjacent in memory gets overwritten. Another iscsi_chap structure from a concurrent connection is the natural spray target — multiple login attempts from separate connections can be used to position allocations.

To demonstrate the exploitation primitive I wrote rce_demo.c, a userspace program that places a client_digest-sized buffer and a victim struct containing a function pointer in contiguous memory (mimicking the kernel slab layout), then triggers the overflow with a crafted base64 payload that encodes the address of a target function at the right offset. The corrupted pointer is called after the overflow:

fn_ptr corrupted, calling 0x55a1b2c3d4e0
executing shell command...
uid=1000(alex) hostname 6.19.11-arch1-1

The allocation in rce_demo.c is a single contiguous calloc() covering the digest buffer, the victim struct, and enough extra space to absorb the full 95-byte write without hitting glibc chunk metadata. Two separate allocations do not work — the chunk header between them gets corrupted at the 17th byte of overflow.

7. The fix

The HEX branch already shows the right approach: check the length before calling the decoder. The maximum number of base64 data characters that can decode to exactly digest_size bytes is DIV_ROUND_UP(digest_size * 4, 3), matching the convention used by BASE64_CHARS() in include/linux/base64.h. Trailing '=' padding characters are stripped before the comparison so that both padded and unpadded encodings are handled correctly. chap_base64_decode() already returns early on '=', so the full original string is still passed to the decoder unchanged.

-       case BASE64:
+       case BASE64: {
+               size_t r_len = strlen(chap_r);
+
+               while (r_len > 0 && chap_r[r_len - 1] == '=')
+                       r_len--;
+               if (r_len > DIV_ROUND_UP(chap->digest_size * 4, 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) {
                        pr_err("Malformed CHAP_R: invalid BASE64\n");
                        goto out;
                }
                break;
+       }

For SHA-256 (digest_size = 32) the data-character limit is 43 — a padded encoding adds one trailing '=' (44 chars total) which is stripped before the check. For MD5 (digest_size = 16) the limit is 22 data characters, with up to two trailing '=' stripped. Any attacker-supplied input whose data length exceeds the limit is rejected before the decoder is called.

All actively supported trees are affected: linux-next, 6.14, 6.12, 6.6, 6.1. The BASE64 branch was introduced in 2022 and has been present in every kernel release since. The patch was submitted with a Cc: stable@vger.kernel.org tag to cover the LTS trees.

8. Patch and other kernel work

The fix was sent as [PATCH] scsi: target: iscsi: validate CHAP_R length before base64 decode to Martin K. Petersen, with copies to Bart Van Assche, target-devel@vger.kernel.org, linux-scsi@vger.kernel.org, and stable@vger.kernel.org. The commit includes a Fixes: 1e5733883421 tag pointing back to the commit that introduced the BASE64 branch. Following review from David Disseldorp (SUSE), a v2 was submitted replacing the inline ceiling formula with DIV_ROUND_UP(chap->digest_size * 4, 3) to match the kernel base64 convention. Maurizio Lombardi then pointed out that the check would incorrectly reject legitimately padded responses — for SHA-256, a padded encoding is 44 characters but DIV_ROUND_UP(32 * 4, 3) = 43. A v3 was submitted that strips trailing '=' characters before the comparison, so both padded and unpadded encodings pass the check correctly. David Disseldorp requested one more change in v4: a comment at the mutual CHAP call site explaining why no equivalent overflow check is needed there (initiatorchg_binhex is CHAP_CHALLENGE_STR_LEN bytes and extract_param() caps the input to CHAP_CHALLENGE_STR_LEN characters, so the decoded output is bounded). David Disseldorp gave Reviewed-by on v4, and Martin K. Petersen applied it to 7.1/scsi-fixes (85db7391310b).

Other recent kernel work: three patch series for the rtl8723bs staging WiFi driver covering ten functions (OnAuthClient(), OnAuth(), HT_caps_handler(), OnAssocRsp(), update_beacon_info(), bwmode_update_check(), issue_assocreq(), join_cmd_hdl(), rtw_get_wps_ie(), rtw_cfg80211_set_wpa_ie()), documented separately on this blog. Before that, patches to the staging tree for NULL pointer dereferences in tegra-video and max96712, a use-after-free and teardown ordering issue in nvec, and a double-free in the ipu7 camera driver.