2025年第三届盘古石杯电子取证竞赛初赛题解¶
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 压缩), 用任意工具解压即可看到原始文件:
可以看到, 这里引用了一个图片文件. 在笔记的数据目录中可以看到这个图片文件:
综合上述信息, 可以分析得知:
蜜语规则:pgs cup@2025
规则邮件中
1.mp3
2.mp4
3.png
可以猜测题目中说到的"第二个解密载体"是指 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++;
}
}
}
}
}
看不懂思密达... 所以这个题及以后的题理论上都是不可做的, 除了猜, 但是这又有什么意义呢?
如果这个逆向有逆明白的, 欢迎来讨论! —— 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):
dd if=.\dsf2wecasdcqwed12434 of=./vc_hash bs=512 count=1
再用 HashCat 爆破, VeraCrypt 的默认加密方式是 SHA-512 + XTS-512
. 只花不到 30 秒就爆出来了:
.\hashcat.exe -a 3 -w 1 -m 13721 -d 1 G:\CaseFile\PGS2025\FileOutput\vc_hash Pgs?d?d?d?dd3j
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)¶
对于做题来说, 部分题目使用动态分析结合查看源码更快. 这里稍微多写一些逆向的方法, 毕竟在离线环境下, 既无法使用在线沙箱也无法在 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
中插入了一张图片:
偏移计算:将总和与26取模后加1,生成1-26区间的固定位移量
虽然到这里为止还有一个 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 行:
ehci.present = "TRUE"
ehci.pciSlotNumber = "35"
在火眼生成的 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 硬盘配置删除:
ide0:0.filename = "disk0-000002.vmdk"
ide0:0.present = "TRUE"
接着启动虚拟机, 如果出现无法识别到引导分区的情况的话, 在刚开启虚拟机后按下 Delete
键, 手动选择 EFI USB Device
.
至此已经可以正常启动系统了:
以下是手动解密 LUKS 分区的操作. UnRAID 的 WebUI 有自动解密功能, 所以不需要手动执行.
查看硬盘信息,
sdb1
为 LUKS 加密分区:
cryptsetup luksDump
查看分区的加密信息:
创建新的
VMDK
, 用来存放keyfile
和其它文件. 挂载新的虚拟硬盘并重启虚拟机之后, 先挂载新的分区:mkdir /mnt/disk2 mount /dev/sdc /mnt/disk2
用
keyfile
进行解密:cryptsetup luksOpen /dev/sdb1 disk -d /mnt/disk2/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:
strings .\SmartFridge.bin > strings.txt
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 一下宣传文: