跳转至

第二届 数证杯电子数据取证分析大赛 初赛 题解

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:

Pasted image 20251104105839.png

Pasted image 20251104105854.png

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

Pasted image 20251104105909.png

Pasted image 20251104105926.png

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

Pasted image 20251104105940.png

Pasted image 20251104110015.png

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

Pasted image 20251104105801.png

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

Pasted image 20251104143344.png

kubectl get pv 查看 Persist Volumn 的信息:

Pasted image 20251104161708.png

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

Pasted image 20251104161853.png

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

Pasted image 20251104162317.png

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

Pasted image 20251104162559.png

Pasted image 20251104162614.png

Pasted image 20251104162628.png

注意到最后将 pv 目录的文件进行了压缩.

192.168.50.83 的服务器的 sshd 配置了禁止挑战-验证登录, 需要修改 sshd_config. 改完之后还是连不上, 看了一眼运行的服务, 发现有 firewalld, 用 systemctl stop firewalld & systemctl disable firewalld 关闭防火墙. 之后即可正常连接.

root 目录和 /data/k8s_data/default/ 目录下的文件:

Pasted image 20251104170013.png

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

Pasted image 20251104170039.png

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

Pasted image 20251104170256.png

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

Pasted image 20251104171317.png

Pasted image 20251104171202.png

Pasted image 20251104171217.png

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

Pasted image 20251104171450.png

看到报错是数据库连不上. 正常情况下, 在 tcp 192.168.50.80:30627 应该运行着一个数据库.

根据先前查看的 pod & pv 信息, k8s 集群中有 mysql 的 service 但没有配置 mysql 的 pod 和 pv / pvc, 但是在 192.168.50.83 服务器上确实安装着 mysql, 并且数据目录不为空:

Pasted image 20251104171855.png

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

Pasted image 20251104172000.png

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

Pasted image 20251104173529.png

算了, 一堆配置都没有, 干脆把 root 目录下所有 k8s 的 yaml 全部 apply 一遍得了.

然后会发现 php-nginx 的 pod 卡在创建容器的步骤, 两个 node 都没有这个镜像, 看眼 log 发现从 docker.io 的 registry 拉不下来这个镜像, 估计是私有仓库的镜像吧:

Pasted image 20251104182038.png

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

Bash
docker save -o php-nginx.tar webdevops/php-nginx:7.4

Pasted image 20251104182218.png

scp 传到 node 上:

Bash
scp ./php-nginx.tar root@192.168.50.91:~/
scp ./php-nginx.tar root@192.168.50.92:~/

在 2 个 node 上 load:

Bash
docker load -i ./php-nginx.tar

Pasted image 20251104182407.png

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

Pasted image 20251104182544.png

看一眼 log:

Pasted image 20251104182653.png

原本跑在 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/ 目录下. 源码恢复之后就可以正常访问网站了:

Pasted image 20251104183056.png

k8s 的服务信息:

Pasted image 20251104193524.png

01 node1 节点的磁盘设备 SHA256 值前六位

答案

83629C1F6CD594533F9D50F9AA8F077F293679AFDCB3635E71BB73769BFA573C

Pasted image 20251104144838.png

02 集群配置了多少个 node 节点

答案

2

参考上述服务器重组过程.

03 嫌疑人于什么时间修改 master 节点的 root 密码

答案

09-19 09:35:59

在火眼的系统日志分析结果中可以看到:

Pasted image 20251104143956.png

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

Pasted image 20251104144124.png

04 Docker 的安装日期

答案

2025-04-08 20:24:14

在 Bash 记录中可以看到 Docker 是使用 yum 安装的:

Pasted image 20251104145157.png

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

Pasted image 20251104145353.png

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

Pasted image 20251104145311.png

05 Docker 通过配置守护进程以使用全局代理, 该代理地址的端口

答案

4780

已经提示了是守护进程(deamon)了, 可以直接用 service 或者 systemctl 看 docker.d 的配置信息, 然后去找到配置的代理.

Pasted image 20251104145624.png

Pasted image 20251104150006.png

06 发卡网站使用的 Mysql 数据库对外访问端口

答案

30627

可以用 kubectl get service 或 kubectl get svc 查看服务信息.

Pasted image 20251104150333.png

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

Pasted image 20251104150118.png

07 发卡网站部署使用的镜像名称

答案

webdevops/php-nginx:7.4

查看过几个主机的 Bash 记录之后, 可以看到用户使用 docker load -i(与 docker image load 功能相同)从许多 tar 包中加载了镜像:

Pasted image 20251104151547.png

Pasted image 20251104151721.png

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

Pasted image 20251104152419.png

Nginx 配置:

Pasted image 20251104152557.png

pvc(Persist Volumn Claim)配置:

Pasted image 20251104152408.png

sv(Supervisor)配置:

Pasted image 20251104152753.png

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

Pasted image 20251104152920.png

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

Pasted image 20251104153101.png

其中包含 docker-compose.yaml 和 Dockerfile. 从 Dockerfile 中可以看出来, 发卡网站的镜像是基于 webdevops/php-nginx:7.4 镜像构建的.

08 当前 Telegram 群管机器人使用的容器 ID 的前六位

答案

8fadf5

kubectl describe all:

Pasted image 20251104155743.png

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

Pasted image 20251104155805.png

09 发卡网站使用的缓存数据库

答案

redis

这道题比较白给, 服务器上一共就两种数据库, 一个 mysql 一个 redis, 猜都能猜到 redis 是缓存数据库.

正常情况下要查看源码中的配置文件确定:

Pasted image 20251104160555.png

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

Pasted image 20251104160628.png

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 代理域名:

Pasted image 20251104191807.png

在重建过程中 captcha-bot pod 的报错信息中也能看到该域名.

12 嫌疑人在 Telegram 上创建的群名称

答案

西门庆交流群

在 master 节点的 /root/mysql-d.yaml 中保存着容器的环境变量 MYSQL_ROOT_PASSWORD=LKKD23mjakl213dmmm:

Pasted image 20251104192339.png

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

Pasted image 20251104193524.png

使用 Navicat 连接数据库:

Pasted image 20251104193021.png

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

Pasted image 20251104193728.png

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

Pasted image 20251104201041.png

13 统计嫌疑人在 Telegram 上创建的群中 2025 年 6 月之后成功入群的人数

答案

2422

接上题. 浏览数据, 看到所有 captcha_success_time 字段有数据的记录, 其 captcha_status 字段均为"1", 判断这些为通过了验证的记录:

Pasted image 20251104194238.png

SQL
SELECT COUNT(id) FROM user_captcha_record
WHERE `captcha_success_time` >= '2025-06-01 00:00:00'

Pasted image 20251104194627.png

14 据嫌疑人交代曾在发卡网上删除过一条订单数据, 请找出该删除订单的订单号

答案

4V8XNK8Q02MD5D2R

在 dujiaoka 数据库的 orders 表中存储着订单记录. 有一个 deleted_at 字段, 但经过查询发现所有记录的该字段均为 null. 因此应该不是在后台删除的, 而是直接在数据库中删除.

使用 mysql 自带的组件 mysqlbinlog 解析数据库存储目录 /data/k8s_data/default/mysql80/ 目录下的 binlog.000001 和 binlog.000002:

Bash
mysqlbinlog -v binlog.000001 > ~/binlog_1
mysqlbinlog -v binlog.000002 > ~/binlog_2

之后在得到的 binlog_2 文件中搜索 delete 关键字就可以找到被删除的数据:

Pasted image 20251104200517.png

订单号 order_sn 是第 2 个字段, 为 @2='4V8XNK8Q02MD5D2R'.

或者也可以使用火眼数据库取证工具, 在解析数据时勾选"恢复删除数据"选项. 即可看到一些被删除的数据, 对照数据后发现, 其中只有 id 为 6924 的数据是被删除的, 其余的是被修改的数据:

Pasted image 20251104201335.png

15 发卡网站上 2025 年 6 月之后订单交易成功的总金额(忽略被删除的数据)

答案

295202

Navicat 连上的数据库就是不包含删除的数据的, 从 status 字段的注释可以看出来 4 代表订单已完成:

SQL
SELECT SUM(actual_price) FROM orders
WHERE updated_at >= '2025-06-01 00:00:00'
  AND `status` = 4

Pasted image 20251104201828.png

如果使用火眼的数据库取证工具则需要注意排除恢复出来的数据和被删除的数据; 或者可以重新进行一次分析, 不勾选"恢复删除数据"选项.

16 发卡网站的后台访问路径

答案

/admin

在网站源码中的 .env 文件中可以看到网站后台地址前缀的配置:

Pasted image 20251104202215.png

通过访问 http://192.168.50.80:31669/admin 可以验证.

17 计算出用户密码算法中 Salt 的值, 并进行 Base64 编码, 结果是

答案

lAID2ktDeRlGbcg=

在 admin_users 表中看到目前平台有 2 个管理员用户, 从密码格式可以判断使用的是 bcrypt 哈希:

Pasted image 20251104202358.png

根据题目描述, 这里存储的密码是加过盐的哈希, 需要去源码中查找哈希函数的实现.

在 /app/Admin/Controller.php 中看到直接是派生了 Dcat 的 AuthController:

Pasted image 20251104211752.png

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

Pasted image 20251104212808.png

版本太老了, 源码都不好找, 只能找到 6.20.1 的了. /vender/Lavarel/src/Illumanate 中是 Lavarel 的组件, 其中的 Auth 中是与认证相关的组件, Hashing 则是与哈希相关的组件, 魔改源码的地方可能在这两处.

在 Illuminate 可以下载到这些组件的源码.

用 WinMerge 对比 2 份源码, 发现 Hashing 的 BcryptHasher.php 被修改:

Pasted image 20251104214551.png

文件的区别:

Pasted image 20251104214629.png

Pasted image 20251104214637.png

计算盐的函数是 getSalt:

PHP
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 源码:

Pasted image 20251104220039.png

根据魔改过的登录逻辑, 重新写一个 bcrypt 加密函数:

PHP
<?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]);
}

Pasted image 20251104221836.png

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

Pasted image 20251104221927.png

18 发卡网站配置的邮件发送人地址

答案

ituzz@qq.com

在后台的"配置"->"系统设置"->"邮件服务"中:

Pasted image 20251104222000.png

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

Pasted image 20251104222809.png

19 当前发卡网站首页仪表盘中显示的发卡网站版本

答案

V2.0.5

参考[[#17 计算出用户密码算法中 Salt 的值, 并进行 Base64 编码, 结果是]]中的分析.

20 当前发卡网站中绑定的订单推送 Telegram 用户 id

答案

6213151597

在后台的"配置"->"系统设置"->"订单推送服务"中:

Pasted image 20251104222128.png

流量包分析

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 也会对两个频段区分, 我们这里还区分吗

2025-11-01100933

22 黑客成功捕获了 WIFI 中 WPA 协议握手包, 其中有效握手包组数

答案

4

Wireshark 过滤器输入 eapol 进行过滤, 然后找到 4 次握手完成, eal 或者 _ws.col.protocol == "EALOP"

屏幕截图 2025-11-04 202711.png

23 黑客爆破得出的 WiFi 密码

答案

password1110

从第一个搜 ssid 时候就看到了对于路由器的 WIFI 配置, 再确认一次搜索 key

image-20251105124629897.png

24 黑客成功连接 Wifi 后, 发现路由器操作系统为

答案

ImmortalWrt

操作系统直接搜 distribution 找版本

屏幕截图 2025-11-01 104234.png

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

Pasted image 20251105152057.png

或者追踪 HTTP 流量:

Pasted image 20251105152323.png

25 黑客对路由器后台进行爆破攻击, 该路由器后台密码

答案

password

查看流量包直接追踪流, 找到那几个 password 的 POST 帧 [Response in Frame:xxxx], 找被攻击方返回的状态码, 判断哪一个登陆成功, 最开始是拿排除法做的, 最优解应该如下文追踪流来看最省力

密码 返回的帧 返回的状态
12345678 52809 403 forbidden
987654321 54408 403 forbidden
password 55956 302(登陆成功 重定向)

image-20251105130312172

image-20251105130410525

image-20251105130429568

26 黑客通过修改路由器设置, 将被劫持的域名

答案

www.qq.com

比赛时比较偷懒的做法:

按照题目格式, 直接正则匹配了(戳手

屏幕截图 2025-11-01 104914.png

比较正经的做法:

通过上述分析, 可以知道攻击者的攻击路线:

  • 爆破路由器密码
  • 登录后台并修改配置 & 执行命令
  • 结合后续题目, 应该还在后台中运行了 shell 指令, 开启了反弹 Shell
  • 在反弹 Shell 中执行命令

此处域名劫持应该是修改了路由器的配置.

查看 HTTP 协议的请求统计:

Pasted image 20251105153739.png

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

Pasted image 20251105153814.png

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 的请求:

Pasted image 20251105154348.png

27 黑客在路由器管理后台发现 FTP 服务配置, FTP 登录密码

答案

mast

ftp 或者 _ws.col.protocol == "FTP" 都可以

屏幕截图 2025-11-04 202318.png

28 黑客通过 FTP 上传了一个压缩包文件, 该文件内容

答案

code:123456789

wireshark -> 文件 —> 导出对象 -> FTP DATA 选择压缩包, 导出之后发现要密码, passware 或者 zipcrack 爆破得到密码是 4321, 里面有个 flag.txt 内容是: code:123456789

屏幕截图 2025-11-04 210910.png

29 黑客通过路由器执行 shell 脚本, 反弹 shell 的监听端口

答案

4445

执行shell脚本, 都会想到用 /bin/sh. 因为是 HTTP 传的明文, 所以直接搜索 /bin/sh, 可以看到 nc 连接的端口是 4445, 即反弹 shell 的监听端口是 4445.

屏幕截图 2025-11-01 111205.png

30 黑客通过反弹 shell 成功控制目标路由器后, 总共执行了多少条命令

答案

4

根据上题看到的指令, 用 tcp.dstport == 4445 and ip.addr == 192.168.1.1 过滤反弹 shell 的流量.

追踪 TCP 流, 可以看到一共执行了 4 条命令

Text Only
ifconfig
uname -a
cd /root
ls

Pasted image 20251105160339.png

Pasted image 20251105160352.png

APK 程序分析

info

请根据APK检材, 回答以下问题

31 apk 的版本名称

答案

3.0.12

PixPin_2025-11-05_20-14-45.png

32 在该 APP 中, 调用了哪个 System 的方法用于获取本地系统的时间戳

答案

currentTimeMillis

果然分析的第一件事是查壳:

PixPin_2025-11-05_20-33-57.png

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

用它的 jadx 打开, 全局搜索 time 就能找到:

PixPin_2025-11-05_21-12-44.png

33 apk 运行后 getVer() 的返回值

答案

4.56.23

搜索这个方法:

PixPin_2025-11-05_21-16-49.png

只有一个地方调用了, 那就对它进行 hook

不知道为什么我的雷电不能连接, 就用本机环境了(好在关闭雷电可以选择不关闭他的 jadx)

spawn 不行, 有可能这个 app 有初始化注入检测, 使用 attach:

JavaScript
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() 的这个包, 实际就是要分析的代码:

Java
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, 也就是下题的第二界面. 具体的参数和顺序:

Java
Uri.decode(fullHttpRequest.uri()).equals(String.format("/%s?hash=%s&ver=%s&ts=%s", enc, apkHash, getVer(), ts))

和上题一样 hook 就行:

JavaScript
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);
});

输出:

PixPin_2025-11-05_22-50-42.png

其实上一题问的都多余了, 这一题需要的参数也包括了 getVer(), 加一个打印就行

35 apk 第二界面的 8 位授权码

答案

84572399

跟进查看 SecondActivity:

Java
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();
    }
}

授权码的验证代码:

Java
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 的字符串哈希算法是固定的, 可以通过它爆破出对应的正确值, 题目限定了八位纯数字进一步缩小范围:

Python
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 跑(原汤化原食, 老祖宗的智慧是对的):

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;
            }
        }
    }
}

Pasted image 20251106105339.png

二进制程序分析

info

请在安全的环境下安装该程序, 并回答以下问题

环境 Win 10, IDA 9.0+.

36 安装该程序后, 该恶意程序的可执行文件所在的直接父目录名称

答案

proxy

安装包是一个 v2rayN 的安装包:

PixPin_2025-11-05_13-14-42.png

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

PixPin_2025-11-05_13-17-05.png

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

Pasted image 20251105163233.png

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

Pasted image 20251105163636.png

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

Pasted image 20251105163913.png

37 解密文件名为"RnRGaWxlcy5lZGIiL"的文件时所使用的 key

答案

1Njc2NTQ2Mzc0NTc

proxy文件夹下存放的就是之后题目要用到的三个文件:

PixPin_2025-11-05_13-18-42.png

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

image-20251105132235709.png

image-20251105132302806.png

反编译:

C
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:

C
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 的数组的前十六个字节, 跳转看定义:

PixPin_2025-11-05_13-36-58.png

也就只有十六个字节: 1Njc2NTQ2Mzc0NTc.

38 解密文件"RnRGaWxlcy5lZGIiL"成功后, 请分析并给出解密后的文件的入口点地址

答案

0x180002730

用上文密钥解密文件:

PixPin_2025-11-05_13-43-19.png

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

PixPin_2025-11-05_13-47-50.png

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

PixPin_2025-11-05_13-55-34.png

39 加密文件名为"6c051a72b91a1"的文件时所使用的密钥

答案

cgDSb6VOegeF7EuW

直接搜是搜不出来 6c051a72b91a1 的, 回头看看 sub_140001070:

C
__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:

C
v44 = (void (*)(void))sub_140001B20(lpAddress, "SDGHY3a9DK3t14Fg1hSGH56U");

转到刚刚解密出的 PE 文件:

image-20251105141235239.png

查看函数SDGHY3a9DK3t14Fg1hSGH56U:

C
_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:

C
// 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 文件, 说明是被这个函数加密的:

image-20251105142010963.png

先看对每一个块进行处理时的初始化函数 sub_180001000:

C
_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 的密钥拓展函数:

C
__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

PixPin_2025-11-05_14-33-22.png

C
57754537466567654F56366253446763h

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

C
ida_bytes.get_bytes(idc.here(), 16).decode('ascii', errors='replace')

PixPin_2025-11-05_14-38-30.png

40 "6c051a72b91a1.1"文件解密后的 md5 值后六位

答案

0D0C3B

之前的 sub_180001000 函数用到了 AES 表, 其中 v27 定义的每一行都是小端序存放的 T 表:

image-20251105150128700.png

Python
2071428195 = 0x7B777C63 (Little-Endian)

至此所有解密的信息已经得到, 写脚本即可(密码学的大作业拿出来改改就行了)

Python
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

火眼分析软件可以直接看到:

Pasted image 20251106110506.png

42 操作系统设置的账户密码最长存留期为多少天

答案

68

仿真计算机, 在 cmd 中执行 net accounts:

42-1.png

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

Pasted image 20251106164434.png

43 用户 2 登陆密码 NT 哈希值后六位

答案

A9C708

用户列表可以直接看到

43-1.png

44 蓝牙 mac 地址

答案

9C-B6-D0-04-C9-CC

网络配置里直接看到:

44-1.png

45 SafeImager 的产品序列号后四位

答案

09C4

SafeImager 是盘古石的镜像制作工具, 是个 USB 设备. 在 USB 基础信息里:

45-1.png

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

Pasted image 20251106165126.png

46 "123.VHD"所处的结束扇区

答案

27445255

XWF 中定位到文件, 切换到文件视图, 按下快捷键 Ctrl + End 跳转至文件尾, 再切换回分区视图, 即可看到文件结尾所处的扇区号:

Pasted image 20251106170822.png

47 用户在 BitLocker 加密分区最后修改的文件

答案

资料1.txt

在上题的 123.vhd 中可以找到 BitLocker 恢复密钥 625075-617309-532576-720302-040975-309232-451924-426679, 用密钥解锁该分区.

47-1.png

文件全显后按时间排序:

47-2.png

48 用户连接"192.168.114.129"时用的会话名称

答案

连接阿里云

Xshell 分析结果中就能看到:

48-1.png

49 用户创建存储虚拟币钱包地址页面的时间

答案

10月07日

注意

Anytype 设置了使用 Windows 用户凭证解锁, 因此只有在仿真时不重置密码的情况下才能自动解锁.

仿真之后桌面上有一个 Anytype 软件, 进去发现有一个 Cryptocurrency 点进去看修改时间:

49-2.png

50 用户的虚拟币钱包地址

答案

3HrdpWM8ZrBVw9yu8jx1RoNNK6BZxwsHd9

将内容放 CyberChef 里 Base64 解码:

50-1.png

51 用户VC加密容器的密码

答案

SHUZHENGBEIctzy2025

在计算机的文件中可以找到 rar 分卷压缩的 5 个压缩包

51-1.png

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

51-2.png

不过根据其他题目密码的规律, 可以猜到是 6 位数字, 可以直接用 Passware 爆破. 这个密码也是计算机 Administrator 用户的密码.

可以使用这导出的 rar 分包进行解压, 但会报错:

Pasted image 20251106194835.png

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

Pasted image 20251106194947.png

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

Pasted image 20251106195120.png

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

Pasted image 20251106195414.png

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

Pasted image 20251106195622.png

但这个包和先前找到的第 4 个包相同. 到这里我意识到或许 3 MB 的不是要找的分包, 2.79 MB 大小的才是要找的分包.

在 Foxmail 的附件中看到了另外 3 个 2.79 MB 大小的分包:

Pasted image 20251107001146.png

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

Pasted image 20251107001547.png

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

Pasted image 20251107001645.png

52 用户在生活中使用的代号

答案

小胖

前面就一直注意到桌面上的 代号.wav 了, 还找到了个 声纹.7z:

52-1.png

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

52-2.png

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

Pasted image 20251106201123.png

播放之后听到很明显且存在低音到高音的杂音, 不是 SSTV 就是频谱图写字. 这个长度只有几秒, 不太可能是 SSTV.

53 李安东的银行卡归属哪个银行

答案

交通银行

在文件里搜关键词"银行"可以搜到一个表格, 并且提示密码是 6 位纯数字:

53-1.png

爆破, 得到密码是 688561:

53-2.png

54 某市 10 月 6 日最高气温

答案

21

可以找到一个气温加密.zip, 导出后直接用 Zip-crack 读取, 会自动检测伪加密, 唯一恶心的是编码做的不是很好:

54-1.png

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

Pasted image 20251106202008.png

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

Pasted image 20251106202237.png

55 用户的 BitLocker 密码

答案

SZBJSJTM2025

前面有了 BitLocker 恢复密钥后重新分析了一次, 在 Foxmail 的分析结果中又发现了一些有意思的东西

55-1.png

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

55-2.png

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

55-3.png

56 用户办公室的门禁密码

答案

147963258

对之前的 123.vhd 进行特征分析会发现还有一个 TrueCrypt 加密卷, 但其实是 VeraCrypt 的加密卷, 只不过火眼不分这俩:

56-1.png

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

56-2.png

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

56-3.png

说句实话, 放文件尾真没必要(

57 用户使用的以 D 开头的解密程序的 MD5 值后六位

答案

3A892E

57-1.png

58 木马程序运行至系统断点前加载了几个动态链接库

答案

5

将软件拖入 x32dbg 中, 等自动暂停后在"视图-模块"查看:

58-1.png

59 木马产生的程序名称

答案

wins.exe

询问 gpt 得知可以用微软的 Procmon 来监听进程

下载好后用管理员模式运行, 做如下过滤, 在运行进程, 会发现进程消失了

59-1.png

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

59-2.png

或者用微步沙箱分析:

Pasted image 20251106205653.png

Pasted image 20251106210219.png

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

Pasted image 20251106205859.png

60 木马尝试访问的域名

答案

edu-image.nosdn.127.net

60-1.png

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

60-2.png

在微步沙箱中可以看到:

Pasted image 20251106210116.png

计算机内存取证

info

请根据计算机检材和内存检材, 回答以下问题

61 内存镜像制作时的系统时间

答案

2025-10-16 11:21:53+00:00

python .\vol.py -f Z:\01-计算机取证\内存检材.mem windows.info:

Pasted image 20251105172313.png

62 用户 Yiyelin 的用户表示后 4 位

答案

1002

随便找个能看到 SID 的插件运行, 这里是 windows.hashdump:

Pasted image 20251105172700.png

63 计算机的 CPU 型号

答案

Intel Core i7-1165G7

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

Pasted image 20251105172924.png

用 Windows Registry Recovery 查看硬件信息:

Pasted image 20251105173028.png

64 wps.exe 的 PID

答案

5888

python .\vol.py -f Z:\01-计算机取证\内存检材.mem windows.pslist:

Pasted image 20251105173303.png

65 此计算机开机自启动的远控软件名称

答案

SunloginClient.exe

还是进程列表, 可以看到 SunloginClient.exe(向日葵)的 PPID 是 688(services.exe), 是开机自启:

Pasted image 20251105173517.png

物联网设备取证

info

请根据物联网设备检材, 回答以下问题

66 打印机的主机名称

答案

print

image-20251101210928397

67 打印文件存储在哪个目录

答案

/var/spool/cups

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

image-20251101212917430

这里与后面69题中的木马中内容相对应:

木马程序中存在以下代码:

Python
# Spool directory to monitor
SPOOL_DIR = "GJTJzBXdjZkMlw2bvB3cGJTJyFmdGJTJ"

image-20251101214947549

68 同一天打印两份文件的用户是

答案

alice

根据上题,CUPS服务打印的日志为在/var/log/cups/page_log:

image-20251101213257099

image-20251101213216144

69 木马运行后, 自身产生的进程 ID

答案

2177

这里有多个分析思路:

  1. Linux日志解析:在日志里面我们可以看到如下的日志,结合关键词C2 server,我们可以确定cups-helper是木马程序。    
  2. 翻阅到 接下来检视/usr/local/bin/cups-helper:
Python
#!/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:

image-20251101214644684

70 分析物联网检材,系统中存在一个非标定时任务,这个任务每隔多少分钟执行?

答案

10

在 /etc/cron.d 路径下存储着 Linux 的计划任务:

image-20251101214725276

71 木马程序会窃取文档暂存在隐藏目录, 这个目录的绝对路径

答案

/tmp/.cache/

参考 69 题, 有代码片段:

Python
# Define staging directory for processing before exfiltration
STAGING_DIR = "GJTJlh2YhNmLGJTJw1GdGJTJ"

image-20251101214255026

72 木马程序将数据上传到的服务器的 IP 地址是

答案

185.199.108.153

参考 69 题, 有代码片段:

Python
# C2 server for uploading diagnostic data
C2_SERVER = "zUTMugDMx4SO5EjL1gTM"
C2_PORT = "zQDN"

image-20251101214157818

或者参考 69 题中提到的 Linux 日志 Attempting to connect to C2 server 185.199.108.153 on port 443.

或者翻阅文件系统, 找到日志文件 /root/.local/share/upload.log, 也对应着 cups-helper 中定义的

Python
# Log file for upload activities
LOG_FILE = "==wZvxmLkF2bsBXdGJTJlJXYoNnRyUCbhN2bs5iRyUCdv9mcGJTJ"

image-20251101214445997

该文件中存在一条日志:

image-20251101214528468

73 根据木马程序, 它监视的关键字

答案

Project Dragonfire

参考 69 题, 有代码片段:

Python
# Keyword to identify sensitive documents for "special handling"
WATCHED_KEYWORD = "=UmcpZmbvdWYyREMyUCdjVmavJHU"

image-20251101214027051

移动终端取证

info

请根据手机检材, 回答以下问题

74 微信 ID "wxid_f4s0jmpvrc522"对应的手机号后 4 位

答案

8390

image-20251101215140708

75 "华为应用市场"第一次安装日期

答案

09月24日

image-20251101215242414

76 找出检材中钱包 APP, 请列出该 APP 中 ETH 地址后 6 位

答案

3FE61F

image-20251101215318270

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

答案

85A51D

image-20251101215611175

78 手机相册中有张"imtoken助记词1.PNG"图片被破坏, 请修复该图片, 列出该图片中第 3 个单词

答案

boost

索引搜索 imtoken, 可以直接看到这 2 个文件:

image-20251101220025853

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

image-20251101220132300

使用 010editor 查看十六进制, 没有发现奇怪的隐写. 根据图片显示的特征, 猜测可能是修改了文件的长宽高. 依据 CRC32 校验, 对png图片长宽进行爆破...(开始攒怒气, 谁 tm 把 'ctf' misc 引入 forensics 的!)

Python
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)

image-20251101222401802

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

答案

delay

image-20251101221621301

80 找出检材中显示"助记词3"的文档, 列出该文档中记录的第 3 个助记词单词

答案

quarter

根据前两个文件的格式, 搜索助记词关键词:

image-20251101222510328

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

image-20251101222643658

81 分析出该组助记词正常顺序中最后一个单词(已知助记词 1、助记词 2、助记词 3 中的单词顺序有被调整)

答案

segment

已知 ETH 钱包地址:

0x304fEd2927f47692E50158A1148a1B65503FE61F

以及乱序的助记词:

movie unlock boost segment foil delay paddle obtain student electric quarter clerk

用工具恢复一下. 我用的是我自己写的工具, 也可以使用 WXjzcccc/recoverMnemonic.

Pasted image 20251107015722.png

82 分析出邮箱中收件人 QQ 号为"850563586"的姓名

答案

刘佳雨

image-20251101215816733

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

答案

dingtong.saichuang

image-20251101222903234

84 接上题, 该应用聊天记录数据库的打开密码

答案

@1@#!aajsk1*JKJ

比赛的时候想的是尽可能快捷, 于是直接使用雷电挂 Frida 脚本了, 我就去看别的题目(处理别的线程了), 过了一会回来也就好了:

image-20251101223132655

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

image-20251101223602294

emmm...也很简单.

image-20251101223249851

image-20251101223304884

85 接上题, 机主发送的这个加密文档, 打开密码是

答案

QWERT666

使用上述密码打开/data/data/dingtong.saichuang/databases/dingtong.db, 在sys_message中可以看到聊天记录:

image-20251101223437473

image-20251101223358649

86 厉明的身份证地址登记的门牌号是

答案

722

在邮箱的收件箱中有一封收件:

image-20251101224242412

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

image-20251101223910057

image-20251101224023546

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

image-20251101224102395

87 "important1.xlsx"文件中体现的"金达欣"银行卡后 6 位

答案

935629

接上题, 使用存储的密码打开文件:

image-20251101224156677

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:

image-20251101225421414

使用 Jadx 查看源码:

image-20251101225545550

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

image-20251101225752824

image-20251101225955376

就可以打开了.

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

image-20251101230211854

但是看这里的 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:

Markdown
你将为用户提供 Python 代码编写和数据分析的支持. 在编写代码时, 需要注意:

- 不要生成注释
- 不要生成不必要的输出
- 严格遵守用户的功能需求
- 如果用户对概念的定义与实际定义有区别, 遵守用户给出的定义
- 编写的代码尽量遵守 Python 代码规范, 并且 Pythonic

89 统计庄家"188"在 2021-05-10 当日的电子投注总笔数

答案

2299

本地起一个 mysqld, Navicat 连数据库然后直接导入 sql 文件的数据. 或者也可以用火眼的数据库分析工具, 用内置的 mysql 起数据库.

Prompt:

Markdown
写一个 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 生成的代码:

Python
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()

运行结果:

Pasted image 20251106220448.png

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:

Markdown
写一个 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 生成的代码:

Python
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()

运行结果:

Pasted image 20251106224132.png

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:

Markdown
在文件 03-案件卡串号数据.csv 中,该表每条数据的"卡串号(IMSI)"字段值存在问题, 不可信. 真实可信的卡串号值在"溯源"字段中(溯源字段的值格式均为`{手机号=[待获取的卡串号->手机卡 串号(IMSI)使用过的手机号->当前]}`). 请写一个python脚本统计分析出该表中哪个真实卡串号出现过的次数最多

生成的代码:

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()

运行结果:

Pasted image 20251106211635.png

97 统计每个分局 2024 - 2025 年每月被骗总额环比大于 30% 的月份个数, 环比大于 30% 的月份个数最多的分局 ID 名称

环比定义: (这个月的数据 - 上个月的数据)/上个月数据

特殊情况: 例如某分局 2025 年 1 月被骗金额总和为 100, 若该分局 2024 年 12 月没有被骗金额, 则该分局 2025 年 1 月也符合题目要求, 应增加一个月份. 2024 年 1 月不需要计算与上个月的环比情况

答案

A675

Prompt:

Markdown
在文件 04-涉诈案件信息表.csv 中,请写一个python脚本统计每个分局 2024 - 2025 年每月被骗总额环比大于 30% 的月份个数, 环比大于 30% 的月份个数最多的分局 ID 名称

> 环比定义: (这个月的数据 - 上个月的数据)/上个月数据
> 
> 特殊情况: 例如某分局 2025 年 1 月被骗金额总和为 100, 若该分局 2024 年 12 月没有被骗金额, 则该分局 2025 年 1 月也符合题目要求, 应增加一个月份. 2024 年 1 月不需要计算与上个月的环比情况
>

Cline 生成的代代码:

Python
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}个月)")

运行结果:

Pasted image 20251106225022.png

98 为摸排疑似涉毒的窝点, 请分析出在 00:00:00~06:00:00(含 0 点与 6 点)人像记录中, 哪个传感器点位 ID 抓拍到最多的不同涉毒前科人员

两表均无重复数据, 直接要求答题即可.

感知时间字段格式均为 yyyy-MM-dd HH:mm:ss; 传感器 ID(人像卡口点位)值不同则代表不同的摄像点位

答案

350203103

Prompt:

Markdown
在文件 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 生成的代码:

Python
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()

运行结果:

Pasted image 20251106233549.png

99 接上题, 为摸排潜在的涉毒人员, 请分析出有多少个非涉毒前科人员至少跟 3 个不同的涉毒前科人员同行过

本题的"同行"指: 两人在同一个人像卡口点位感知时间差在 10(含)秒内

答案

4

100 请筛选出 2024 和 2025 年的住宿记录(以"入住时间"为准), 频繁更换酒店(即只住一天然后更换酒店)的人员有几个

答案

35

这道题问题比较大, 模糊不清的点很多.

Prompt:

Markdown
在文件 07-旅店住宿信息表.csv 中,请写一个python脚本完成以下数据分析任务:

1. 请筛选出 2024 和 2025 年的住宿记录(以"入住时间"为准), 频繁更换酒店(即只住一天然后更换酒店)的人员有几个

Cline 生成的代码:

Python
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)

运行结果:

Pasted image 20251106234317.png