进攻性安全挑战 2025 - Stealers' Shadow
Table of Contents
盗贼的影子
今年 OffSec 的 CTF 的一部分涉及对受感染机器的取证调查。
我们收到以下提示:
Download the ZIP package. The password is "**Shadow234@**".
Thanks to your actions during the ProtoVault incident, you've gained the trust of the Etherians. The OffSec Legend, Cipherflare has called upon you to investigate the breach before more damage is done.
The Etherians offer fragments of evidence, just enough to begin the investigation:
- The user directory of **[email protected]** from the machine **WK001**
- Event logs from **WK001**
A ZIP archive awaits you.
Uncover the truth hidden in the darkness. Find what was taken, and how.
在 zip 里面,我们有一些文件 -
在 a.smith 目录中,我们获取用户的主文件夹

而 .evtx 文件是该系统的 sysmon 日志。
第 1 部分 - 为简洁起见进行总结
我使用 evtx_dump 来解析文件以提高可读性:
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% ~/Tools/evtx_dump -o jsonl -f logs_json.json logs.evtx
也可以在 Windows 事件查看器中打开,但通过 CLI 解析要容易得多

我们可以使用 jq 来过滤和细化我们的搜索
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% cat logs_json.json | jq '.Event.System["EventID"]' | head -n 50
23
11
11
11
11
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% cat logs_json.json | jq '.Event | select(.System["EventID"] == 3)' | head -n 50
{
"#attributes": {
"xmlns": "http://schemas.microsoft.com/win/2004/08/events/event"
},
"System": {
"Provider": {
"#attributes": {
"Name": "Microsoft-Windows-Sysmon",
"Guid": "5770385F-C22A-43E0-BF4C-06F5698FFBD9"
}
},
"EventID": 3,
"Version": 5,
"Level": 4,
"Task": 3,
"Opcode": 0,
"Keywords": "0x8000000000000000",
"TimeCreated": {
"#attributes": {
"SystemTime": "2025-07-31T08:56:30.629677Z"
}
},
"EventRecordID": 41617,
"Correlation": null,
"Execution": {
"#attributes": {
"ProcessID": 6648,
"ThreadID": 10256
}
},
"Channel": "Microsoft-Windows-Sysmon/Operational",
"Computer": "WK001.megacorpone.com",
"Security": {
"#attributes": {
"UserID": "S-1-5-18"
}
}
},
"EventData": {
"RuleName": "-",
"UtcTime": "2025-07-31 08:56:50.550",
"ProcessGuid": "00000000-0000-0000-0000-000000000000",
"ProcessId": 11360,
"Image": "<unknown process>",
"User": "-",
"Protocol": "tcp",
"Initiated": true,
"SourceIsIpv6": false,
"SourceIp": "10.10.10.245",
"SourceHostname": "WK001.megacorpone.com",
juicecat@Sovngarde-2:~/CTF/offsec-gauntlet/stealers-shadow
% jq -c 'select(.Event.System.EventID == 3 25-10-14 - 14:46:29
and .Event.EventData.Initiated == true
and .Event.EventData.Image != "C:\\Windows\\System32\\svchost.exe"
and .Event.EventData.Image != "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
and .Event.EventData.Image != "C:\\Windows\\System32\\taskhostw.exe")' logs_json.json | wc -l
15981
最终,(通过跳过这个挑战的大部分来节省时间)我们遇到了这个事件:
},
"EventData": {
"RuleName": "-",
"UtcTime": "2025-08-05 09:02:06.865",
"ProcessGuid": "8404BF77-C85E-6891-0E37-000000000C00",
"ProcessId": 17852,
"User": "MEGACORPONE\\a.smith",
"Image": "C:\\Users\\a.smith\\AppData\\Local\\Microsoft\\Windows\\INetCache\\IE\\66HCZK0X\\captcha_privacy[1].epub",
"TargetFilename": "C:\\Users\\a.smith\\AppData\\Local\\Temp\\101010245WK001.zip",
"Hashes": "SHA1=756FF3A252D10493CE9C34297FA7BB6F84DC27A4,MD5=053C53EC53D5E6C720AB105BC46FAE2B,SHA256=B6A1646F23BA0A05B7C80A7D6261204384AB06F15983EB195EB5F0A3FEDF2475,IMPHASH=00000000000000000000000000000000",
"IsExecutable": false,
"Archived": "true"
}
}
}
这很奇怪——如果你注意到的话,这是一个文件创建事件,而Image字段是一个epub文件,这是极其不正常的。这促使我们调查这个有问题的 epub 文件。正如您所料,epub 文件实际上是一个 PE32,由于恶意注册表更改,该文件正在作为可执行文件运行。
这是对事件日志和用户配置文件进行数小时挖掘的总结,但这篇博文的主要目的是进行反汇编。
第 2 部分 - 恶意文件反汇编
我们将此文件上传到Ghidra进行反汇编,并立即检查入口函数。这个入口函数只调用了两个函数,
第一个没什么有趣的——只是样板文件

下一个是用于准备数据的注入函数

在红色部分,我们看到一些设置。但有趣的是蓝色和它下面的东西。
我们看到这些行:

uVar8 = _get_initial_narrow_environment();
puVar9 = (undefined8 *)__p___argv();
uVar1 = *puVar9;
puVar10 = (undefined4 *)__p___argc();
iVar5 = FUN_14002a820(*puVar10,uVar1,uVar8);
cVar3 = FUN_140374c9c();
if (cVar3 != '\0') {
if (!bVar2) {
_cexit();
}
__scrt_uninitialize_crt(1,0);
return iVar5;
}
这看起来像是设置变量,然后调用函数并传入这些变量。在本例中,FUN_14002a820 被调用,并被赋予 3 个参数。之后,似乎正在调用 FUN_140374c9c 并检查输出是否成功(无错误)退出。之后,返回FUN_14002a820 func的返回值。
为了便于后续操作,我们将 FUN_14002a820 重命名为 wrapper_main,然后按照外部参照进行操作。
看起来 wrapper_main 本身就是另一个 FUN_1402cef40 的包装。我们将其重命名为 wrapper_2。我们还看到传递了更多参数,但我们稍后会再讨论这些参数。让我们看看wrapper_2里面有什么
在这里,我们还有更多工作要做——
我将让您免于头痛,并解释一下这是在做什么。 首先,它设置堆栈并准备错误处理:
AddVectoredExceptionHandler(0, FUN_1402e1550);
local_90 = (undefined *)CONCAT44(local_90._4_4_, 0x5000);
SetThreadStackGuarantee((PULONG)&local_90);
然后,它初始化一个线程:
pvVar3 = GetCurrentThread();
(*(code *)PTR_FUN_14054bd70)(pvVar3,"m");
接下来的几行做了几件事 - 首先,它通过 GS 段设置对 TLS(线程本地存储)的访问。然后它设置一个循环来增加 DAT_14054bff0 并锁定,并将其写回 TLS 位置。实际上,这只是 在全局运行时上下文中注册线程 并确保线程安全的初始化。
lVar5 = *(longlong *)
(*(longlong *)(*(longlong *)(unaff_GS_OFFSET + 0x58) + (ulonglong)_tls_index * 8) + 0xe8)
;
lVar4 = DAT_14054bff0;
if (lVar5 == 0) {
do {
if (lVar4 == -1) {
FUN_140383ec0();
do {
invalidInstructionException();
} while( true );
}
lVar5 = lVar4 + 1;
LOCK();
bVar6 = lVar4 != DAT_14054bff0;
lVar1 = lVar5;
if (bVar6) {
lVar4 = DAT_14054bff0;
lVar1 = DAT_14054bff0;
}
DAT_14054bff0 = lVar1;
UNLOCK();
} while (bVar6);
*(longlong *)
(*(longlong *)(*(longlong *)(unaff_GS_OFFSET + 0x58) + (ulonglong)_tls_index * 8) + 0xe8) =
lVar5;
}
现在是重要的部分
iVar2 = (**(code **)(param_2 + 0x28))(param_1);
我们之前已经见过这种情况,但这需要对低级编程语言有一点了解。这行代码的作用本质上是找到param_2(充当指针)所在的数据块,并将偏移量0x28添加到其中。它获取此时的任何数据,并将其作为函数调用,并传入 param_1 作为参数。
这是一种冗长的解释方式,它非常混乱,我们甚至还没有看到真正的“main”函数。
要找到它,我们需要找出 param_2 + 0x28 解析为什么,这意味着找到 param_2
好吧,请记住我们已经有 3 个包装纸深了。因此,让我们检查一下上面的包装器来找出参数来自哪里:
在这里,我们看到 &DAT_14038c518 作为 param_2 传递。我们可以通过双击它来导航到该指针,它会将我们带到存储数据的位置

看来只是归零了。幸运的是,我们并没有直接搜索它,而这只是我们将用 0x28 进行偏移的基础。所以,我们能做的就是按G并输入DAT_14038c518的地址,并添加我们的偏移量。

我们在这个位置找到了一个内存地址!现在,多亏了 Ghidra,它显示为 FUN_1400583d0,所以我们知道这里有一个函数。
让我们进入这个函数,并将其重命名为 main 。

太棒了,又一个包装!让我们继续关注FUN_14004eea0
那是什么?另一个包装!?伟大的。但现在我们正处于十字路口。这个包装看起来不一样。它所做的本质上是获取存储在 param_1 中的任何数据,将其解释为内存地址,并执行其中的任何内容。因此,我们需要再次追踪参数的来源。
换句话说,FUN_14004eea0(code *param_1)确实(*param_1)();调用了地址在param_1中的函数。
仅通过查看 C 代码,我们无法追踪该变量来自何处。然而,当我们查看反汇编指令时,我们发现该值只是存储在 RCX 寄存器中 -

事实上,前面的包装层准备了要读取的寄存器:

现在我们有点停滞不前 - 我们需要找到 RCX 寄存器中的内容,但这些仅在运行时存在,因此我们需要调试程序并在此位置设置断点,这应该很容易,因为我们有要中断的函数的地址。

因为该程序是 PE32 可执行文件而不是 极低频 二进制文件,所以我们无法使用 Ghidra 或 gdb 来调试它(我为此类工作选择的程序),而是需要一个 Windows 调试器。我首选的 Windows 调试器是 x64dbg。
我们需要在调试器中完成的工作相当少——我们只是想在某个断点处检查内存。因此,我不会在这里解释太多,而是只讨论高级细节。
首先要注意的是左侧的不同地址 - 因此,我们不能简单地在 0x14004eea0 处设置断点。造成这种差异的原因是程序加载方式的差异。
- 吉德拉 静态分析文件。它使用PE头中记录的图像库(例如
0x140000000)作为所有虚拟地址的开始。 - x64dbg 显示地址 Windows 加载程序后。 Windows 的加载程序应用 地址空间布局随机化 (ASLR),因此它将图像映射到 随机碱基(例如
0x00007FF6C3E51000)。

由于我们无法以简单的方式做到这一点,因此我们可以仅使用相对偏移量。如果我们回到 Ghidra,我们可以一直向上滚动以查看所使用的基地址:

然后,为了找到函数的偏移量,我们只需从函数地址中减去它:
0x14004eea0 - 0x140000000 = 0x4eea0
然而,由于我们想要在调用函数之前实际暂停并检查 RCX 的内容,所以我实际上只获取这个地址:
位于偏移0x583d4处
要获取 x64dbg 中 exe 的基地址,我们可以打开内存映射选项卡并查看它的加载位置。

幸运的是,我们实际上不需要做数学运算,只需将其作为命令插入到 x64dbg 中即可访问此偏移量:

然后我们可以恢复执行并命中断点
事实上,我们看到反汇编的指令也与我们在 Ghidra 中看到的指令相符。从这里,我们可以检查右侧的内存内容,这为我们提供了 RCX 值:
0x000000248D5BFE70
但是等一下..这没有意义。我们期望函数指针位于该寄存器中,并且该值位于我们的基址偏移量之下。在这种情况下,我们实际上暂停了 前 RCX 设置 - 因此我们只需前进一条指令,我们就会看到 RCX 内容更改为看起来更正确的内容:

0x00007FF718F6A600,与我们的偏移量匹配。我们可以右键单击该值并跟随它到转储窗口,在该窗口中我们可以右键单击内存地址并复制其文件偏移量

这给了我们:0x29A00。在 Ghidra 中,我们可以导航到这个偏移量:

这是一个 非常 sus 函数。

在里面我们看到很多非常有趣的东西表明枚举,例如下面的行:
FUN_1400285d0(&lStack_e0,
*(undefined8 *)((longlong)&PTR_s_%USERPROFILE%\Documents_140388ff0 + lVar11),
*(undefined8 *)((longlong)&DAT_140388ff8 + lVar11));
看起来它迭代了用户数据——考虑到该文件的恶意上下文,这是有道理的。
为了便于理解,这项调查将在另一篇博客文章中继续。但目前,我们已经发现了该恶意软件的基本机制