第三届 盘古石杯电子取证竞赛 初赛 题解¶
Author
- BlackBird @ 猫猫啥也不会 (APK 逆向部分)
- NoahTie @ 猫猫啥也不会 (其余部分)
3D 案情容器密码:
9f8L2kP7mQv3RzX1tB5n6yH4CwJp9TqEa2rF1xSbD0LcKjZ检材容器密码:
01tn0GdE0]BF00pT0T&f00&u0k-q0Up41UhA00N,0\-L0kRr0j-p0T&R1F&=0\Ni1GR]
写在前面¶
这是我唯一一场打完后彻底怀疑"取证不存在了"的比赛. 整个过程仿佛在云里雾里晃荡了一圈——案情引导几乎为零, 找证据全靠蒙. 我们原本已经全员达成共识, 这 WP 不写也罢, 感觉也没什么价值. 结果后来:

最有动力写 WP 的一集
小作文有点长, 为了避免影响观感, 放在最后面了.
UE5 解包及解包文件中的 3D 模型读取¶
之前两次用的 UE 引擎版本都在 4.2x, 可以直接用 Umodel 解包并查看 UAsset 文件. 但这次是 UE 5.3.2, 新增并修改了很多资源文件的标识符, Umodel 对其支持较差, 无法正常查看资源文件.
好在 UnrealPakViewer 还能用, 因为它只读取并导出原始 UAsset 文件和 Uexp 文件, 并不进行更进一步的处理, 可以正常解包. 然后在 UE 里面用 UAssetBrowser 插件批量导入就可以了. UE 引擎是向下兼容的, 保证自己安装的是最新版的 UE5 就一定可以正常导入. 做 Misc 的电脑上有 UE 引擎也是合理的吧.
FModel 更好用一些, 基本上支持到了最新的 UE 引擎版本, 甚至对一些知名游戏还有专门的优化. 但是对盘古的这个不能正常处理, 不知道是对 map 做了什么处理, 还是别的什么问题, FModel 没有自动识别到.
3D 案情中的信息¶
- 电脑开机密码:
NOtep@d++ - 手机锁屏密码:
147258 - 容器及vmx加密规则:
Pgs+4位数字+d3j
贾韦码的 Android 手机¶
参考检材 贾韦码\安卓手机\Image.zip.
不是哥们, 真用自己手机出题啊, 数据也不清一下? 这通讯录, 盘古石的人快齐活了...
![]()
1 手机的 IMSI 是¶
答案
460036641292715

IMSI & IMEI
以往的取证比赛中更常见的是问 IMEI, 很少见到 IMSI.
原因在于:
- IMEI 是设备基带中存储的信息, 由设备(通讯模块)生产厂家写入, 与设备绑定.
- IMSI 是 SIM 卡中保存的信息, 由通信运营商写入, 与 SIM 卡绑定.
以往的出题人会在出题前清空设备中的数据,并取出 SIM 卡, 因此在检材中无法提取到 SIM 卡相关的信息. 但这次出题人并没有进行这一操作.
2 养鱼诈骗投资 1000, 五天后收益是¶
答案
175


钟无声的 iPhone¶
参考检材 钟无声\苹果手机\Image.zip.
部分题目与钟无声的 Mac 电脑有关, 参考检材 钟无声\mac电脑\mac.E01.
3 手机的 IDFA¶
答案
E477D4C7-BD02-4979-BC9D-5C5DE7BD1F17

IDFA
Identifier for Advertiser, iOS 14.5 版本后引入的个性化广告标识符. 可由用户重置或关闭.
这就属于无效信息. 虽然确实存在这个信息, 但是对取证工作毫无意义. 难不成还能拿着 IDFA 去苹果公司调证?
4 Telegram 应用的卸载时间¶
答案
2025-04-17 10:51:39

5 机主的 hotmail 邮箱地址¶
其一是来自于 iOS 系统自动保存的表单信息中的邮箱地址, 且保存在了多个不同的应用的自动填入中:

另一是来自于 Telegram 聊天记录中发送的邮箱地址:

6 苹果电脑开机密码¶
答案
12345678
在系统截屏(系统为任务切换视图自动生成的截图)中可以看到两个白板应用的截图:

其中之一记录了 MacBook 的开机密码:

7 Telegram 加密通讯中, 加密聊天信息用到的第二个解密载体是¶
答案
2.mp4
Telegram 的聊天记录中提及到"邮箱接收蜜语规则".

但在查看手机上的邮箱工具(iOS 自带邮箱和网易邮箱大师)后发现并没有邮件信息. 在 MacBook 上发现使用 Safari 浏览器登录了网页端的邮箱.
在手机的备忘录中可以看到与"蜜语规则"相关的记录:

在手机上的"概念画板"应用的数据中, 看到一张图片, 内容与"蜜语规则"相关:

在 MacBook 的检材中, 存在笔记的数据, 但是火眼没能自动解析出来存在图片附件, 需要手动进行解析.

首先找到笔记应用的数据路径 Macintosh HD - Data/Users/apple/Library/Group Containers/group.com.apple.notes/, 导出其中的数据库文件:

打开数据库, 在表 ZICNOTEDATA 中的字段 ZDATA 中存储了笔记正文:

用 Hex 编辑器保存为文件(gzip 压缩), 用任意工具解压即可看到原始文件:

可以看到, 这里引用了一个图片文件. 在笔记的数据目录中可以看到这个图片文件:

综合上述信息, 可以分析得知:
可以猜测题目中说到的"第二个解密载体"是指 2.mp4. 这些文件在 MacBook 的检材中可以找到.
8 贾韦码的内部代号¶
答案
48
这里开始的题目有两种做法: 一是根据上题中找到的文件, 对 Telegram 中的消息进行解密; 二是找到在 Telegram 的缓存文件中的解密后的消息:

太谜语人了
解密的方式参考之后的"钟无声的 MacOS 计算机"部分.
9 特快专递的收货地址¶
答案
西红市中山路35号PGS健身房
接上题.
二进制分析(APK)¶
参考检材 贾韦码\安卓手机\Image.zip.
1 远控工具包名¶
答案
com.carriez.flutter_hbb
查看检材, 首先最直接的远控工具是向日葵远程控制, 但是和下面的题目对不上, 查看聊天记录, 根据上下文, 确定为远控软件.


也在手机的应用安装列表中看到该应用:

2 远控工具中继服务器 IP¶
答案
59.110.10.229
查看 /data/user/0//{packageName}/ 路径下应用相关数据, 首先查看 files 和 shared_prefs 两个路径, 看到配置相关内容:

查看/data/user/0/com.carriez.flutter_hbb/app_flutter路径:

存在多个 toml 配置文件, 配置中有多个相关的服务器内容, 但是无法对应中继服务器 / ID 服务器, 安装到模拟器上做如下设置:


确定在 RustDesk2.toml 文件 options 选项中, custom-rendezvous-server 为 ID 服务器, relay-server 为中继服务器, Key 为中继服务器的 Key 值.
3 远控工具 ID 服务器端口¶
答案
21116
见上题.
4 远控工具中继服务器 Key¶
答案
WIUqzRq1Ocx4QNnsF26dZQijKdyd2L9OfaT55hDlQCI=
见上题.
5 远控工具中收藏的远程 ID¶
答案
1807892422
在 /data/user/0/com.carriez.flutter_hbb/app_flutter 路径下 RustDesk_local.toml 文件中, 看到 fav 选项,推测为收藏记录:

6 远程控制该手机的手机型号¶
答案
google-Pixel
在 /data/user/0/com.carriez.flutter_hbb/app_flutter 路径下 1807892422.toml 文件中, 该文件名也对应收藏的远程 ID, 记录连接设备相关信息:

7 监听工具包名¶
答案
com.example.liekai
浏览手机安装的软件, 其中除了黄色软件, 仅剩一个陌生应用:

导出该应用, 进行分析(手快截图):

确认该程序为监听工具.
8 监听工具代码主入口¶
答案
com.example.liekai

9 监听工具的签名算法¶
答案
SHA256-RSA

10 监听工具运行多少秒后会跳转成黑色幕布¶
答案
1
查看源码, 知道该程序是 flutter 应用:

使用blutter: Flutter Mobile Application Reverse Engineering Tool进行逆向分析, asm/liekai 路径下为该项目核心的逻辑代码,我们对其进行分析。

在 screen 路径下存在 black_overlay.dart 文件, 推测该文件为黑色幕布相关内容. 在给 IDA 添加符号之后, 也印证该推测:

重点查看 BlackOverlayState 类中的信息, 在 initState 方法中, 可以看到如下内容:

首先设置一个时间持续对象 Duration Object, 然后再调用延迟方法 Future::Future.delayed, 设置持续时间. 根绝时间持续对象 Duration Object, 在 objs.txt 文件中定位 Obj!Duration@453341 对象:

因此监听工具运行 1 秒后会跳转成黑色幕布.
11 监听工具运行后, 黑色幕布上字符串¶
答案
pgs比武专用
根据 flutter 官网中的 Case, 如果想在幕布上显示字符, 需要首先创建一个 Widget 对象, 并在 Widget 对象中填充 Text 对象:

因此直接在 objs.txt 文件中搜索 Text@ 关键词, 便可获得如下内容:

进一步全项目检索 Obj!Center@44e251 对象, 引用于 black_overlay.dart 文件中, 上下文也符合页面组件的特征, 因此该内容为正确答案.

12 监听工具检测到多少分贝开始录音¶
答案
70
参考第 7 题.
根据文件名称, 监听相关服务应该在 SoundDetectorService 中. 查看 startMonitoring 方法:

该方法首先检查应用录音权限, 然后调用匿名方法:

匿名方法中, 首先设定监听阈值 d0 = 70 分贝, 然后获取当前分贝大小存储为 d1. 比较 d0 & d1, 如果 d0 < d1, 跳转到 0x362da0, 否则顺序执行, 调用 SoundDetectorService::_handleNoise 处理.
13 监听工具录音连续几秒没有检测到声音停止录音¶
答案
4
参考第 7 题.
在匿名方法里面, 首先设置除数 x0 = 1000000, 然后获取当前没有检测到声音的时间为 x1, 用 x1 / x0(参考第 10 题), 结果存储在 x2 中. 再将 x2 与 4 进行比较. 因此监听工具录音连续 4 秒没有检测到声音停止录音.

14 监听工具保存文件存储路径的数据库名称¶
答案
recordings.db
在 /data/user/0/com.example.liekai/databases 路径下存在 recording.db:

在 database_helper.dart 文件中 _initDatabase 方法中记录着数据库文件名称:


跟着调用链一路向上, [package:liekai/database_helper.dart] DatabaseHelper::_initDatabase -> [package:sqflite_common/sqflite.dart] ::getDatabasesPath -> [package:sqflite_platform_interface/src/factory_platform.dart] _SqfliteDatabaseFactoryImpl&Object&SqfliteDatabaseFactoryMixin::getDatabasesPath.
在 [package:sqflite_platform_interface/src/factory_platform.dart] _SqfliteDatabaseFactoryImpl&Object&SqfliteDatabaseFactoryMixin::getDatabasesPath 中, 又通过 safeInvokeMethod 调用getDatabasesPath function:


最终拼接路径为 data/data/<package_name>/databases + recordings.db, 即 /data/user/0/com.example.liekai/databases/recordings.db.
15 监听工具保存录像文件的文件夹¶
答案
/storage/emulated/0/Android/data/com.example.liekai/files/video
在 video_service.dart 文件中:

跟着调用链到 [package:liekai/database_helper.dart] DatabaseHelper::insertAudioRecording:

再到 [package:path_provider/path_provider.dart] ::getExternalStorageDirectory, 这里是通过 invokeMethod 调用.
getExternalStorageDirectory()是一个弃用的 API, 用于返回 /storage/emulated/0/Android/data/<PackageName>/files 路径.
16 监听工具数据库中保存音视频文件的路径使用什么加密¶
答案
Salsa20
在 video_service.dart 文件中 stopRecording 方法中, 分别对文件路径和文件进行了加密:

根据 [package:liekai/encryption_utils.dart] EncryptionUtils::encryptPath, 这里使用的是 Salsa20 加密算法.

17 录音的文件采用什么加密方式¶
答案
AES
查看 recording_service.dart 的 stopRecording 方法, 可以看到对录音文件加密使用的方法是 [package:liekai/encryption_utils.dart].

在 EncryptionUtils::encryptFile 中, 调用的是 AES:

进一步对其密钥以及 IV 进行分析. 追踪调用链, _audioKey 由 _audioKeyBytes 得到:

可以看到大量的赋值操作, 这里基本上没有其他的函数调用, 这里我们在 IDA 里面查看:

Key 一共是 32 次赋值, 但是这里每一次赋值都是一个 int 类型(4 Bytes), 但在之后每个 int 都被转换成 UInt8 类型(2 Bytes). 参考Aura's Blog的分析:

参考上述思路, EncryptionUtils 的 videoKeyBytes、videoIvBytes、pathKeyBytes、pathIvBytes、audioKeyBytes、audioIvBytes 中均有调用dart_typed_data___Uint8List__TypedList__IntListMixin__TypedIntListMixin::_slowSetRange_276ddc 方法,根据名字大致猜测该方法实现了 int 数组转为 Uint8 数组的功能。
但是无法从 asm 文件夹中发现该方法的实现, 推测可能是库函数, 但是也未找到相关实现, 尝试各种骚操作, 只能获得这样一个伪代码(也不一定对):
// 该函数是一个 Dart 语言中 Uint8List 类型的底层实现方法,主要用于处理字节数组(Uint8Array)的慢速范围设置操作。它的核心功能是在特定条件下对字节数组进行数据复制或填充操作。
static void _slowSetRange_276ddc(
const TypedData& target_list,
const Object& source_data,
intptr_t target_start,
intptr_t target_end,
intptr_t source_start,
intptr_t source_length,
intptr_t flags) {
const intptr_t target_length = target_end - target_start;
if (target_list.IsTypedData()) {
const TypedData& target_data = TypedData::Cast(target_list);
if (source_data.IsTypedData()) {
const TypedData& source_data_ = TypedData::Cast(source_data);
if (target_data.ElementType() == source_data_.ElementType()) {
if (target_length == 0) {
return;
}
if (!IsType_List_int(source_data)) {
if (target_length != (target_length << 1) >> 1) {
ThrowStubError(kInteger32ToInteger64ConversionError);
}
} else {
if (target_length != (target_length << 1) >> 1) {
ThrowStubError(kInteger32ToInteger64ConversionError);
}
}
const intptr_t array_length = target_length;
const Array& array = Array::Handle(Array::New(array_length, Heap::kOld));
intptr_t i = 0;
while (i < array_length) {
const intptr_t element =
source_data_.At(source_start + i);
if (!array.SetAt(i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
i = 0;
while (i < array_length) {
const intptr_t element = array.At(i);
if (!target_data.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
} else {
intptr_t i = 0;
while (i < target_length) {
const intptr_t element = source_data.At(source_start + i);
if (!target_data.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
}
} else {
intptr_t i = 0;
while (i < target_length) {
const intptr_t element = source_data.At(source_start + i);
if (!target_data.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
}
} else {
if (!target_list.IsArray()) {
ThrowStubError(kUnsupported);
}
const Array& target_array = Array::Cast(target_list);
if (source_data.IsTypedData()) {
const TypedData& source_data_ = TypedData::Cast(source_data);
if (target_array.IsTypeFixedLength()) {
if (target_length != 0) {
if (target_length != (target_length << 1) >> 1) {
ThrowStubError(kInteger32ToInteger64ConversionError);
}
}
const intptr_t array_length = target_length;
const Array& array = Array::Handle(Array::New(array_length, Heap::kOld));
intptr_t i = 0;
while (i < array_length) {
const intptr_t element =
source_data_.At(source_start + i);
if (!array.SetAt(i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
i = 0;
while (i < array_length) {
const intptr_t element = array.At(i);
if (!target_array.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
} else {
intptr_t i = 0;
while (i < target_length) {
const intptr_t element = source_data.At(source_start + i);
if (!target_array.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
}
} else {
if (target_array.IsTypeFixedLength()) {
if (target_length != 0) {
if (target_length != (target_length << 1) >> 1) {
ThrowStubError(kInteger32ToInteger64ConversionError);
}
}
const intptr_t array_length = target_length;
const Array& array = Array::Handle(Array::New(array_length, Heap::kOld));
intptr_t i = 0;
while (i < array_length) {
const intptr_t element = source_data.At(source_start + i);
if (!array.SetAt(i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
i = 0;
while (i < array_length) {
const intptr_t element = array.At(i);
if (!target_array.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
} else {
intptr_t i = 0;
while (i < target_length) {
const intptr_t element = source_data.At(source_start + i);
if (!target_array.SetAt(target_start + i, element)) {
ThrowStubError(kRangeError);
}
i++;
}
}
}
}
}
看不懂思密达... 所以这个题及以后的题理论上都是不可做的, 除了猜, 但是这又有什么意义呢?
info
如果这个逆向有逆明白的, 欢迎来讨论! —— BlackBird
18 录像文件加密秘钥的最后一位是¶
19 原始文件 MD5 为 3b4d****55ae 的创建时间是¶
贾韦码的 Windows 计算机¶
参考检材 贾韦码\计算机\pc-disk.E01.
1 计算机系统 Build 版本为¶
答案
18362.356

2 计算机最后一次正常关机的时间(UTC+0)¶
答案
2025-04-18 11:20:54

3 计算机网卡的 MAC 地址¶
答案
00-0C-29-0F-69-00

4 计算机用户"贾韦码"的 SID¶
答案
S-1-5-21-3733482367-3411043098-2536183883-1001

5 计算机默认浏览器¶
答案
Google Chrome

6 计算机默认浏览器版本¶
答案
135.0.7049.96

7 机主通过浏览器搜索国外社交软件¶
答案
Telegram

8 机主的邮箱账号¶
答案
tqmdavidjohnson300@gmail.com

9 计算机装过一款反取证软件¶
答案
VeraCrypt

10 计算机通过 Xshell 远程连接的 ip 地址¶
答案
192.168.56.129

11 机主曾买过一个美国的 Telegram 账号, 请给该账号的原两步验证密码¶
答案
13770603
在邮箱中:

12 给出其电脑内加密容器的解密密码¶
答案
Pgs8521d3j
加密容器可以通过火眼的"特征分析"插件来识别到.

或者在用户目录中查看所有文件, 过滤文件并按照文件大小降序排序可以看到一个大小正好为 4 GB 的文件:

Note
顺便还能看到 Windows 虚拟机的虚拟硬盘文件和 UnraidOS 的文件. 以及 X-Ways Forensics/WinHex 生成的缓存文件:

看了一下 7Zip 的缓存, 确定是 WinHex 了, 曾经打开了桌面上的一个叫"3pgscup.rar"的文件和某个磁盘文件并生成了簇缓存和目录结构.
接下来导出 VC 容器并根据案情中的提示"Pgs+4位数字+d3j"进行掩码爆破.
首先导出 VC 容器的 Hash(容器的前 512 Bytes):
再用 HashCat 爆破, VeraCrypt 的默认加密方式是 SHA-512 + XTS-512. 只花不到 30 秒就爆出来了:

Passware 也能爆, 但表现实在不咋样.
13 给出其电脑内加密容器挂载的盘符¶
答案
F
在最近访问的路径中可以看到访问了 F 盘符, 可根据访问的文件存在于 vc 容器中判断是该盘符.

14 给出其电脑内存放了多少张伪造身份证¶
答案
1023
容器里面存储的全是假的, 有效期全部都不正常. 文件名从 1 到 1023.

15 找出任敏的身份证编号¶
答案
430529195112085460
在 id_cards_info.xlsx 文件中可以看到任敏的身份证号:

16 找出其电脑内存放的密钥文件, 计算其 MD5¶
答案
1022cc083a4a5a9e2036065e2822c48e
结合之后 UnraidOS 服务器部分的题目, 这里要找的应该是 luks 的解密密钥.
但是并没有找到文件名为 keyfile 的文件. 不过在前面提到了, WinHex 打开了一个压缩文件 pgscup.zip, 并且读取了磁盘. 在最近访问的文件中看到了比较有意思的情况:

感觉像是用 WinRAR 打开了 pgscup.zip 之后又创建了一个压缩文件 3pgscup.rar. 在这一过程中还用到了 WinHex, 猜测是直接用 WinHex 将压缩包或其中的文件写在了磁盘中的某个位置.
用 X-Ways Forensics 打开镜像, 发现磁盘在最后的几个簇恰巧存在一个大小为 1 MB 的空闲空间, 其中存储了一个 RAR 文件:

能看出来存储的文件名为 keyfile.keyfile 且压缩级别为 Store, 文件内容为 Base64: emZz5Yqg5a+GcG9vbOWvhumSpeaWh+S7tg==, 解码之后是 zfs加密pool密钥文件.
恢复出来的 rar 压缩包大小为 115 Bytes, 与先前提到的 3pgscup.rar 大小一致:

如果使用 Zip Store 来压缩解压出的 keyfile.keyfile 文件, 会发现其大小恰为 200 Bytes. 与先前提及的 pgscup.zip 的大小一致:

这也印证了之前对 WinRAR 和 WinHex 使用过程的猜测.
对解压后的密钥文件计算 MD5:

17 找出其电脑内存放的密钥文件, 解密此密钥文件, 给出其内容¶
答案
zfs加密pool密钥文件
见上题.
另外, 纠正一点: Base64 是编码, 不是加密.
二进制分析(PE)¶
Note
对于做题来说, 部分题目使用动态分析结合查看源码更快.
这里稍微多写一些逆向的方法, 毕竟在离线环境下, 既无法使用在线沙箱也无法在 GitHub 寻找源码.
另外: 我不是逆向手, 所以有些地方可能会出错.
这道题有混淆, 可以通过 de4dot 去混淆.
然而 Noah 在复盘的时候根本不知道有这个工具, 直接对着一堆乱码硬做的.
参考检材 贾韦码\计算机\pc-disk.E01.
惯例启动火绒:

找到了原始病毒文件 pc-disk.E01\分区7\Users\贾韦码\Documents\lupg\lupg.exe, 还找到了疑似病毒释放的文件 pc-disk.E01\分区7\Users\贾韦码\AppData\Roaming\SubDir\BwAcr.exe, 是个间谍木马.
DIE 查一下, 是个 .NET 程序:

dnSpy 加载, 看到入口点. 基类里面不知道是编码问题还是有混淆, 符号表全是乱码:

在资源里面看到了远控端的名称 Quaser.Client:

在 GitHub 可以搜到源码 quasar/Quasar: Remote Administration Tool for Windows.
在入口点看到:

跟进类, 类加载时处理:

接着跟进其中的自定义方法:


新线程用于记录键盘操作并输出到文件 %APPDATA%\\Logs\\.

跟进之后可以看到与字符串使用相同的加解密方法:

PC 检材上有键盘记录文件 pc-disk.E01/分区7/Users/贾韦码/AppData/Roaming/Logs/2025-04-18, 解密之后可以看到记录内容:


运行窗口中记录的键盘输入说明出题人在出题时访问了缓存目录 %tmp%.
此处获取控制端信息的代码分析参考第 1 题解析.

跟进其中加载模块的方法:

第 1 个模块用于连接控制和被控端卸载:

第 2 个模块用于文件管理:

第 3 个模块用于读取被控端日志.
第 4 个模块用于显示信息弹窗.
第 5 个模块用于获取本地程序保存的密码, 这部分分析参考第 4 题:

第 6 个模块用于读取 & 修改注册表:

第 7 个模块用于远控, 可以获取屏幕内容和控制键鼠.
第 8 个模块用于终端命令执行, 跟进几次后发现这里会直接获取一个 cmd 终端:

第 9 个模块用于反代连接(反弹 Shell).
第 10 个模块用于关机或重启.
第 11 个模块用于增/删/改开机启动项.
第 12 个模块用于获取系统信息, 这部分分析参考第 5 题.
第 13 个模块用于进程管理.
第 14 个模块用于建立/关闭网络连接.
第 15 个模块用于获取 http 网络资源.
1 控制端 IP 是¶
答案
104.18.45.79
大概查看一下类的导入包, 发现 020001D3 位置的类导入了 Socket 类, 并且有一个方法的参数是 List<Obj> hosts:

分析一下这个方法在哪里被使用了:

跟进, 这段代码用到了好几个字段:

跟进, 发现有好几个 Base64 编码的字符串:

尝试解码发现有加密. 在字符串所在的类中可以看到一段代码, 对所有字符串都用同一个方法进行了处理:

跟进这个方法, 发现其作用是将参数的 Base64 字符串解码, 并用另外一个方法进行处理, 再将方法返回的字节流以 UTF-8 编码编码并返回:

处理 Base64 编码内容的方法就在这个方法下面:

可以看到方法进行了解密操作. 参数如下:
aesCryptoServiceProvider.KeySize = 256;
aesCryptoServiceProvider.BlockSize = 128;
aesCryptoServiceProvider.Mode = CipherMode.CBC;
aesCryptoServiceProvider.Padding = PaddingMode.PKCS7;
aesCryptoServiceProvider.Key = ...;
// Token: 0x040005D4 RID: 1492
在解密方法上, 可以看到使用 MasterKey 生成 AES 密钥的方法. AES 密钥是通过 rfc2898DeriveBytes.GetBytes(32) 生成的:

seed 在类的最后:

再分析生成密钥的方法, 找到 MasterKey:


以源程序中的解密方法为基础, 稍作修改, 写一个 C# 的解密程序:
#pragma warning disable
using System.Security.Cryptography;
using System.Text;
namespace QuaserDecode
{
class Program
{
private static byte[]? RSAKey;
private static byte[]? HMACKey;
private static readonly byte[] seeds = new byte[]
{
191, 235, 30, 86, 251, 205, 151, 59, 178, 25,
2, 36, 48, 165, 120, 67, 0, 61, 86, 68,
210, 30, 98, 185, 212, 241, 128, 231, 230, 195,
57, 65
};
static void GenerateKey(string masterKey)
{
using (Rfc2898DeriveBytes pbkdf2 = new Rfc2898DeriveBytes(
masterKey,
seeds,
50000
))
{
RSAKey = pbkdf2.GetBytes(32);
HMACKey = pbkdf2.GetBytes(64);
}
}
static string Decrypt(string? encrypted)
{
byte[] array6;
using (MemoryStream memoryStream = new MemoryStream(Convert.FromBase64String(encrypted)))
{
using (AesCryptoServiceProvider aes = new AesCryptoServiceProvider())
{
aes.KeySize = 256;
aes.BlockSize = 128;
aes.Mode = CipherMode.CBC;
aes.Padding = PaddingMode.PKCS7;
aes.Key = RSAKey;
using (HMACSHA256 hmac = new HMACSHA256(HMACKey))
{
byte[] array = hmac.ComputeHash(memoryStream.ToArray(), 32, memoryStream.ToArray().Length - 32);
byte[] array2 = new byte[32];
memoryStream.Read(array2, 0, array2.Length);
}
byte[] array3 = new byte[16];
memoryStream.Read(array3, 0, 16);
aes.IV = array3;
using (CryptoStream cryptoStream = new CryptoStream(memoryStream, aes.CreateDecryptor(), CryptoStreamMode.Read))
{
byte[] array4 = new byte[memoryStream.Length - 16L + 1L];
byte[] array5 = new byte[cryptoStream.Read(array4, 0, array4.Length)];
Buffer.BlockCopy(array4, 0, array5, 0, array5.Length);
array6 = array5;
}
}
}
return Encoding.UTF8.GetString(array6);
}
static void Main(string[] args)
{
GenerateKey("85C93D66AAD8EE9E1BC3DEC15329110306011A06");
while (true)
{
Console.Write("> ");
string? input = Console.ReadLine();
try
{
Console.WriteLine(Decrypt(input));
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
}
}
}
}
对字符串解密之后得到的字符串:


其中包含 IP 地址, 是之前找到的通过 Socket 连接的方法中使用的字符串.
2 软件会复制自身到哪个文件夹下¶
答案
SubDir
火绒扫描到了 %APPDATA%\SubDir\BwAcr.exe 是病毒的复制. 在上题解密出来的字符串中也可以看到相关信息:

分析一下字符串的读取, 发现在方法(Token: 0x06000296)中对字符串进行了拼接, 且字符串开头为用户的 %APPDATA% 目录:


拼接的结果分别为 %APPDATA%\\Logs\\ 和 %APPDATA%\\SubDir\\BwAcr.exe.
如果想要更确切地分析, 可以分析拼接后的字符串的读取位置, 找到复制文件的方法. 这里不多赘述了, 最终找到的方法是:

以及将可执行文件设置为开机启动项的方法:

3 接上题, 复制后软件名称是¶
答案
BwAcr.exe
见上题.
4 软件一共可以窃取多少种浏览器的信息¶
答案
8
稍微翻一下基类, 看到含有 IE 浏览器名称的类:

分析一下实例化该类的位置, 看到在一个 List 中添加了若干个类的实例:

依次查看, 发现每个类都获取 1 种应用的信息:

依次是: Brave, Chrome, Opera, Opera GX, Microsoft Edge, Yandex, Firefox, Internet Explorer, FileZilla, WinSCP. 其中 FileZilla 和 WinSCP 并非浏览器, 而是文件传输工具.
5 软件查询安装的杀毒软件出错或异常会返回什么字符串¶
答案
Unknown
类(Token: 0x02000068)用于获取系统信息:

在获取的信息中有一项是防病毒软件信息(antivirus):

跟进之后看到获取方式是利用 .NET 提供的接口获取 Windows 的管理信息中对防病毒软件的记录. 如果存在, 将所有防病毒软件的名称拼接并返回; 不存在则返回"N/A"; 这个过程中出现异常则返回"Unknown".

钟无声的 MacOS 计算机¶
参考检材 钟无声\mac电脑\mac.E01.
18 登陆的电子邮件服务是谁提供的¶
答案
Outlook
这题还是有些模糊的, 不知道到底答案是 Microsoft 还是 OutLook.

19 系统备忘录的包名¶
答案
com.apple.Notes
20 图片中隐藏的内容¶
答案
位移加密 正向位移操作
虽然 LSB 是图片隐写的基本操作, 但我真的觉得不用工具就能看出来 LSB 的嫌疑人是真的牛逼. 这道题已经完全脱离了取证的逻辑了, 纯纯的 Misc 题.

隐写二维码在 R 的最低位. 识别出来内容是"位移加密 正向位移操作".
在 2.mp4 中插入了一张图片:

虽然到这里为止还有一个 mp3 文件包含的信息没有找到, 但也大概能猜出来解密的方法了. 应该是:
- 先将
pgs cup@2025的每一个字符的 ASCII 码相加模 26 的余数得到位移量 - 再对密文的每个字符在 ASCII 码可见字符范围内循环右移
def decrypt(ciphertext, key):
ascii_min = 32
ascii_max = 126
ascii_range = ascii_max - ascii_min
total = sum(ord(c) for c in key)
offset = (total % 26) + 1
plaintext = ''
for char in ciphertext:
if ascii_min <= ord(char) <= ascii_max:
decrypted_ord = (ord(char) - ascii_min + offset + ascii_range) % ascii_range + ascii_min
plaintext += chr(decrypted_ord)
else:
print(char)
plaintext += char
return plaintext
ciphertext = r"5W:bF3AS:0SSPYXbN32zFC=SP3TX53:bNYF\F3BaP3XTN3XzQ?.TF}6XFC{XNZ=a51XaMD>\ED>X53:bNZ>eEC:z54>XOY{\NY2zMC%a8U5"
key = "pgs cup@2025"
print(decrypt(ciphertext, key))
21 被加密文件的扩展名¶
答案
enc
在桌面上可以看到 1 个 MacOS 的软件包和 1 个被加密的文件:

22 被加密的文件总共有几个¶
答案
1
23 贾韦码家使用的智能门锁品牌型号¶
此题存疑
不同检材中的 2 个文件中出现了不同的型号:
- 金刚I号
- 金刚III号
在钟无声的 iPhone 手机的 Image.tar/private/var/mobile/Containers/Shared/AppGroup/9BE37656-12EF-4B83-9434-83C19E9A8858/telegram-data/temp/app/temp-713350a88c23151d/5/聊天话术V3.docx 文件中贾韦码家的门锁型号为"金刚I号":

但在 MacOS 中被加密的文件 贾韦码资料.rar.enc 解密后的文件中, 又提到另外一个型号"金刚III号":

二进制分析(MacOS 中的 Python 脚本打包)¶
参考检材 钟无声\mac电脑\mac.E01.
分析及文件解密¶
在软件包里可以看到软件是个打包的 Python 脚本:

在 zsh 的历史记录中可以看到打包的过程:

gui_wrapper.py 是软件的主入口, 其中存在缺失的导入:

这个包在 \文件加密器.app\Contents\Resources\lib\python38.zip 中, 是个 pyc 文件. 解压之后用工具把字节码还原成 py 源码, uncompyle6 和 decompyle3 均在还原 encrypt_fileParse 函数时出现了错误:

pylingual 可以正常还原, 加密部分核心代码:
class EncryptionTool:
def __init__(self):
self._generate_key()
def _generate_key(self):
seed_values = [(19, 7, 83), (5, 31, 69), (13, 11, 86), (41, 3, 76), (2, 57, 55), (23, 5, 96), (17, 13, 58), (29, 7, 94), (11, 19, 102), (7, 17, 42), (43, 3, 48), (37, 11, 51), (3, 43, 52), (59, 7, 53), (47, 5, 54)]
key_parts = []
for a, b, base in seed_values:
val = (a * b % 60 + base) % 256
if val % 2 == 0:
val = (val + 13) % 256
else:
val = (val + 7) % 256
key_parts.append(chr(val))
scrambled = []
indices = [3, 7, 2, 12, 0, 11, 5, 14, 9, 1, 6, 4, 10, 8, 13]
for idx in indices:
scrambled.append(key_parts[idx])
raw_key = ''.join(scrambled)
timestamp = int(time.time()) % 1000
random_val = random.randint(1, 255)
entropy = chr(timestamp % 256) + chr(random_val)
temp_key = hashlib.sha256((raw_key + entropy).encode()).digest()
self._descramble_key(temp_key)
def _descramble_key(self, temp_key):
mixed_base = b''.join([bytes([b ^ 42]) for b in temp_key[:10]])
actual_key = 'SecureKey123456'
self.enhanced_key = self.enhance_key(actual_key)
def enhance_key(self, key):
round1 = self._add_salt(key)
round2 = self._ascii_transform(round1)
round3 = self._xor_transform(round2)
round4 = round3[::-1]
final_key = hashlib.md5(round4.encode()).digest()
return final_key
def _add_salt(self, key):
salt_components = ['salt', '_', 'value']
return key + ''.join(salt_components)
def _ascii_transform(self, text):
result = ''
for i in range(len(text)):
ascii_val = ord(text[i])
if i % 3 == 0:
result += chr((ascii_val + 7) % 256)
elif i % 3 == 1:
result += chr((ascii_val ^ 15) % 256)
else:
result += chr(ascii_val * 5 % 256)
return result
def _xor_transform(self, text):
xor_keys = ['XorKey123456789', 'AnotherKey987654']
result = text
for xor_key in xor_keys:
temp = ''
for i in range(len(result)):
temp += chr(ord(result[i]) ^ ord(xor_key[i % len(xor_key)]))
result = temp
return result
def pad_data(self, data):
...
def unpad_data(self, data):
...
def encrypt_file(self, input_file, output_file=None):
if not output_file:
output_file = input_file + '.enc'
try:
cipher = AES.new(self.enhanced_key, AES.MODE_CBC)
iv = cipher.iv
with open(input_file, 'rb') as f:
file_data = f.read()
padded_data = self.pad_data(file_data)
encrypted_data = cipher.encrypt(padded_data)
with open(output_file, 'wb') as f:
f.write(iv + encrypted_data)
return (True, output_file)
except Exception as e:
return (False, str(e))
加密部分代码就是 AES 加密, 不过进行了很长一串对密钥的预处理. _generate_key 函数里面生成了一个随机的临时密钥, 但在 _descramble_key 函数中, 并没有使用之前生成的随机密钥, 而是直接使用了字符串 SecureKey123456 作为密钥, 传递给 enhance_key. 接着对密钥进行了几轮的操作, 包括加盐(固定值 salt_value) & 置换 & 异或, 所有操作都是固定的, 因此最终会生成一个固定的密钥. IV 存储在加密文件的头部.
可以直接导入类后访问 enhanced_key 拿到密钥之后, 去 CyberChef 里解密:



也可以直接在类里加一个解密函数, 只需要对加密函数微调即可:
def decrypt_file(self, input_file, output_file=None):
if not output_file:
output_file = input_file[:-4]
with open(input_file, 'rb') as f:
iv = f.read(16)
encrypted_data = f.read()
cipher = AES.new(self.enhanced_key, AES.MODE_CBC, iv)
decrypted_data = self.unpad_data(cipher.decrypt(encrypted_data))
with open(output_file, 'wb') as f:
f.write(decrypted_data)
解密之后的压缩包中有 2 个文件:

图片文件是智能冰箱检材中出现的照片之一, word 文档是贾韦码的自传(?):

1 使用了一个特定的数作为密钥生成的种子, 请问这个数是什么¶
此题存疑
不知道出题人在问什么
没看懂题目什么意思. 生成密钥的种子是列表 seed_values, 在之后处理 seed 生成临时密钥的时候用到了一堆数字, 不知道在问什么. 大模型也这么觉得:

非要按照答案格式里面写 4 位数的话, 那就是 1000. 但这多少有点扯了.
2 分析文件头部元素并确定它们的正确顺序¶
答案
iv + encrypted_data

3 密钥派生过程中使用了几个算法步骤. 其中一个函数使用了与其实际功能不符的名称¶
答案
_descramble_key
见上述分析.
4 程序中实现了一个故意减慢加密过程的机制, 延迟值是多少¶
此题存疑
在 Python 代码中没有看到延迟机制.
5 程序中隐藏了一个版本标识符, 请找出版本号¶
此题存疑
v3.8.10
那版本标识符可太多了. Python 就有个 v3.8.10,
贾韦码的 Linux 服务器(Unraid OS)¶
参考检材 贾韦码\服务器\server1.001 及 贾韦码\服务器\server2.001.
文件系统解析¶
在 X-Ways Forensics 中加载第一个镜像 server1.001 可以看到这是个系统盘, 其中安装了 UnraidOS. 在分区 3(卷标为"UNRAID")中可以看到系统版本信息:

第二个镜像 server2.001 是有 LUKS 加密的 ZFS 文件系统, 可以在 UFS 中进行解密. 需要用到"贾韦码的 Windows 计算机"部分的第 16 题"找出其电脑内存放的密钥文件, 计算其 MD5"中找到的 keyfile.keyfile 中的 Base64 字符串 emZz5Yqg5a+GcG9vbOWvhumSpeaWh+S7tg== 进行解密.
在 UFS 中挂载镜像, 可以看到识别到了 LUKS 加密, 点击解密:

选择使用 LUKS 元数据解密:

输入 Base64 串作为密码之后解密:

仿真¶
这系统是有点奇特的, 有点类似于 Windows CE 系统, 专门为了给便携启动媒介设计的. 在官网Unraid | Getting Started with Unraid中明确提及了, 需要将系统写入可移动 USB 设备中.
VMware Workstation 的图形化界面并没有提供将虚拟硬盘作为 USB 设备挂载的方法, 但可以通过直接修改 VMX 文件来实现, 参考: Create a Virtual USB Drive in VMware Workstation.
使用火眼仿真软件将服务器的 2 个镜像仿真. 先不要启动虚拟机, 打开虚拟机目录, 用任意文本编辑器打开生成的 VMX 文件.
首先确保 VMX 中有以下 2 行:
在火眼生成的 VMX 文件中, 缺少 ehci.pciSlotNumber, 查看其他的 pciSlotNumber, 为 ehci.pciSlotNumber 设置一个不同的插槽编号.
接着在 VMX 中添加以下内容:
ehci:0.present = "TRUE"
ehci:0.deviceType = "disk"
ehci:0.fileName = "disk0-000002.vmdk"
ehci:0.readonly = "FALSE"
其中的 disk0-000002.vmdk 是火眼生成的系统盘虚拟硬盘.
还要将原本的 IDE 硬盘配置删除:
接着启动虚拟机, 如果出现无法识别到引导分区的情况的话, 在刚开启虚拟机后按下 Delete 键, 手动选择 EFI USB Device.
至此已经可以正常启动系统了:

以下是手动解密 LUKS 分区的操作. UnRAID 的 WebUI 有自动解密功能, 所以不需要手动执行.
查看硬盘信息,
sdb1为 LUKS 加密分区:
cryptsetup luksDump查看分区的加密信息:
创建新的
VMDK, 用来存放keyfile和其它文件. 挂载新的虚拟硬盘并重启虚拟机之后, 先挂载新的分区:用
keyfile进行解密:
使用 ip addr add <VMware NAT IP Addr> dev br0 来为服务器添加 IP 地址. 不知道 VMware 虚拟网卡的 IP 地址的情况下, 可以在虚拟机中运行 dhcpd 指令, 再查看 ip addr, 其中 eth0 的 IP 地址会被自动修改.
可以访问 WebUI 了:

但 Unraid 保存了先前的硬盘识别符, 导致硬盘无法读取. 需要先去清除硬盘配置:

重新配置硬盘:

上传密钥文件:

接着点击启动, 稍等之后可以看到硬盘上线:

在 Docker 选项卡可以看到正在运行的 Docker 容器:

在共享选项卡可以看到服务器目前通过 SMB 共享的目录:

另外, 服务器的 sshd 是正常工作的, 可以直接连接.
1 找出服务器系统启动盘的 GUID¶
答案
223DCB83-82B0-4C62-864A-DB28D84735B8
在分区 3 的 /config/go (sh 脚本)中:

2 找出服务器网关 IP¶
答案
192.168.56.128
在分区 3 的 /config/network.cfg 中:

结合 PC 中的浏览器记录, 这个路由器刷入了 OpenWRT 系统.
3 找出服务器数据盘的文件系统格式¶
答案
zfs
在分区 3 的 /config/disk.cfg 中可以看到采取的是 luks 加密的 zfs 文件系统:

在分区 3 的 /logs/tower-diagnostics-20250416-0148.zip 中可以看到系统日志, 其中的 /system/zfs-info.txt 也保存了 zfs 的挂载信息:

4 找出服务器数据盘的解密密钥文件名¶
答案
keyfile
见上题.
5 找出服务器密码¶
答案
P@ssw0rd
在分区 3 的 /config/shadow 中找到密码的哈希:

没有其它关于密码的信息, 应该是弱口令爆破, 找个弱口令字典.
.\hashcat.exe -m 1800 -a 0 '$6$xeMY8O4VOHMbbNqA$XEUOl1pPRwgud/rKwDbIeH9f7khOTmuUQwND3P9T35p14j9I0/Et4R9KdvwhW4AD1xKMve6K7Fwrab4MuPCPp.' .\dict\password-attacks\top19576.txt

6 找出服务器版本号¶
答案
7.0.1
在分区 3 的 changes.Txt 中可以看到系统版本信息:

在分区 3 的 /logs/tower-diagnostics-20250416-0148.zip 中的 /system/vars.txt 也可以看到系统版本:

7 找出服务器内 Docker 虚拟硬盘位置¶
答案
/mnt/disk1/docker.img
在分区 3 的 /config/docker.cfg 中可以看到 Docker 配置信息:

8 找出服务器启动盘的启动标识¶
答案
U210
在分区 3 的 /logs/tower-diagnostics-20250416-0148.zip 中的 /system/vars.txt 可以看到有两块盘:



sda (usb 设备)的硬盘大小为 7864288 Bytes ≈ 7.5 GB, 与检材中的系统盘的大小一致. sdb (VMware 虚拟硬盘)则与数据盘的大小一致.
sda 设备的 id 值为 U210.
9 找出服务器内共有多少个容器镜像¶
答案
9
本题及之后的题目需要解密 LUKS 或者仿真服务器之后才能做.
在解密后的分区的根目录中看到 docker.img:

将其作为硬盘镜像挂载, 并找到 BTRFS 的根目录位置:


在 /$//image/btrfs/imagedb/content/sha256 目录下看到 Docker 镜像的信息:

共有 9 个镜像.
仿真之后:

10 找出服务器内网站服务器所用数据库运行的容器名¶
答案
www-db-1
在 /$//containers 目录下可以看到一共有 5 个容器:

依次查看每个目录中的 config.v2.json 文件, 发现容器 182b0d619b1a09ea37e1d20d6c0776be561aaf0a5b187beb96eef11763254905 是 MySQL 数据库容器:


并且可以看到容器的名称:

这道题的答案也可以从 Unraid 的系统日志中推断出来. 在分区 3 的 /logs/tower-diagnostics-20250416-0148.zip 中的 /logs/syslog.txt 可以搜索 rc.docker 也可以看到 www-db-1 的容器名:

仿真之后, 在 WebUI 的容器选项卡中可以看到全部的容器, 可以点击"全部启动".


11 找出服务器内虚拟币容器对外暴露的端口号¶
答案
22556
在容器 9f6a38d4d773f3f790b50f87fdb225a871fed6b99d5cbc176fbd49ddbe2de6f9 目录的 config.v2.json 文件中可以看到是狗狗币的容器:

在 hostconfig.json 看到端口映射:

12 找出投资理财网站的域名¶
答案
panguite.cup
在容器 feec46efa56655d3a5f824bf9119042a4067eaf861f83dcd99993af659672fdc 目录的 config.v2.json 文件中可以看到是 Apache 服务器:

并且有一个文件挂载点:

ZFS 卷的 /www 目录是容器的文件挂载点, 是个 ThinkPHP 框架的网站:

在 /$//volumes/www_mysql_data/_data 目录下找到 MySQL 容器的数据:

导出之后本地起一个 MySQL 服务连上之后进行数据库分析, 密码在 MySQL 容器的配置文件中可以看到. 这里我偷懒, 直接用了火眼的数据库取证工具. 在表 info 中可以看到 APP 的域名:

在 PC 的浏览器历史记录中也可以看到这个网站域名.
服务器仿真之后, Docker 启动之后, 可以访问 8080 端口:

13 找出投资理财网站内连接数据库的密码¶
答案
www_dkewl_com
在网站目录 /$/www/Public 中:

14 找出投资理财网站后台访问地址¶
答案
http://2025.pgscup.com:8080/www9nwcc/login.html
在 APP 的日志 /$/www/App/Runtime/Logs/Index/25_04_17.log 中可以看到访问后台并登录的记录:

访问的 URI 是 /www9nwcc/login.html. 在 Apache 的日志 /$//containers/feec46efa56655d3a5f824bf9119042a4067eaf861f83dcd99993af659672fdc/feec46efa56655d3a5f824bf9119042a4067eaf861f83dcd99993af659672fdc-json.log 中可以看到登录后台的记录:

服务器仿真之后访问该 URI:

查看数据库, 密码是个 MD5 哈希:

先假设它只是最简单的 md5(pwd), 试着修改一下数据:

登录成功:

但是这个网站后台的加载速度慢到令人发指. 不如直接去看数据库.
15 找出投资理财网站会员等级设置存放在那个数据库表内¶
答案
user_member

16 找出投资理财网站提现成功的金额¶
答案
5769477
数据库中提现记录的表是空的.

在 /$/www/mysql_data 目录下可以看到几个 sql 文件, 其中的 dkewl.sql 是数据库的备份文件. 将 sql 文件导入到正在运行的 mysql 数据库中. 这里我还是用了火眼的数据库取证工具:

里面虽然有银行卡信息, 但是没有提现记录. 看了一下另外几个 sql 文件, 除了 generate_10k_records.sql 之外应该都是出题的时候留下的. generate_10k_records.sql 里面保存的是提现记录, 再次导入.
可以看到字段的备注:

计算一下状态为"已提现"的金额总和:

17 找出投资理财网站内用户王欣的银行卡号¶
答案
2114313505182218

18 找出投资理财网站用户的最低提现金额¶
答案
100
在表 info 中:

19 给出存放投资理财用户表内 clock 为 0 表示用户处于那种状态¶
答案
未锁定
在表 user 中:

20 找出投资理财网站内通过支付宝支付充值状态为未支付的金额¶
答案
11642201
字段 status 值为 0 的订单为未充值:

查询:

贾韦码的 Windows 服务器(虚拟机)¶
参考检材 贾韦码\计算机\pc-disk.E01.
贾韦码的 Windows 计算机检材中有一个 Windows 虚拟机.

可以导出虚拟机文件之后, 通过 vmx2john 和 hashcat 爆破虚拟机密码. 但这有点太慢了, 火眼现在已经支持将 vmx 文件作为检材导入了, 可以直接将 vmx 文件作为检材导入火眼, 接着进行仿真, 从而绕过 vmx 的加密.
帐本系统位于 Windows 10 x64.vmdk/新加卷/crm (D:\crm) 目录下.
21 账本系统使用的 web 框架是什么¶
答案
vue
在前端文件目录中的 node_modules 中看到 vue 的文件:

且前端文件目录中有 vue 的配置文件:

22 账本系统使用的数据库版本是多少¶
答案
5.0.3
在后端的 server.js 中看到使用的是 MongoDB:

MongoDB 的配置信息在环境变量中, 环境变量自动加载自 .env 文件:

MongoDB 的安装目录在 Windows 10 x64.vmdk/分区6/Program Files/MongoDB/Server/5.0. 查看 /bin/mongo.exe 的文件信息:

23 账本系统使用的数据库名称是¶
答案
crm
在上题的 .env 文件中的 MONGODB_URI 中, URL 的最后一部分就是使用的数据库名称.
可以仿真之后用数据库管理工具链接, 或者直接用火眼的数据库取证工具分析数据库文件.
24 用户手机号码在数据库中的加密方法是¶
答案
aes-128-cbc
在数据库的 customers 表中可以看到地址 & 身份证号 & 电话号码都被加密, 在数据库中存储为 Base64 字符串:

在后端的 models 目录中的 Customers.js 中存储着用户的数据模型. 使用了 utils/crypto.js 中提供的加密函数, 并且在几个属性设置时对属性值自动调用:

打开 crypto.js 后发现对方法调用进行了混淆, 使用JavaScript Deobfuscator去一下混淆, 手动还原一下变量名称, 再去掉两处控制流混淆:
const ALGORITHM = "aes-128-cbc";
const DEFAULT_IV = Buffer.alloc(16, 0);
const cipher = () => {
try {
const AESKey = keyManager.getAesKey();
return { k: AESKey, a: "aes-128-cbc", i: Buffer.alloc(16, 0) };
} catch (error) {
console.error("获取加密密钥失败:", error.message);
return { k: crypto.randomBytes(16), a: ALGORITHM, i: DEFAULT_IV };
}
};
const encrypt = (() =>
let AESCipher;
return text => {
if (!text) return "";
try {
if (!AESCipher) AESCipher = cipher();
const AES = crypto.createCipheriv(AESCipher.a, AESCipher.k, AESCipher.i);
let text_enc;
if (text.length > 0) {
text_enc = Buffer.concat([AES.update(text.toString(), "utf8"), AES.final()]);
} else {
return "";
}
return text_enc.toString("base64");
} catch (_0x5e3d) {
console.error("加密处理异常");
return "";
}
};
})();
25 分析 crypto.js 中的 _0x3ad7 函数, 找出返回加密数据的编码格式¶
答案
utf-8
见上题.
26 分析 crypto.js 中的 _0x3ad7 函数, 找出使用异常作为控制流的触发语句¶
答案
continue
代码中有一处用于控制流混淆的 try{}catch{} 语句, 实际执行的是 catch 中的代码.
try {
throw new Error("continue");
} catch (_0x1c93) {
if (_0x1c93.message !== "continue") return "";
// [snip]
}
27 分析 keyManager.js 中 initializeKeys 函数的密钥获取优先级是什么¶
答案
AES_SECRET_KEY = JWT_SECRET > MASTER_PASSWORD
在 initializeKeys 可以看到以下代码:
if (process.env.AES_SECRET_KEY && process.env.JWT_SECRET) {
return {
aesKey: Buffer.from(process.env.AES_SECRET_KEY, 'utf8'),
jwtKey: process.env.JWT_SECRET
};
}
const masterPassword = process.env.MASTER_PASSWORD;
if (!masterPassword) {
console.error('警告: 未提供主密码或加密密钥。使用随机生成的一次性密钥(不适用于生产环境)');
return {
aesKey: crypto.randomBytes(KEY_DERIVATION_KEY_LENGTH),
jwtKey: crypto.randomBytes(JWT_KEY_LENGTH).toString('hex')
};
}
const salt = getMachineSalt();
if (fs.existsSync(KEY_STORAGE_FILE)) {
try {
const fileKey = deriveKey(masterPassword, salt, 32);
const encryptedKeys = JSON.parse(fs.readFileSync(KEY_STORAGE_FILE, 'utf8'));
const decryptedData = decryptWithKey(encryptedKeys, fileKey);
const keys = JSON.parse(decryptedData);
return {
aesKey: Buffer.from(keys.aesKey, 'hex'),
jwtKey: keys.jwtKey
};
} catch (err) {
console.error('从文件加载密钥出错:', err.message);
}
// snip
}
- 先获取环境变量中的
AES_SECRET_KEY和JWT_SECRET- 如果有, 返回这两个密钥
- 否则, 在环境变量中查找
MASTER_PASSWORD- 如果有, 通过
MASTER_PASSWORD生成 AES 和 JWT 密钥 - 否则随机生成临时的 AES 和 JWT 密钥
- 如果有, 通过
28 账本记录的用户总数是多少¶
答案
2000
在数据库取证工具中导出 crm 数据库的 customers 表为 JSON 文件. 在 Python 中读取, 并进行统计:

29 身份证号"430014197812200986"用户的投资金额是多少¶
此题存疑
尝试对数据库中的加密数据进行解密(AES-128-CBC, Key=QxK8s4PjZ9LmRvT6BnE3F5, IV=b"\x00"*16), 发现无法正常解密.
离谱的是, 在 Customer 的 Model 里面, 在获取用户信息的时候, 直接返回了随机数据, 导致重组的服务器中的数据全为随机数据:

我不懂这道题想让我干什么. 这个后端根本就是依托.
30 姓名为明凤英的客户共有几人¶
答案
2

数据分析¶
参考检材 贾韦码\计算机\pc-disk.E01.
这部分的检材文件在贾韦码的 Windows 计算机里面, 叫做"三张表"的文件夹中有 3 个 csv 文件.
对于 csv 文件, 如果熟悉 Excel 的公式的话, 可以比较快地用 VLOOKUP & XLOOKUP 等公式完成. 但我不是很熟悉, 所以选择将这些 csv 文件导入到数据库里, 用 SQL 语句进行查询.
这部分一小半的题目描述不清, 导致根本无法确定唯一的答案. 不知道出题人是怎么想的.
1 该诈骗集团的最高层领导者的 ID 和姓名¶
答案
M020038 杨俊
在 hierarchy.csv 中看到 level = 0 的是层级最高的:

在 members.csv 中查找 id = M020038 的用户:

2 找出最高领导的所有下线中提现总额最高的成员 ID¶
答案
M019024
将 csv 导入到数据库之后查询:
SELECT payer_id, SUM(amount) as sum
FROM transactions
WHERE type = 'withdrawal'
GROUP BY payer_id
ORDER BY sum DESC;

3 找出从直接下线获得平均佣金最高的成员 ID 及其平均佣金金额¶
答案
M029080 999.87
比较复杂, 写个 Python 脚本来处理:
# %%
import csv
from collections import defaultdict
def read_csv_to_dict_list(file_path):
with open(file_path, mode='r', encoding='utf-8-sig') as f:
return list(csv.DictReader(f))
# %%
hierarchy = read_csv_to_dict_list('hierarchy.csv')
transactions = read_csv_to_dict_list('transactions.csv')
upline_dict = {row['member_id']: row['upline_id'] for row in hierarchy if row['upline_id']}
commission_transactions = [
tx for tx in transactions if tx['type'] == 'commission'
]
# %%
upline_commissions = defaultdict(list)
for tx in commission_transactions:
payer = tx['payer_id']
payee = tx['payee_id']
amount = float(tx['amount'])
if payee in upline_dict and upline_dict[payee] == payer:
upline_commissions[payee].append(amount)
# %%
upline_avg_commission = {
member_id: sum(values) / len(values)
for member_id, values in upline_commissions.items()
}
这是一个 IPython NoteBook (ipynb)格式的文件, 建议使用 VS Code 搭配 ipynb 插件和 Data Wrangler 插件使用.

4 找出注册时间最早的前 10% 成员中,交易次数最少的 5 位成员的 id¶
此题存疑
M003135,M004208,M018368,M016076,M016119
这道题是有问题的. 在符合题目条件(注册时间最早的前 10% 成员)的成员中, 按照交易次数降序排列, 可以看到 4 人交易次数为 0, 19 人交易次数为 1:

只有按照注册时间顺序排列, 才能找出具体的 5 个成员.
代码如下:
# %%
import math
from datetime import datetime
members = read_csv_to_dict_list('members.csv')
# %%
for member in members:
member['registration_date'] = datetime.strptime(member['registration_date'], "%Y-%m-%d %H:%M:%S")
members_sorted = sorted(members, key=lambda m: m['registration_date'])
# %%
top_10_percent_count = max(1, math.ceil(len(members_sorted) * 0.10))
top_10_percent_members = members_sorted[:top_10_percent_count]
top_member_ids = set(m['member_id'] for m in top_10_percent_members)
# %%
transaction_count = defaultdict(int)
for tx in transactions:
transaction_count[tx['payer_id']] += 1
transaction_count[tx['payee_id']] += 1
# %%
member_tx_counts = [(member_id, transaction_count.get(member_id, 0)) for member_id in top_member_ids]
5 找出交易次数增长率最高的成员 ID 及其增长率¶
此题存疑
这道题没办法做, 因为题目中没有明确说明增长率的度量尺度. 是月平均增长率? 是季度平均增长率? 还是年平均增长率? 除非有一个人的增长率足够高, 高到无论如何统计都是最高的, 否则这道题根据对增长率的定义不同, 答案也完全不同.
按照半期增长率计算(前半期和后半期对比):

按照月平均增长率计算(忽略前导的交易次数为 0 的月份):

可见两种计算方式答案截然不同.
代码如下:
# %%
for tx in transactions:
tx['timestamp'] = datetime.strptime(tx['timestamp'], "%Y-%m-%d %H:%M:%S")
# %% [markdown]
# ## 统计半期增长率
# %%
sorted_transactions = sorted(transactions, key=lambda x: x['timestamp'])
mid_index = len(sorted_transactions) // 2
mid_time = sorted_transactions[mid_index]['timestamp']
early_counts = defaultdict(int)
late_counts = defaultdict(int)
for tx in transactions:
payer = tx['payer_id']
payee = tx['payee_id']
if tx['timestamp'] <= mid_time:
early_counts[payer] += 1
early_counts[payee] += 1
else:
late_counts[payer] += 1
late_counts[payee] += 1
# %%
growth_rates = {}
for member_id in set(early_counts.keys()).union(late_counts.keys()):
early = early_counts.get(member_id, 0)
late = late_counts.get(member_id, 0)
if early > 0:
growth = (late - early) / early
growth_rates[member_id] = growth
# %% [markdown]
# ## 统计月平均增长率
# %%
member_month_count = defaultdict(lambda: defaultdict(int)) # {member_id: {YYYY-MM: count}}
for tx in transactions:
month_str = tx['timestamp'].strftime("%Y-%m")
for member_id in [tx['payer_id'], tx['payee_id']]:
member_month_count[member_id][month_str] += 1
# %%
member_growth = {}
for member_id, month_counts in member_month_count.items():
sorted_months = sorted(month_counts.keys())
if len(sorted_months) < 2:
continue
first_month = sorted_months[0]
last_month = sorted_months[-1]
first_count = month_counts[first_month]
last_count = month_counts[last_month]
month_span = len(sorted_months) - 1
if first_count == 0 or month_span == 0:
continue
growth_rate = (last_count - first_count) / (first_count * month_span)
member_growth[member_id] = growth_rate
6 统计状态为 active, 90 天无交易, 历史交易额前 20% 的成员数¶
此题存疑
4718
这个 90 天也非常模糊. 到底是到什么时候为止的 90 天? 是到比赛当天为止的 90 天, 还是到平台最后一笔交易发生为止的 90 天?
我按照截止到比赛当天为止的 90 天统计的结果:

# %%
now = datetime.strptime("2025-05-10", "%Y-%m-%d")
active_members = {m['member_id'] for m in members if m['status'] == 'active'}
member_total_amount = defaultdict(float)
member_last_transaction = defaultdict(lambda: datetime.min)
for tx in transactions:
payer_id = tx['payer_id']
amount = float(tx['amount'])
timestamp = tx['timestamp']
member_total_amount[payer_id] += amount
if timestamp > member_last_transaction[payer_id]:
member_last_transaction[payer_id] = timestamp
all_amounts = sorted(member_total_amount.values(), reverse=True)
top_20_percent_index = int(len(all_amounts) * 0.2)
amount_threshold = all_amounts[top_20_percent_index - 1]
qualified_members = []
for member_id in active_members:
total_amount = member_total_amount.get(member_id, 0)
last_tx_time = member_last_transaction.get(member_id, datetime.min)
days_since_last_tx = (now - last_tx_time).days
if total_amount >= amount_threshold and days_since_last_tx > 90:
qualified_members.append(member_id)
7 找出有上线且直接下线最多的成员 ID 及下线数¶
答案
M009748 18

# %%
members_with_upline = {row['member_id'] for row in hierarchy if row['upline_id']}
upline_downline_count = defaultdict(int)
for row in hierarchy:
upline = row['upline_id']
if upline:
upline_downline_count[upline] += 1
filtered_downlines = {
upline: count for upline, count in upline_downline_count.items() if upline in members_with_upline
}
8 比较最早年份 Q1 与 Q4 注册成员的总交易额, 指出哪个更高及具体金额¶
此题存疑
Q1 9054361.47
再次看不懂题目, 这个句子结构到底是怎么划分的? 问的到底是 "最早年份的(Q1 or Q4)注册的会员在所有年份的交易" 还是 "最早年份的(Q1 or Q4)注册的会员在最早年份的交易"?
我按照所有年份的交易统计的:

# %%
years = [m['registration_date'].year for m in members]
earliest_year = min(years)
q1_members = set()
q4_members = set()
for m in members:
if m['registration_date'].year == earliest_year:
month = m['registration_date'].month
if month in (1, 2, 3):
q1_members.add(m['member_id'])
elif month in (10, 11, 12):
q4_members.add(m['member_id'])
q1_total = sum(float(tx['amount']) for tx in transactions if tx['payer_id'] in q1_members)
q4_total = sum(float(tx['amount']) for tx in transactions if tx['payer_id'] in q4_members)
9 找出成员地址中最常出现的省份, 并计算居住在该省份的所有成员的总提现金额¶
答案
江苏 2301065.13


from collections import Counter
province_counter = Counter()
member_province = {} # member_id -> province
for member in members:
address = member['address']
if '省' in address:
province = address.split('省')[0] + '省'
elif '市' in address:
province = address.split('市')[0] + '市'
else:
continue
province_counter[province] += 1
member_province[member['member_id']] = province
most_common_province, _ = province_counter.most_common(1)[0]
target_members = {mid for mid, prov in member_province.items() if prov == most_common_province}
total_withdrawal = sum(
float(tx['amount']) for tx in transactions
if tx['type'] == 'withdrawal' and tx['payer_id'] in target_members
)
10 计算最高层领导者的净资金流¶
答案
17443.99
净资金流指资金流入减去资金流出.
SELECT SUM(amount) FROM transactions WHERE `payer_id` = "M020038"
UNION SELECT SUM(amount) FROM transactions WHERE `payee_id` = "M020038";

末尾的超长小数部分应该是浮点数误差, 直接忽略掉, 20264.85 - 2820.86 = 17443.99.
物联网取证(冰箱)¶
参考检材 贾韦码\智能冰箱\SmartFridge.bin.
给的甚至不是个固件. 在看过检材中提及的冰箱的型号(Panasonic NR-E46CV1)的详细说明后, 可以确认这部分的检材是生编硬造出来的, 根本不是来自于真实设备的数据.
1 分析冰箱,请问智能冰箱的品牌¶
答案
Panasonic

2 请问智能冰箱的型号¶
答案
NR-E46CV1
接上题.
3 请找智能冰箱的 UUID¶
本题存疑
12345678-90AB-CDEF-1234-567890ABCDEF
只能靠猜. 只有 16 Bytes 不知道是什么. 但实际上用1 ~ F 来当作占位填充也完全合理.

不知道这种纯靠猜的题目有什么意义.
4 请问智能冰箱默认保存几张图片¶
本题存疑
5
检材中可以搜索到 5 个 face 字符串, 但实际上 face5 的位置并没有存储图片. 可以猜测是有 5 个存储位置(默认可以保存至多 5 张), 但实际上只保存了 4 张.
But. 在网上搜索"Panasonic NR-E46CV1"后可以看到松下的日文官网, 其中有对于这台冰箱的详细介绍:

该型号的冰箱搭载的摄像模组包含 2 个摄像头, 可以在冰箱门打开时从冰箱外部识别内部的食材, 并且用 AI 给出食谱建议和食品保质期提醒. 摄像头能拍摄到的实际画面是这样的:

明确提到了可以保存最新的 1 张照片和最近过去的 5 张照片, 合计 6 张照片. 并且, 在查看过配套 APP 的使用说明之后还会发现, 这些照片并不存在本地, 而是上传到松下的云服务器中.
所以可见, 这部分题目已经完全脱离实际了, 毫无现实意义.
5 请问冰箱中已存的第一张图片上的内容是什么¶
答案
盘古石杯贾韦码
用 foremost 分离出检材内的已知文件, 包括 4 张 jpg 格式的图片. 第 1 张是:

6 请问冰箱中已存的第二张图片的名称是什么¶
本题存疑
face2.jpg
可以看到在第 1 张图片之前有 face1 的字符串, 猜测 faceX 是作为图片文件的描述符存在的. 全局搜索 face 字符串, 找到 face2 的位置:

之后就全是猜测了. 标识为 face2, 又是 jpg 格式, 猜测原始文件名为 face2.jpg. 但, 为什么扩展名不能是 jpeg 呢? 或者干脆没有扩展名? 而且就算文件在存储内的标识符为 face2, 也不代表着原始文件名一定是 face2. 又是一道毫无意义的题目.
7 请找冰箱中隐藏的内容¶
答案
pangushicup
更是逆天. 我很想知道正常情况下, 怎么才能在冰箱的闪存芯片(也许)里写这么一段文字.
strings:

8 请找出冰箱中嫌疑人图片 MD5 值的后六位¶
答案
882564
第 3 张图片是钟无声的照片. 计算 MD5:

9 请找出冰箱最后一次开门时间¶
此题存疑
这道题猜都猜不到. 整个检材里就那么多信息, 之前的题目已经全部用完了. 唯一一个没用上的是图片文件的大小:

10 默认图片的存储限制大小是多少¶
此题存疑
100 KB
还是只能猜. 有两种不同的猜测.
第一种是, 每两个相邻的 face 之间的距离都为 102424 Bytes, 去除相对固定的大小为 24 Bytes 的头之后, 其余 102400 Bytes = 100 KB 的空间可用于存储图片文件.

第二种是, 从文件和字符串的存储来看, 这个文件采取小端序存储, 因此用于描述图片文件的大小的位置是 2 Bytes ~ 4 Bytes:

如果只有 2 Bytes, 那么图片文件的大小上限就是 0xffff Bytes ≈ 64 KB. 在 3 Bytes 的情况下, 大小上限为 0xffffff ≈ 16 MB. 在 4 Bytes 的情况下, 可以达到 0xffffffff ≈ 4096 MB.
但考虑到实际情况, 存储限制应该更大一些. 现实中, 该摄像头需要拍摄的是冰箱内部的照片, 并且需要能够清晰分辨冰箱内的物品, 因此图片的像素并不会低, 图片大小不可能限制在 100 KB. 不过这部分题目已经抛开事实不谈了, 纠结这些也没什么意义.
物联网取证(监控)¶
参考检材 贾韦码\监控\video.E01.
11 被修改的录像 MD5 前 5 位¶
答案
EA7BE
挂载之后会发现视频文件的时间非常混乱, 根据文件的修改时间和视频内左上角的时间, 可以判断出来, 视频内的时区与文件修改时间的时区差了 15 小时.
可以看到, video.E01/分区2/video/00/20250415/2300-2400/233549-00001-M.mp4 的修改时间是最晚的, 但视频内容却是最早的. 据此可以推断文件被篡改过.

如果在或火眼的自动分析中开启了视频逐帧分析, 会在生成的略缩图中看到时间发生了回跳:

计算 MD5:

小作文¶
首先是盘古祖传 UE 3A 大作. 往年倒还好, 要么游戏里只有案情, 要么有一些信息但检材里也有. 今年直接开局来个王炸, 电脑密码和 VC 容器口令爆破掩码塞到里面, 还是在 衣柜里的隐藏房间 里面? 不是, 哥们在玩 DnD 还是 CoC 还是密室逃脱啊, 隐藏密室都来了?
而且这个 **仙舟粗口** 小游戏还卡的一比, 我玩崩铁开高画质大世界好歹能稳定 60 帧吧, 盘古这个 3A 大作低画质只能 20 帧上下波动. 仔细一看, 原来用的是 Vulkan 和 OpenGL. 比赛的时候因为没看到 VC 口令掩码, 一直觉得 VC 容器用的是文件密钥 + 口令(白板里的提示).
其次是无比模糊的题目描述. 看一道题得先想半天, 这题问的是哪个检材的事. 题目分类还不如不分, 干脆把所有题目一次性显示出来得了. 不说详细到检材了, 你好歹手机把安卓和 iOS 分一下, 服务器好歹把 Linux 和 Windows 分一下吧?
举个典中典的题: "图片中隐藏的内容是什么", **龙门粗口** 电脑上几千张图片, 倒是给个范围啊? 既然有隐写, 那我问你, 嫌疑人是怎么读的信息啊? 检材里又没有工具, 嫌疑人直接肉眼从屏幕上识别 LSB 还顺便扫了个二维码是吧.
其次是 Unraid OS. 我的评价是: 放到 CTF 的 Misc 分类里面挺好的, 可以给 Misc 手充分的参与感. 然后边做边骂: 他呜呜伯的出题人真是个小可爱, 我真想一锤子爱死你.
一刻也没有为服务器找不到 Keyfile 而哀悼, 紧接着来到战场的是数据分析部分. 题目描述好似丁真同等的文化水平, 几道题根本不知道想问什么. 丢给大模型后, 大模型都得反问我一句到底要干啥. 中文描述看不懂想着看看英文吧, 结果更是重量级, 修饰语根本看不出来是怎么套的, 感觉比案情都复杂一点了.
最后一位那直接是魔幻主义选手. 松下的开发看了都得摸着后脑勺问句"なにこれ?". 这部分写 WP 的时候直接没忍住, 全写到题目解析里面了. 简单点说就是"抛开事实不谈", 在取证比赛里面给这检材, 属实有点幽默.
总结一下, 这次盘古的初赛的赛题水平比起前两年稳步提升, 接近登峰造极. 至于是哪方面提升就不好说了.
最后再回过头来看看盘古石比赛前官方的宣传文:
大赛亮点实战导向:改编真实案件,结合 刑事技术 实战侦查思路,通过虚实交织的 “意外死亡” 推演,嵌入多模态数据:监控录像(含深伪片段)、加密通信记录、生物特征(指纹/足迹)、社交网络痕迹、物联网设备日志**等。设置**多层干扰信息(如虚假时间戳、AI生成的伪造语音、篡改的元数据),完整的体现案件上下游全链路的打击思路。
- 改编真实案件: 基本没有案情
- 监控录像(含深伪片段): DeepFake 在哪儿呢?
- 加密通信记录: 原来是手动加密
- 社交网络痕迹: 指 Telegram 聊天记录
- 物联网设备日志: 你管这玩意儿叫日志?
- 多层干扰信息(如虚假时间戳, AI 生成的伪造语音, 篡改的元数据): 根本没有.
顺便 AIGC check 一下宣传文:


