吾爱破解 2024 春节红包活动题解(除七)
本帖最后由 爱飞的猫 于 2024-2-27 09:31 编辑今年参与「吾爱破解 2024 春节红包活动」做的题解。除了「七/安卓高级题」以外,其它活动均挑战成功。
本届春节红包活动有三大方向,分别是 Windows、Android 和 Web 方向。
## 题解导航
- (https://www.52pojie.cn/thread-1893086-1-1.html#pid49547094)
- [解题领红包之二 {Windows 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547094_解题领红包之二-{windows-初级题})
- [解题领红包之六 {Windows 高级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547094_解题领红包之六-{windows-高级题})
- (https://www.52pojie.cn/thread-1893086-1-1.html#pid49547117)
- [解题领红包之三 {Android 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547117_解题领红包之三-{android-初级题})
- [解题领红包之四 {Android 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547117_解题领红包之四-{android-初级题})
- [解题领红包之五 {Android 中级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547117_解题领红包之五-{android-中级题})
- 解题领红包之七 {Android 高级题} - 挑战失败/放弃。
- (https://www.52pojie.cn/thread-1893086-1-1.html#pid49547134)
- [解题领红包之八 {Web 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_解题领红包之八-{web-初级题}) - 1-4/A
- [解题领红包之九 {Web 中级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_解题领红包之九-{web-中级题}) - 5-8/B
- [解题领红包之十 {Web 高级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_解题领红包之十-{web-高级题}) - 9-12/C
## 更新记录
- 2024.02.26
- 重新分析了下 Win 高级题中的码表计算(天堂之门的 64 位代码)以及调试器检测部分(SEH/硬件断点)。
- 修正部分错字,重新理了下 Win 高级题的流程。希望能够更容易理解一些。
- 重新设计了导航。
- 2024.02.27
- 在“解题领红包之六 {Windows 高级题}”添加了简易的可行脱壳方案。
本帖最后由 爱飞的猫 于 2024-2-27 09:34 编辑
Windows 题解在此。
- [返回总导航](https://www.52pojie.cn/thread-1893086-1-1.html#pid49547087)
- [解题领红包之二 {Windows 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547094_解题领红包之二-{windows-初级题})
- [解题领红包之六 {Windows 高级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547094_解题领红包之六-{windows-高级题})
## 前言/注意事项
使用工具:
* Windows 逆向工具
* 找朋友借的 IDA Pro 7.6 SP1 工具一份
* (https://www.52pojie.cn/thread-1345176-1-1.html) / (https://www.52pojie.cn/thread-1874203-1-1.html)(ARM 解析不完美)
* (https://github.com/NationalSecurityAgency/ghidra/releases/latest) 的伪代码质量还是没有 IDA Pro 的高啊…
* 调试工具 - + (反调试器检测)
* PE 编辑工具 - (https://github.com/hasherezade/pe-bear/releases/latest)
* 其它工具
* 十六进制编辑器 - 、(免费)
: https://www.sweetscape.com/010editor/
: https://github.com/x64dbg/x64dbg/releases/latest
: https://github.com/x64dbg/ScyllaHide/releases/latest
: https://mh-nexus.de/en/downloads.php?product=HxD20
该文章可能新手不友好的地方:
- 你需要熟悉 x64dbg 以及 IDA Pro 的基础操作。
- 你需要熟悉 C/C++ 语法
- 本文会引用 C++ 2020 新增的语言特性。
- 部分基础或算法无关内容的操作可能被省略。
- 如果你觉得部分内容需要更详细的解释,可以发表评论指出。
### 前置信息/MSVC 容器结构体
使用微软 Visual Studio(MSVC)编译的 `std::string` 容器,大致可以用如下结构体来描述:
```cpp
struct MsvcStdString {
union {
char inlined;
char* far_ptr;
} buff; // sizeof(buff) = 16
size_t length;
size_t capacity;
}
```
其它容器的结构体基本也大同小异 - 指针或在结构体内储存;含有一个 `capacity` 和 `length` 成员表示其最大大小和当前大小。
## 解题领红包之二 {Windows 初级题}
> 出题老师:云在天
直接打开程序观察行为。
根据提示随机输入测试字符串「`123456`」回车,得到提示字符串「`Error, please try again`」:
```text
Please input password:
123456
Error, please try again
请按任意键继续. . .
```
因为开始前[偷偷用调试器看了下区段名](https://www.52pojie.cn/thread-378612-1-1.html),没有加壳;直接偷懒使用 IDA 打开程序;随手翻翻整理下,代码还是比较清晰的。
在一个看着像 `memcmp` 的实现后面,紧接着提示成功或失败字符串:
```c
if ( flag_code_incorrect ) {
msg = "Wrong,please try again.";
} else {
msg = "Success";
}
```
反推条件,有一个检查是否等于 `36`,以及几个循环检查输入内容是否相等 ⇒ 检查输入字串长度 + 内容。
检查字符串内容前有两个可疑的调用;第二个函数是「解密密文」:
```asm
_004022C0:
movedx, 0FFFFFFFDh ; "-3"
leaecx,
call sub_401FE0
_004022CD:
add esp, 0x18
```
直接在「解密」结束后的 `004022CD` 下一个断点,然后输入构造的 36 位随机密码,等待断点触发。
触发后可以发现内存 `` 处存在我们要找的 `flag`,将其提交即可:
```text
01216D4066 6C 40 67 7B 48 40 70 70 79 5F 4E 33 77 5F 65fl@g{H@ppy_N3w_e
01216D5040 72 21 32 6F 32 34 21 46 69 67 68 74 69 6E 67@r!2o24!Fighting
01216D6021 21 21 7D 00 00 00 00 00 00 00 00 00 00 00 00!!!}............
```
※ 软件使用的字符串应该是 MSVC 实现的 `std::string`;很多变量可以手动设置该类型。
### 解密算法
虽然找到了解密后的内容,但是解密过程却还没有研究。
目前已知的内容:
* `401FE0` 负责解密密文
* 加密后的密文是 `"ioCj~KCss|bQ6zbhCu$5r57$Iljkwlqj$$$\x80"`
只能去看 `401FE0` 的过程了。一开始可能被大片的 `_mm_` 开头的“函数调用”给吓到了,但其实这个分支只是编译器给的优化代码(由 `dword_42E5DC` 的值控制)。
我们直接看底下回退到使用 `for` 循环逐字节解密的实现即可:
```c
for ( ; i != len; ++i )
*v6++ += key % 26;
```
简单来说将整个字符串循环一遍,然后每次和 `key` 相加(参数,为固定值 `-3`)。
对应的 C++ 代码实现:
```cpp
#include <iostream>
#include <string>
std::string g_flag_encrypted = "ioCj~KCss|bQ6zbhCu$5r57$Iljkwlqj$$$\x80";
void decrypt(std::string &buff, char key) {
int delta = key % 26;
for (auto &item : buff) {
item = static_cast<char>(item + delta);
}
}
int main() {
std::string flag(g_flag_encrypted.cbegin(), g_flag_encrypted.cend());
decrypt(flag, -3);
std::cout << "solution for challenge 02: " << flag;
return 0;
}
```
## 解题领红包之六 {Windows 高级题}
> 出题老师:Solly
初版本加了 Themida 壳,发现它利用 COM 组件进行通信,折腾几个小时后无果后放弃。
2024.02.22 日 21 时降低难度,将原本的加密壳 Themida 更换为更为温和的 ASPack,这样的话又能继续分析了。
> 强壳我唯唯诺诺,没壳我重👊出击!
本题利用了很多反调试检测和异常处理机制来增大逆向分析难度。
> Solly 老师很强。但是假如,我是说假如,我若是掏出 Cheat Engine 且不附加调试器,阁下又当如何应对?
> (Solly 老师请不要再上强度了 QAQ)
这套 CM 有两个组件 - COM 服务器和 COM 客户端,以 `crackme2024` 名的[命名共享内存](https://learn.microsoft.com/zh-CN/windows/win32/memory/creating-named-shared-memory)来通信。
### 脱壳
本文的脱壳步骤需要提前将两个可执行文件的 ASLR 行为关闭。
如果不需要静态分析,也可以跳过脱壳部分,直接带壳调试。
#### 关闭 EXE 文件的 ASLR
※ 如果需要使用中文的 PE-Bear,在菜单依次选择【Settings】→【Configure】,在设置窗口将“语言 Language” 从 `en_US` 切换到 `zh_CN` 后重启程序即可。
使用 PE-Bear 加载可执行文件,切换到【可选头(Optional Hdr)】选项卡,往下滑找到【DLL Characteristics】。
因为下方可以看到 `40` 标志位(DLL can move,即开启 ASLR),因此双击 `8160`,填入 `8120`(因为 `0x8160 - 0x40 = 0x8120`)后回车。
最后右键文件并选择【另存为】,输入或选择现有的文件名来保存即可。
提示:你也可以使用 (https://down.52pojie.cn/?query=StudyPE) 来“一键固定基址”,原理是一样的:
#### 客户端脱壳
客户端程序 `crackme2024.exe` 是 x64 程序,使用魔改的 UPX 壳。
推荐参考内容:
- [《吾爱破解培训第十课:探寻逆向新航标---x64平台脱壳与破解实战》 讲师:Kido](https://www.52pojie.cn/thread-422192-1-1.html)
下方为适用于该挑战的脱壳教程:
- 使用 x64dbg 打开该程序。
- 如果地址不是 `0000000140027A00`,那么你可能停在了 ntdll 的系统入口。按下 `F9` 继续运行即可。
- 按下 `Ctrl-B` 打开搜索框
- 在底下随意输入几个 `00`,例如重复 5 次的 `00 00 00 00 00`
- (注意这是 UPX 壳的特性,其它壳不一定适用)
- 回车确认来完成搜索
- 检索完毕后,第一条检索命中的地址应为 `0000000140027C49`。
- 双击进入该地址
- 向上滚动,找到 `E9` 大跳 - `0000000140027C40 | E9 97BCFDFF | jmp crackme2024.1400038DC |`
- 选中它,并按下 `F4` 让程序运行到该行,然后再按下 `F8` 步进到 `00000001400038DC` 这个地址。
- 在 x64dbg 的菜单,选择【插件】→【Scylla】
- 此时 OEP 应当自动填充 `00000001400038DC`。
- 按下左侧的【IAT Autosearch】按钮,自动搜寻 IAT。若是询问是否使用“IAT Search Advanced result”,选择【是】。随后确定。
- 按下左侧的【Get Imports】按钮,获取导入表。此时上方导出应看见 `kernel32.dll` 和 `ole32.dll`。
- 按下右侧的【Dump】按钮,将进程转储到硬盘,默认为 `crackme2024_dump.exe`。
- 按下右侧的【Fix Dump】按钮,选择刚刚 Dump 时写出的文件名。生成 `crackme2024_dump_SCY.exe`
这个新的 `crackme2024_dump_SCY.exe` 就是脱壳后可正常运行的可执行文件了。
#### 服务端脱壳
服务端程序 `crackme2024service.exe` 是 x86 程序,使用 ASPack 压缩壳处理。
对脱壳有兴趣可以参考下述教程:
- [《吾爱破解培训第八课:短兵相接--深入浅出探讨脱壳细节(上)》 讲师:L4Nce](https://www.52pojie.cn/thread-411104-1-1.html)
- [《吾爱破解培训第九课:短兵相接--深入浅出探讨脱壳细节(下)》 讲师:L4Nce](https://www.52pojie.cn/thread-420354-1-1.html)
和客户端的处理大同小异,但是这次使用 ESP 定律来做;下方为适用于该挑战的脱壳教程:
- 使用 x32dbg 打开该程序,确保调试器停在了 `00424001` 这个入口处。如果不是,尝试按下 `F9` 继续执行即可。
- 按一下 `F8` 步进来执行 `pushad` 指令,当前指令地址切换到 `00424002`。
- 在下方的命令窗口,输入 `bph esp, r, 1` 并回车来对 ESP 地址设置访问断点:
- 成功后将提示成功设置断点 `已于 xxxxxxxx 处设置硬件断点!`。
- 如果你重复执行该指令,则会提示 `该地址已存在硬件断点!`。
此时按下 `F9` 继续执行,可以看到如下指令:
```s
00424422 | 75 08 | jne 0x42442C| <- 停在此处
00424424 | B8 01000000 | mov eax,1 |
00424429 | C2 0C00 | ret C |
0042442C | 68 70714000 | push 0x407170 |
00424431 | C3 | ret |
```
慢慢按下 `F8` 步进。抵达 `ret` 时按下最后一次 `F8` 来步进:
```s
00407170 | E8 91040000 | call 0x00407606 | <- 最后一次步进后,抵达此处
00407175 | E9 78FEFFFF | jmp 0x00406FF2|
0040717A | CC | int3 |
```
抵达地址 `0x407170` 后,同刚才客户端的 `Scylla` 工具步骤一样,转储内存并修正导入表即可。
与刚才不同的一点是,按下【Get Imports】后会显示一个 ❌ 标记的项目,右键它并选择【Delete tree node】即可:
### 客户端
~~脱壳 - 这个不会,脱下来的无法正常运行,因此只是脱下来方便静态分析。~~ 逼着自己看了下坛子里的教程,整了个能脱了后还能运行的步骤。
首先看看客户端。打开时提示输入 UID 和序列号,会检查长度,要求 35 或更长。
```c
serial_len = -1;
do {
++serial_len;
} while ( serial ); // strlen(serial)
if ( serial_len >= 35 ) {
if ( server_validate(uid, serial) ) { // 正题
// ... 省略 ...
} else {
printf("\nERROR: uid or serial is error\n");
}
}
```
本体的通信代码是 COM 组件,不是很懂;稍微分析后进行一番整理后的代码如下:
```c
// `vtable` 这个名字大概不准确,其实是调用 COM 的接口。
bool __fastcall server_validate(unsigned int uid, const char *serial)
{
CrackmePpvObj *pCrackMe; // BYREF
uint64_t p_serial_good; // BYREF
char szSerial; // BYREF
LOBYTE(p_serial_good) = 0;
g_found_int3_bp = anti__check_CC_bp(346, 56i64);// 扫描 CC (int3) 断点
if ( CoInitialize(0i64) >= 0 )
{
if ( CoCreateInstance(&rclsid, 0i64, 0x14u, &riid, &pCrackMe) < 0 )
{
printf("\nCreate Server Instance failure. Please run crackme2024service.exe /RegServer as administrator first.\n");
}
else
{
// 拷贝一份
memset(szSerial, 0, 0xC8ui64);
strcpy_s(szSerial, 0xC8ui64, serial);
pCrackMe->vtable->SetUID(pCrackMe, uid);// idx = 7 -- SetUID
// 第一次运行时,该共享内存偏移 0x00 处为计算 UID 的函数指针。
// 执行后将更新为对应的 UID 混淆后的数据(uint64_t)。
if ( *g_shared_buffer )
*g_shared_buffer = (*g_shared_buffer)(*(g_shared_buffer + 2));// update uid buffer
pCrackMe->vtable->SetSerial(pCrackMe, szSerial);// idx = 8, SetSerial
// 成功执行 - 返回 0
// 未初始化等错误 - 返回错误码
// 序列号校验成功 - 设置第一个参数的指针内容为 1
if ( pCrackMe->vtable->CheckSerial(pCrackMe, &p_serial_good) == 1 )// idx = 9, Check
{
LOBYTE(p_serial_good) = 0;
printf("Running failure. Please run crackme2024service.exe /RegServer as administrator first.\n");
}
(pCrackMe->vtable->field_10)(pCrackMe); // 大概是停止服务
}
}
CoUninitialize();
return p_serial_good;
}
```
注意客户端有一些反调试,可能还有一些我不清楚的手段:
- 爬 `PEB` 检测 `BeingDebugged` 探测是否被调试。一般的「反调试器检测」插件通常都能处理。
- 起新线程
- 抛异常并接管,根据堆栈回溯扫 `int3` 断点;
- 抛异常并接管,检查 `ContextRecord->Dr0~3` 的值(不确定对不对);
- 简单粗暴的扫返回地址处的 `int3` 断点;
- 调用未公开方法 `CsrGetProcessId` 来获取 `CRSS.exe` 进程 ID,然后使用 `OpenProcess` API 来打开它。
- 因为调试器会给目标进程添加 `SeDebugPrivilege` 权限,若是能成功打开则表示有调试器… 防不胜防啊。
检查到调试器后,会将 `g_shared_buffer` 储存的函数更改为错误的 UID 混淆方法,或直接改坏该值导致崩溃。
正确的 UID 混淆算法如下:
```cpp
uint64_t __fastcall sub_7FF695491A90(uint64_t a1) {
return (a1 * a1 * a1) ^ uint64_t{0x323032796C6C6F73};
}
```
### 服务端
不是很知道怎么下手,直接翻翻字符串,在 `00418F38` 发现 `crackme2024` 字样。
下面没多远就是 C++ 的类函数指针集合了。
```asm
.rdata:00418F50 2D 00 00 00 text "UTF-16LE", '-',0
.rdata:00418F54 B0 AC 41 00 dd offset ??_R4?$CComContainedObject@VCATLCrackmeObject@@@ATL@@6B@
; const ATL::CComContainedObject
; <CATLCrackmeObject>::`RTTI Complete Object Locator'
.rdata:00418F58 ; const ATL::CComContainedObject
; <class CATLCrackmeObject>::`vftable'
.rdata:00418F58 B0 1E 40 00 ??_7?$CComContainedObject@VCATLCrackmeObject@@@ATL@@6B@ dd offset sub_401EB0
.rdata:00418F58 ; DATA XREF: sub_401840+9C↑o
.rdata:00418F58 ; sub_401840+B8↑o
.rdata:00418F5C 00 1F 40 00 dd offset sub_401F00
.rdata:00418F60 E0 1E 40 00 dd offset sub_401EE0
.rdata:00418F64 F0 16 40 00 dd offset sub_4016F0
.rdata:00418F68 90 16 40 00 dd offset sub_401690
.rdata:00418F6C 40 15 40 00 dd offset sub_401540
.rdata:00418F70 E0 14 40 00 dd offset sub_4014E0
.rdata:00418F74 80 12 40 00 dd offset CrackMeObjSolly__SetUID
.rdata:00418F78 B0 12 40 00 dd offset CrackMeObjSolly__SetSerial
.rdata:00418F7C 60 13 40 00 dd offset CrackMeObjSolly__Check
.rdata:00418F80 C0 14 40 00 dd offset sub_4014C0
```
中间没打标注的翻了翻没发现什么重要的东西,直接看底下那三个【`CrackMeObjSolly__`】前缀的方法就好。
#### SetUID
通知服务器当前 UID。
```c
int CrackMeObjSolly__SetUID(CrackMeObjSolly *ctx, int uid)
{
*(_DWORD *)&ctx->p_map_view = uid;
int result = 0;
ctx->mapview_offset_00 = 0;
ctx->mapview_offset_04 = 0;
return result;
}
```
其中 `p_map_view` 指向共享内存区域,而 `code_has_ebfe` 则是之前的反调试检测,扫描给定区域是否有死循环(字节码 `EB FE`)。
若是检测到则将该值设置为 `1`,反之为 `0`。
若是被检测到了,效果等价于设置 UID 为其正确值的 `256` 倍。
#### SetSerial
通知服务器当前密钥。
代码稍微整理下:
```cpp
int CrackMeObjSolly__SetSerial(CrackMeObjSolly *ctx, char *p_serial)
{
memset(ctx->decoded_serial, 0, sizeof(ctx->decoded_serial));// 最多 50 项 uint32_t 类型的内容
int array_idx = 0;
char *p_next = nullptr;
while ((p_next = strstr(p_serial, L"-")) != nullptr) {
// 十六进制转数值,若遇到非合法字符则终止。
auto value = strtoul(p_serial, nullptr, 16);
ctx->decoded_serial = decoded;
p_serial = p_next;
}
// 写出最后一个 '-' 号后的值
ctx->decoded_serial = strtoul(p_serial, nullptr, 16);
// 记录共享内存的两个值
ctx->mapview_offset_00 = *(uint32_t*)ctx->p_map_view;
ctx->mapview_offset_04 = *(uint32_t*)ctx->p_map_view;
return 0;
}
```
简单来说就是使用 `-` 符号来分割,每一段都交给 `strtoul` 来将字符作为十六进制文本解析,并依次放入 `ctx->decoded_serial` 中。
#### Check
请求服务器检查之前的 UID 和密钥是否匹配。
前两个方法看起来还不算太难;那就接着看看最后一个吧(代码经过手动优化整理):
```cpp
int __stdcall CrackMeObjSolly__Check(CrackMeObjSolly *ctx, bool *a2)
{
auto value_at_00 = *(uint64_t *)&ctx->mapview_offset_00;
uint8_t decoded_str{};
memcpy(decoded_str, ctx->decoded_serial, 16);
// 16 字节,分成 4 个 uint32_t 来用;
// 2 个 uint32_t 一组来处理;
auto* p_result = (uint32_t *)decoded_str;
for(int z = 0; z < 4; z += 2) {
uint32_t left =p_result;
uint32_t right = p_result;
for(int i = 17; i >= 2; i--) {
temp = left^ g_table_1;
left = right ^ (g_ss_table_3
+ (g_ss_table_2
^ (g_ss_table_0
+ g_ss_table_1)
)); // depend on left, right
right = temp; // depend on left
}
new_right = left ^ g_table_1;
new_left= temp ^ g_table_1;
p_result = new_right;
p_result = new_left;
}
decoded_str = 0; // 截断字符串
*a2 = value_at_00 == _strtoui64(decoded_str, 0, 16);
return 0;
}
```
如果读者尝试过实现 DES 或类似的算法,会发现这个和 DES 内使用的费斯妥密码结构(Feistel cipher)很像:将数据分为左右两半,进行数轮加密(DES 中为 16 轮),每轮加密一半的数据并交换左右两半。
(图改自[百科对应条目](https://en.wikipedia.org/wiki/Feistel_cipher#/media/File:Feistel_cipher_diagram_en.svg)示意图;其中 F 可以是不可逆函数;K 为当轮参与 F 函数加密的密钥;L/R 代表左右两半的输入数据;⊕ 表示 XOR 操作)
逐步反推,整理后的反向加密算法如下:
```cpp
inline uint32_t transform_block(uint32_t left)
{
auto temp = left;
uint8_t a = temp;
uint8_t b = temp >> 8;
uint8_t c = temp >> 16;
uint8_t d = temp >> 24;
return (g_ss_table_3 + (g_ss_table_2 ^ (g_ss_table_0 + g_ss_table_1)));
}
void check_encipher_serial(uint32_t *p_left, uint32_t *p_right)
{
auto last_left = *p_left;
auto last_right = *p_right;
auto right = last_left ^ g_table_1;
auto left = last_right ^ g_table_1;
for (int i = 2; i <= 17; i++)
{
auto next_right = right;
auto next_left = left;
left = next_right ^ g_table_1;
auto result = transform_block(left ^ g_table_1);
right = result ^ next_left;
}
*p_left = left;
*p_right = right;
}
```
### 通过调试验证反向加密算法
毕竟是一个非标准算法,将其当为“黑盒”来搜集输入及其对应的输出(解密内容)。
加入下述注册表选项,可以让 COM 服务器组件主程序在启动时自动附加调试器(记得修改路径):
```ini
Windows Registry Editor Version 5.00
"Debugger"="C:\\RE\\Tools\\x64dbg\\x32\\x32dbg.exe"
```
构造正确格式的密钥并输入,在调用 `__strtoui64` 前观察其解密后的字符串内容:
```s
.text:00401463 50 push eax
.text:00401464 E8 93 9E 00 00 call __strtoui64; 此处设置断点,观测 eax 所指向的内存区域。
```
验证通过后,尝试生成一个“正确”的序列号。可惜的是因为有反调试措施,实际调试是根据错误的码表进行检测的。
当然,使用调试器可以比较方便的调整输入并观测输出内容;我们只需要验证完代码的正确性后再考虑绕过验证/重新 dump 码表即可。
算法整明白后,再利用 Cheat Engine 在不附加调试器的情况下魔改代码区段使其暂停,然后提取正确的码表数据。
### 使用 Cheat Engine 提取码表
※ 下文中的 Cheat Engine 可能会使用其缩写「CE」来表示。
首先是针对客户端,使其在远程调用 `CrackMeObjSolly__Check` 前停止执行。
在「内存查看器」添加 Auto Assembler 脚本,然后贴入下述内容:
```lua
define(address,"crackme2024.exe"+1DDC)
define(bytes,48 8B 01 FF 50 48)
assert(address,bytes)
alloc(newmem,$1000,"crackme2024.exe"+1DDC)
label(code)
label(return)
newmem:
code:
jmp code // 如果需要继续执行,可以利用 CE 的反汇编窗口将其改为 `nop`。
mov rax,
call qword ptr
jmp return
address:
jmp newmem // E9 远跳
db 00 // 随便改改。
return:
address:
db bytes
// mov rax,
// call qword ptr
dealloc(newmem)
```
※ 注意程序会扫描 `EB FE`;此处利用 CE 生成的 `E9` 大跳跳板刚好。
启动客户端本体后启用该脚本(应用更改),这样就能在输入 UID 和密钥后唤出服务器,但不进行密钥验证以及后续过程;
此时观察任务管理器,可以发现 `crackme2024.exe` 和 `crackme2024service.exe` 两个进程都在运行中。
在这个时候就可以提取(dump)码表了。使用管理员权限启动 HxD,然后参考下述操作进行提取:
- HxD 菜单栏「工具」→「读取内存」
- 选择 `crackme2024service.exe`
- 另起一个 CE 或类似工具,查看 exe 镜像加载的基址
- CE:手动添加地址 `crackme2024service.exe` 查看。
- 定位到 `crackme2024service.exe + 0x1E8D0` 处;
- 例如 `crackme2024service.exe` 的基址是 `00910000`,对应的地址就是 `0092E8D0`。
- 当然也可以用 CE 添加这个表达式来查看地址;
- HxD 菜单栏「编辑」→「选择范围」,确保窗口底下选中「十六进制」:
- 起始位置输入 `0092E8D0`
- 选择「长度」,输入【`1000`】(四个连续的码表,每个 `0x400` 长度)
- 点击「确定」
- 此时窗口会有一个蓝色的选区代表已经选区
- 选择 HxD 菜单的「编辑」→「复制为」→「C」
一番操作后,剪切板就有这 `g_ss_table_0` ~ `g_ss_table_3` 这四个完整的码表数据了,每个占 `0x400` 字节(这些数据一起,导出到一个大数组):
```c
/* crackme2024service.exe (67890)
起始位置(h): 0092E8D0, 结束位置(h): 0092F8CF, 长度(h): 00001000 */
unsigned char rawData = {
0xB0, 0xCF, 0xFD, 0x62, 0x85, 0xC8, 0x34, 0x3E, 0xF7, 0xEA, 0xB1, 0x7F,
// … 太长了,省略…
0x63, 0x55, 0x4F, 0xDC
};
```
紧接着在这个选块后面的就是 `g_table_1` 码表,这个大小是 `18 * sizeof(uint32_t)` 即 `72 (0x48)` 字节:
```c
/* crackme2024service.exe (6908) (23/2/2024 上午 10:07:17)
起始位置(h): 0092F8D0, 结束位置(h): 0092F917, 长度(h): 00000048 */
unsigned char rawData = {
0xD8, 0xAD, 0x26, 0x52, 0x20, 0xC8, 0x55, 0x5E, 0x51, 0x9E, 0x0A, 0xA3,
0xC2, 0x06, 0xBB, 0x22, 0x94, 0x17, 0x26, 0x5A, 0x73, 0x93, 0x71, 0x0C,
0x78, 0x13, 0xB8, 0x47, 0x44, 0x66, 0x53, 0xFE, 0xBC, 0xCB, 0x59, 0x46,
0x7D, 0x55, 0xA4, 0xEB, 0x41, 0x2F, 0xD5, 0x60, 0xC4, 0xB2, 0x6B, 0x3D,
0x1B, 0xF0, 0x15, 0x9D, 0xB4, 0x9E, 0x0D, 0x16, 0xBF, 0x07, 0xCA, 0xEC,
0x94, 0xCF, 0xD0, 0x36, 0x37, 0x96, 0x38, 0xAE, 0x9B, 0xB1, 0x68, 0xF8
};
```
将之前逆向出来的算法内容全部拼接到一起(留心处理下格式),就得到注册机了。
具体代码请参考下方附件的完整代码:
UID `176017` 计算出来的对应密钥并大写处理后为 `ECEAD171-E26F0C10-F9758DC1-4AA28386`。
### 2023.02.26 更新 - 初始码表计算
在 `00401B50` 初始化方法中,会进行初始化码表的操作:
```cpp
memmove_0(g_table_1, g_table_1_init, 72);
memmove_0(g_ss_table_0, g_ss_table_0__init_byte_4190A8, 0x1000);
int init_next_rotl_count = NtCurrentPeb()->BeingDebugged; // 未检测到调试器则为 0
int next_rotl_count = init_next_rotl_count;
auto* p_table_1_data = (uint64_t *)g_table_1;
for (int i = 0; i < 72/8; i++) {
p_table_1_data = sub_406610(p_table_1_data, 8 * next_rotl_count);// init g_table_1
}
```
`sub_406610` 则隐藏了一段 64 位程序代码,一开始分析的时候还没搞明白这是什么;后来发现 `33` 远跳有点耳熟,搜了下果然是以前看过的[“天堂之门”技巧](https://taardisaa.github.io/2021/09/25/Heaven'sGate/)。
将整个函数都 dump 下来到 `heavens-gate.bin`,然后使用 ndisasm 来分析吧。注意我在此处 dump 的基址为 `0x330000`,读者需要自行针对基址进行一个修正。
```s
; 基址为 0x330000
; 32 位代码使用下述内容反汇编
; 将 00336610 至 00336663 的内存 dump 到 `heavens-gate.bin` 文件
; ndisasm -o 0x336610 -b 32 -e 0 -p intel heavens-gate.bin
0033661055 push ebp
003366118BEC mov ebp,esp
0033661351 push ecx
003366148B4508 mov eax, ; 参数 1 - 表数据 (低位)
003366178B550C mov edx, ; 参数 2 - 表数据 (高位)
0033661A8B4D10 mov ecx, ; 参数 3 - 移动位数
0033661D53 push ebx
0033661EEA256633003300 jmp 0x33:0x336625 ; 进入天堂之门(64 位代码执行模式)
; 使用 NASM 套件的 ndisasm 来使用 x64 模式反编译
; ndisasm -o 0x336625 -e 0x15 -b 64 -p intel heavens-gate.bin
0033662548C1E020 shl rax,byte 0x20
00336629480FA4C220 shld rdx,rax,0x20 ; rdx = (rax & 0xFFFFFFFF) | uint64_t{rdx << 32}
0033662E48B8736F6C6C7978mov rax, 'sollyx64' ; rax = 0x343678796c6c6f73
-3634
0033663848D3C0 rol rax,cl
0033663B4833C2 xor rax,rdx ; rax = (0x343678796c6c6f73 rol cl) ^ rdx
0033663E488BD0 mov rdx,rax
0033664148C1EA20 shr rdx,byte 0x20 ; rdx = rax >> 0x20
; 将高 32 位导出到 edx 中,低 32 位仍然保留在 eax 中。
; 准备退出天堂之门
003366456A00 push byte +0x0
003366476857663300 push qword 0x336657
0033664CC744240423000000mov dword ,0x23
00336654FF2C24 jmp dword far
; 后续回到 32 位现场,使用下述指令进行反汇编
; ndisasm -o 0x336657 -e 0x47 -b 32 -p intel heavens-gate.bin
; 还原现场 (退出时的 jmp 入栈了 2 个 uint64_t 大小值)
0033665759 pop ecx
0033665859 pop ecx
0033665959 pop ecx
0033665A59 pop ecx
; 还原现场,准备退出
0033665B5B pop ebx
0033665C59 pop ecx
0033665D8BE5 mov esp,ebp
0033665F5D pop ebp
00336660C20C00 ret 0xc
```
理解后就可以在 C++ 重新实现了:
```cpp
#include <bit>
#include <cstdint>
uint64_t sub_406610(uint64_t value, int rotl_count)
{
constexpr uint64_t kKeySollyX64 = 0x343678796c6c6f73;
return value ^ std::rotl(kKeySollyX64, rotl_count);
}
```
注意:你仍然需要 dump 对应的初始值才能得到最终的码表。
```c
memmove_0(g_table_1, g_table_1_init, 72); // 0041A0B8
memmove_0(g_ss_table_0, g_ss_table_0__init_byte_4190A8, 0x1000); // 004190A8
```
所以看起来还是直接 dump 处理好的码表更方便…?
如果需要参考完整的码表计算实现,可以查看下方的附件:
### 2023.02.26 更新 - 服务器端的调试器检测与绕过
程序在 `00401CEB` 开始处手动注册了个[结构化错误处理(SEH)](https://learn.microsoft.com/zh-CN/cpp/cpp/structured-exception-handling-c-cpp?view=msvc-170)处理器来接管错误:
```s
.text:00401CEB 50 push eax ; (old rotation value + 4) / 9
.text:00401CEB ; eax = 1
.text:00401CEC 68 00 65 40 00 push offset sub_406500 ; 手动注册 SEH 的处理函数地址
.text:00401CF1 64 A1 00 00 00 00 mov eax, large fs:0 ; FS: is the pointer to ExceptionList
.text:00401CF7 50 push eax ; push old exception pointer to stack
.text:00401CF8 64 89 25 00 00 00 00 mov large fs:0, esp ; 修改 SEH 链表
```
随后立即抛出错误:
```s
.text:00401CFF 33 04 24 xor eax, ; eax 置零
.text:00401D02 31 00 xor , eax ; 写空指针导致内存访问错误
```
来到错误处理方法:
```c
int __cdecl sub_406500(_EXCEPTION_RECORD *error_code, int __unused_1, _CONTEXT *ctx) {
// 只处理访问异常错误事件
if ( error_code->ExceptionCode != EXCEPTION_ACCESS_VIOLATION ) return 0;
if ( !LOBYTE(ctx->Dr7) ) // 检测是否启用了硬件断点,未检测到则继续。
{
auto Eip = ctx->Eip;
ctx->Eax = Eip; // 将 eax 设置为当前指令所在的地址
ctx->Eip = Eip + 2; // 跳过有问题的 xor , eax 指令
return 0;
}
// 省略 ...
}
```
回到源程序,`eax` 被设置为 `0x00401D02` (字节码 `31 00`);获取偏移 1 位置的值则是 `00`。
```s
.text:00401D04 3E 0F B6 40 01 movzx eax, byte ptr ; eax = 0
.text:00401D09 将初始值储存到 ebp-18 和 ebp-14 的缓冲区中。
.text:00401D09 89 45 EC mov , eax
.text:00401D0C 89 45 E8 mov , eax
```
终于把正确的 Seed 给找到了。之前调试器被检测到,所以得到的初始值是 `0x40` 而计算错误。
另外如果需要补丁服务器端来绕过检测,可以做出如下更改:
```text
00401CAB - 改 and eax, 0
00406794 - 改 and eax, 0
004019C0 - 改 and eax, 0
00406514 - 改强制跳转 "EB 45"
```
(希望我没有遗漏…)
关于 SEH 相关技巧的参考内容:
- [[原创]从TEB到PEB再到SEH(二)](https://bbs.kanxue.com/thread-223939.htm)
- (https://www.psbazx.com/2020/08/26/Exception-Handling-in-CTF)
用来测试 SEH 和硬件断点对抗的代码:
```cpp
#include <Windows.h>
#include <cstdio>
LONG WINAPI MyUnhandledExceptionFilter(struct _EXCEPTION_POINTERS* ExceptionInfo)
{
printf("ExceptionCode: 0x%08X\n", ExceptionInfo->ExceptionRecord->ExceptionCode);
printf("ExceptionAddress: 0x%p\n", ExceptionInfo->ExceptionRecord->ExceptionAddress);
printf("Dr0: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr0);
printf("Dr1: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr1);
printf("Dr2: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr2);
printf("Dr3: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr3);
printf("Dr6: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr6);
printf("Dr7: 0x%08llx\n", ExceptionInfo->ContextRecord->Dr7);
return EXCEPTION_EXECUTE_HANDLER;
}
int main() {
SetUnhandledExceptionFilter(MyUnhandledExceptionFilter);
int* ptr = NULL;
*ptr = 1;
return 0;
}
```
可以发现 0~3 `Dr` 寄存器 (Debug Register/调试寄存器) 对应四个硬件断点的地址,以及 `Dr7` 表示哪些硬件断点已启用。
### 结语
反调试用得比较多,比较考验日常积累… 异常处理我玩不明白…
Themida 这类加密壳,难受起来是真的难受。
算法本身倒不是特别复杂,耐心跟一跟还是能出货的。
### 碎碎念
在[【2024-2-23 07:27】发帖](https://www.52pojie.cn/forum.php?mod=redirect&goto=findpost&ptid=1889163&pid=49529242) 表示自己找到答案但无法提交,以为次日就截止没法提交了在哭哭;
然后当日下午发现,早上 11 时的时候有人报告验证错误也修正了问题,但没给我补发提交次数,继续哭哭… 还好次日凌晨提交成功,至少证明我当时的分析是没有问题的 😎
题外话:我在[去年 8 月制作的一个 CM](https://www.52pojie.cn/thread-1817466-1-1.html) 也用到了类似的加密方案来处理密钥(无壳无反调试),没看到有人折腾出有效的算法注册机。欢迎挑战哦。
本帖最后由 爱飞的猫 于 2024-2-26 08:50 编辑
Android 题解在此。
- [返回总导航](https://www.52pojie.cn/thread-1893086-1-1.html#pid49547087)
- [解题领红包之三 {Android 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547117_解题领红包之三-{android-初级题})
- [解题领红包之四 {Android 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547117_解题领红包之四-{android-初级题})
- [解题领红包之五 {Android 中级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547117_解题领红包之五-{android-中级题})
- ~~解题领红包之七 {Android 高级题}~~ - 挑战失败/放弃。
## 前言/注意事项
使用工具:
* Android 逆向工具
* 开发工具 - (https://developer.android.com/studio)
* 反编译工具 - (https://github.com/skylot/jadx/releases/latest)
* APK 魔改工具 - (https://mt2.cn/) 或 (https://github.com/githubXiaowangzi/NP-Manager)
该文章可能新手不友好的地方:
- 你需要知晓「MT 管理器」或「NP 管理器」的基本用法;
- 你需要知晓「Android Studio」的基本用法;
本文使用了一些非标准注解表示形式:
- `1.apk:/assets/woc.yuan` 表示文件 `1.apk` 内的 `/assets/` 路径下的 `woc.yuan` 文件。
- `com.example.class_name::method` 表示 `com.example` 包下的 `class_name` 类的 `method` 方法。
部分代码片段使用 `diff` 语法来表示更改:
```diff
- 行首为负号,应当删除此行内容
行首为空格,不应更改此行内容
+ 行首为加号,应当添加此行内容
@ 行首为其他字符,视为注释即可
```
### 前言/MT 过签名校验
在 MT 管理器中,导航到 `apk` 文件所在目录,点选该文件。
然后依次选择「功能」→「去除签名校验」,按照默认(参考下图)选项确认后点击「确定」完成:
最后得到一个 `_kill.apk` 后缀的过签名校验的新文件。
NP 管理器的操作应该类似。
## 解题过程
### 解题领红包之三 {Android 初级题}
> 出题老师:正己(正常人)
使用 JADX 打开 APK,查看 `AndroidManifest.xml` 定位到入口 `com.zj.wuaipojie2024_1.MainActivity`。
简单看看,可以发现它本质上就是加载了个网页,然后网页接口和应用交互(“特权”指令):
```java
public void onCreate(Bundle bundle) {
// … 省略 …
copyAssets(); // 拷贝 `ys.mp4` 到用户数据目录,大概
// … 省略 …
this.webView.addJavascriptInterface(new MyJavaScriptInterface(this), "AndroidInterface");
}
```
而 `MyJavaScriptInterface` 就是处理“特权”指令的实现了。进去看看干了啥…
```java
public class MyJavaScriptInterface {
// … 省略无关代码 …
@JavascriptInterface
public void onSolverReturnValue(int i) {
if (i == -1) {
this.mContext.startActivity(new Intent(this.mContext, YSQDActivity.class));
}
}
}
```
导出了一个接口,监听成功事件;成功后加载另一个 `YSQDActivity` “窗口”,继续分析。
在底部的 `extractDataFromFile` 发现了 `flag` 字样,直接分析即可;简化整理后的代码如下:
```java
public static String extractDataFromFile(String str) throws Exception {
RandomAccessFile f = new RandomAccessFile("/data/user/0/com.zj.wuaipojie2024_1/files/ys.mp4", "r");
byte[] bArr = new byte;
long length = f.length();
for (long i = Math.max(length - 30, 0L); i < length; i++) {
f.seek(i);
f.read(bArr);
String temp = new String(bArr, StandardCharsets.UTF_8);
if (temp.indexOf("flag{") != -1) {
return temp.substring(indexOf).split("\\}") + "}";
}
}
}
```
大致含义就是从文件末尾开始找,最多查找 30 字节,提取 `flag{...}` 字样的内容。
直接使用十六进制编辑器打开 `03.apk:/assets/ys.mp4` 文件,看看文件结尾:
```text
Offset(h) 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F |
__________________________________________________________|_________________
000EFAC0 36 2E 31 30 | 6.10
000EFAD030 66 6C 61 67 7B 68 61 70 70 79 5F 6E 65 77 5F | 0flag{happy_new_
000EFAE079 65 61 72 5F 32 30 32 34 7D | year_2024}
```
因为没有加密,一眼就找到密码 `flag{happy_new_year_2024}`。
### 解题领红包之四 {Android 初级题}
> 出题老师:侃遍天下无二人
同第三题操作,直接打开定位到主窗口的 `com.kbtx.redpack_simple.WishActivity` 中。
看代码应该是模拟了手游抽卡,运气好一发入魂就能看到 `flag`;但都看到源码了,直接分析过去就行。
```java
Toast.makeText(wishActivity, "恭喜你十连出金了,奖品为 flag 提示!", 1).show();
wishActivity.startActivity(new Intent(wishActivity, FlagActivity.class));
```
熟悉的启动新窗口,代码倒是挺简洁的。稍微整理下后如下:
```java
public class FlagActivity extends h {
public static byte[] o = { 86, -18, 98, 103, 75, -73, 51, -104, 104, 94, 73, 81, 125, 118, 112,
100, -29, 63, -33, -110, 108, 115, 51, 59, 55, 52, 77};
@Override
public void onCreate(Bundle bundle) throws Exception {
byte[] secret = o; // 上面那一串神秘字符
// 取出签名(证书?)
Signature[] signatures = getPackageManager().getPackageInfo(getPackageName(), 64).signatures;
byte[] signature = signatures.toByteArray();
for (int i = 0; i < secret.length; i++) {
secret ^= signature;
}
// 提示内容
StringBuilder d = a.d("for honest players only: \n");
d.append(new String(secret));
((TextView) findViewById(R.id.tvFlagHint)).setText(d.toString());
}
}
```
这个证书我还没搞明白怎么从 APK 文件读取,但是我们可以编写一个简单应用来获取。
首先添加权限到 `AndroidManifest.xml`(在 `<manifest ...>` 下,和 `<application ...>` 平级):
```diff
@ 此为 diff 片段
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
+ <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<application
android:allowBackup="true"
```
到 `MainActivity` 主窗口,添加提取代码:
```java
public void DumpCert() {
try {
String pkg_name = "com.kbtx.redpack_simple";
android.content.pm.PackageInfo pkg = this.getPackageManager().getPackageInfo(pkg_name, PackageManager.GET_SIGNATURES);
byte[] signature = pkg.signatures.toByteArray();
String b64_cert = Base64.encodeToString(signature, Base64.DEFAULT);
android.util.Log.i("CERT", b64_cert);
} catch (Exception e) {
e.printStackTrace();
}
}
```
然后在 `onCreate` 内调用它:
```diff
@ 此为 diff 片段
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ this.DumpCert();
setContentView(R.layout.activity_main);
```
使用 Android Studio 推送应用到设备,并打开 Logcat 观察日志,得到 `base64` 编码后的证书信息。
得到证书之后用 Kotlin 跑一下解密代码即可:
```kotlin
import kotlin.experimental.xor
import java.util.Base64
fun main() {
// APK 的签名者证书信息
val cert = Base64.getMimeDecoder().decode(
"""
MIIDADCCAegCAQEwDQYJKoZIhvcNAQELBQAwRjEQMA4GA1UEAwwHa2J0eHdlcjEQMA4GA1UECwwH
NTJwb2ppZTEQMA4GA1UECgwHNTJwb2ppZTEOMAwGA1UEBwwFQ2hpbmEwHhcNMjQwMTE2MDYzMzIz
WhcNNDkwMTA5MDYzMzIzWjBGMRAwDgYDVQQDDAdrYnR4d2VyMRAwDgYDVQQLDAc1MnBvamllMRAw
DgYDVQQKDAc1MnBvamllMQ4wDAYDVQQHDAVDaGluYTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC
AQoCggEBAIBIBBNfV8FTmAmp9ikd0NqDxfn8V8rxmaSM/je5oMxGoQUhMqY0TjCaMbgO5xXf/L0g
f4SwfmIMi8MjKwkwUEc/gp7LdKVF7o/UKf6uhIDkKEw1vGncQ9PBMOv3sKFsbRCFdhPCJCAq53Em
/P3JZCFEFYKH/noZaWO8UqR7uULw916wWSNr+mTFJxjHNUekw2LxF07GQrmKMaTXy+jpkd+ifbcA
NdRRyHm13vEtu32xn9WrIREQJWxBVs0L5z0i0sBgMUTeoY5lehLAwBRrpcXrprlzoie4FfyO/tTE
onVHcYVL08BEaG7L5lBaVA56+qCZkzlBC1qf64JkB0UsKIsCAwEAATANBgkqhkiG9w0BAQsFAAOC
AQEAXhAk7ZWZLNjYgzTq82D9VpntfSMzY03e1l6c2mIiu1rmgYnbavtYmMqfNDeVnbLlDObRn8O5
gu3n6e1d2SSI3tZpKK1ZOf3zGLF7SpXwIFu22iej3k97aXANlKJegHZ9JWtjABTiVGSLKjfWiZWe
9HKTp3LBUJ2zGw3e03eWT+kzZtjvgI4gfRsji7vVG2odODMODCm+4a/dBnTlADtM0lVdJaDPUj8R
eR0ql/99EyNUMv7wtE+3o0xpCrUd5NVLp4doEusfaRnSvS35fDfp6SfODQ9BqE9TPgEPyOGn+iA8
HHw+XQhzsrn8bNdNnlOBMsbXJcMFvF92Cw+4cQGoog==
""".trimIndent()
)
// 此处为 FlagActivity.o 进行 Base64 编码后的值
val flag = Base64.getDecoder().decode("Vu5iZ0u3M5hoXklRfXZwZOM/35JsczM7NzRN");
for (i in flag.indices) {
flag = flag xor cert
}
println("flag: ${String(flag)}");
}
```
运行后输出过关密码:
```text
flag: flag{52pj_HappyNewYear2024}
```
### 解题领红包之五 {Android 中级题}
> 出题老师:正己(谜语人)
>
> 谜语人滚啊!
※ 该题,若是设备或模拟器有 root 权限会比较方便。
和三、四题一样的思路,打开后直奔主窗口。发现可以 `checkPassword` 这个方法在偷偷摸摸干坏事:
1. 将 `classes.dex` 拷贝到用户数据目录下的 `app_data/1.dex`;
2. 动态加载 `app_data/1.dex` 文件,并反射获取 `com.zj.wuaipojie2024_2.C::isValidate` 方法;
3. 将密码传入 `isValidate` 方法进行检查。
该方法代码稍微整理后如下:
```java
public boolean checkPassword(String str) {
try {
// 1. 将 `classes.dex` 拷贝到用户数据目录
InputStream file_classesDex = getAssets().open("classes.dex");
File file_dex1 = new File(getDir("data", 0), "1.dex");
byte[] bArr = new byte;
file_classesDex.read(bArr);
FileOutputStream fileOutputStream = new FileOutputStream(file_dex1);
fileOutputStream.write(bArr);
fileOutputStream.close();
file_classesDex.close();
// 2. 动态加载 `app_data/1.dex` 文件,并反射获取
// `com.zj.wuaipojie2024_2.C::isValidate` 方法;
var loader = new DexClassLoader(
file_dex1.getAbsolutePath(),
getDir("dex", 0).getAbsolutePath(),
null,
getClass().getClassLoader()
);
// 3. 将密码传入 `isValidate` 方法进行检查。
String message = (String) loader
.loadClass("com.zj.wuaipojie2024_2.C")
.getDeclaredMethod("isValidate", Context.class, String.class, int[].class)
.invoke(null, this, str, getResources().getIntArray(R.array.A_offset));
// 成功信息的开头为「唉!」
if (message != null && message.startsWith("唉!")) {
this.tvText.setText(message);
this.myunlock.setVisibility(8);
return true;
}
} catch (Exception e) {
e.printStackTrace();
}
return false;
}
```
于是直接提取 APK 中的 `assets/classes.dex` 文件到硬盘,然后使用 JADX 打开,继续分析。
※ 如果提示校验值错误,你可以在设定将「加载 DEX 文件时检查校验值( verify dex file checksum before load)」设定为 `no` 来关闭。
然后继续动态加载执行:
```java
public static String isValidate(Context context, String password, int[] offsets) {
try {
return (String) getStaticMethod(
context, offsets, "com.zj.wuaipojie2024_2.A", "d",
Context.class, String.class
).invoke(null, context, password);
} catch (Exception e) {
Log.e(TAG, "咦,似乎是坏掉的dex呢!"); // 杂鱼~ 杂鱼~
e.printStackTrace();
return "";
}
}
```
`getStaticMethod` 内的代码比较多,简单阐述一下过程:
1. 调用 `read` 方法从私有数据目录下获取 `app_data/decode.dex` 文件;
2. 调用 `fix` 方法进行修正,并写出到 `app_data/2.dex`;
3. 将 `2.dex` 其动态加载,然后删除该文件。注意这个 `2.dex` 为合法的 `dex` 文件。
* 继续执行 `com.zj.wuaipojie2024_2.A::d` 方法
这个方法在 dex 中是被抽取了(应该是指向错误的函数实现),若查看反编译代码会得到下述代码块:
```java
return "?" + a2 + "?";
```
因此需要得到修复后的 `2.dex` 来继续下一步分析。
另外之前在 `com.zj.wuaipojie2024_2.D` 晃悠时看到了一些和签名检查相关的代码。
因此以防万一,先用「MT 管理器」去个签。
※ 如果你没有 `root` 权限,在「去签」后可以「注入文件提供器」,将应用内部文件暴露给第三方文件管理器访问。
稍微翻了翻代码,并没有发现 `decode.dex` 这个文件哪来的。只能猜是谜语人在搞事情…
一番尝试后,发现将 `1.dex` 拷贝到此处后重试即可继续。
回到主题。`getStaticMethod` 方法调用的 `fix` 里面做了很多事情。
我对安卓也不熟悉,照搬可能会比较麻烦;而且它会在释放并加载 dex 后删除,触发事件后也找不到文件。
但如果能想办法让它生成这个文件但是不删除,不就能继续分析了吗?
于是使用「MT 管理器」打开 `05.apk:/assets/classes.dex`,定位到 `C::getStaticMethod` 并删除所示两行:
```diff
@ 此为 diff 片段
.line 48
- invoke-virtual {p1}, Ljava/io/File;->delete()Z
.line 49
new-instance p3, Ljava/io/File;
invoke-virtual {p1}, Ljava/io/File;->getName()Ljava/lang/String;
move-result-object p1
invoke-direct {p3, p0, p1}, Ljava/io/File;-><init>(Ljava/io/File;Ljava/lang/String;)V
- invoke-virtual {p3}, Ljava/io/File;->delete()Z
:try_end_44
.catch Ljava/lang/Exception; {:try_start_1 .. :try_end_44} :catch_45
```
※ 在行前添加 `#` 符号将该行标记为注释亦可。
随后退出,让「MT 管理器」将更改写回 APK、重新安装、启动、随意输入一个手势密码触发密码校验事件来生成 `2.dex`。
回到「MT 管理器」并访问数据目录 `/data/data/com.zj.wuaipojie2024_2/app_data/`,可以看到 `2.dex` 已经正常生成。
此时使用「MT 管理器」打开该文件,可以发现 `com.zj.wuaipojie2024_2.A::d` 的代码已经出现(下述代码经过整理):
```java
public static String d(Context context, String password) {
MainActivity.sSS(password); // Frida 检测,不用管它
// 签名验证
String signInfo = Utils.getSignInfo(context);
if (signInfo == null || !signInfo.equals(C.SIGNATURE)) {
return "";
}
// 校验密码
StringBuffer sb = new StringBuffer();
for(int i = 0; i < 40; i++) {
// 获取下一位密码
String needle = "0485312670fb07047ebd2f19b91e1c5f".substring(i, i + 1);
// 若是还没取出过该字符,则加入到密码中
if (!sb.toString().contains(needle)) sb.append(needle);
// 最多取出 9 位
if (sb.length() >= 9) break;
}
// 将输入与密码比对
return sb.toString() == password ? "" : "唉! <成功提示> 坐标在 B.d";
}
```
密码照着跑一遍也可以得出;实际上就是取出前九位的内容 `048531267`。
提示信息的结尾则是提示还有内容藏在 `com.zj.wuaipojie2024_2.B::d` 中,但是这个方法也无法正确反编译。
如果你跟着源码操作到此处,你可能记得在 `MainActivity::checkPassword`
中曾经传入的一个神奇的值:`.getIntArray(R.array.A_offset))`
如果你查看 `A_offset`的定义(JADX 中可以双击查看),发现它的下面有个类似的 `B_offset`:
```java
public static final class array {
public static int A_offset = 0x7f030000;
public static int B_offset = 0x7f030001;
}
```
我们刚才修复的是 `A`,传入参数是 `A_offset`;那如果改成 `B_offset` 呢?
和之前的操作一样,使用「MT 管理器」打开 `05.apk:/classes.dex`。
接着定位到方法 `MainActivity::checkPassword`,将 `A_offset` 更改为 `B_offset`:
```diff
@ 此为 diff 片段
move-result-object p1
- sget v3, Lcom/zj/wuaipojie2024_2/R$array;->A_offset:I
+ sget v3, Lcom/zj/wuaipojie2024_2/R$array;->B_offset:I
invoke-virtual {p1, v3}, Landroid/content/res/Resources;->getIntArray(I)[I
```
然后重复「安装、启动、触发密码检查」步骤,得到一个新的 `2.dex`;称做 `2_B.dex` 吧 🙂
打开它,查看 `com.zj.wuaipojie2024_2.B` 对应的 Java 代码,得到 flag 计算提示:
```java
public static String d(String str) {
return "机缘是{" + Utils.md5(Utils.getSha1("password+你的uid".getBytes())) + "}";
}
```
这两个方法照搬即可。注意 `md5` 方法内「转十六进制代码」的补零部分有点毛病,不清楚校验服务器会不会照搬这个「特性」。
最后,将上面获得的信息使用 Kotlin 编写 flag 获取程序:
```kotlin
import java.math.BigInteger
import java.security.MessageDigest
// com.zj.wuaipojie2024_2.Utils::getSha1
fun sha1(arg1: ByteArray): ByteArray {
return MessageDigest.getInstance("SHA").digest(arg1)
}
// com.zj.wuaipojie2024_2.Utils::md5
fun md5(arg3: ByteArray): String {
val digest = MessageDigest.getInstance("md5").digest(arg3)
var result: String = BigInteger(1, digest).toString(16)
// 补 0 的代码很怪哦…
var i = 0
while (i + result.length < 0x20) {
result = "0$result"
i++
}
return result
}
fun getPassword(): String {
val set = mutableSetOf<Char>()
val sb = StringBuffer()
// 实际上就是取出了前九位不重复字符做手势密码, "048531267"
for (chr in "0485312670fb07047ebd2f19b91e1c5f") {
if (sb.length >= 9) break
if (set.contains(chr)) continue
set.add(chr)
sb.append(chr)
}
return sb.toString()
}
fun main() {
val password = getPassword()
val uid = 176017 // 论坛 UID
val flag = md5(sha1("$password$uid".toByteArray()))
println(flag)
}
```
结语:验证口令的地方给我接受 `flag{...}` 的写法啦!
#### 可选 / 过 Frida 检测
编辑 `05.apk:/classes.dex`,在 `MainActivity` 作出如下更改:
(1) 删除 so 加载代码
```diff
@ 此为 diff 片段
.method static constructor <clinit>()V
.registers 1
- const-string v0, "52pj"
- .line 26
- invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
return-void
.end method
```
(2) 将 Native 声明部分改写,删除 `native` 并填充为空函数:
```diff
@ 此为 diff 片段
-.method public static native sSS(Ljava/lang/String;)V
+.method public static sSS(Ljava/lang/String;)V
+ .registers 1
+ return-void
.end method
```
拿到 apk 的时候稍微看了下 so,没发现什么有用的东西,只看到 frida 检测的部分。因为没用到 frida,所以过不过检测都没事。
### 解题领红包之七 {Android 高级题}
> 出题老师:qtfreet00
和去年一样上了混淆(基于 LLVM 的控制流混淆等),放弃。 大佬还是强 本帖最后由 爱飞的猫 于 2024-2-26 09:45 编辑
此为 Web 题解。
- [返回总导航](https://www.52pojie.cn/thread-1893086-1-1.html#pid49547087)
- [解题领红包之八 {Web 初级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_解题领红包之八-{web-初级题})
- (https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag1)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag2)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag3)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag4)、[动态 flag - A](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_动态-flag---a)
- [解题领红包之九 {Web 中级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_解题领红包之九-{web-中级题})
- (https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag5)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag6)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag7)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag8)、[动态 flag - B](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_动态-flag---b)
- [解题领红包之十 {Web 高级题}](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_解题领红包之十-{web-高级题})
- (https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag9)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag10)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag11)、(https://www.52pojie.cn/thread-1893086-1-1.html#49547134_flag12)、[动态 flag - C](https://www.52pojie.cn/thread-1893086-1-1.html#49547134_动态-flag---c)
## 前言/注意事项
使用工具:
* 编辑工具 - (https://code.visualstudio.com/)
* 图像处理 - (https://www.gimp.org/)
* 终端 - 部分指令可能需要一个 Linux 环境才能正常使用,例如 WSL 2:
* 视频处理 - (https://ffmpeg.org/download.html)
* 网络请求 - (https://curl.se/)
该文章可能对新手不友好的地方:
* 你需要熟悉浏览器开发者工具(网络、调试器、审查元素)
* 你需要熟悉部分 HTML、JavaScript 或 CSS 知识。
* 你可能需要使用终端或类 Unix 环境来执行部分命令。
在 WSL 2 Ubuntu 或 Ubuntu 环境可以使用下述指令安装上述所需要的终端指令:
```sh
sudo apt update-y
sudo apt install -y ffmpeg curl
```
相比去年,没有那么多套路(去年有东西藏在音频里),更多的是在算法和技术上的对抗了。
动态题调试题 C 会根据 UID 不同而返回不同的校验条件。你需要在登录界面使用我的 UID `176017` 来复现与本文相同的题目。
※ Web 题源码已公布,可以在 (https://github.com/ganlvtech/52pojie-2024-challenge) 查看源码。
※ 动态密码因为是根据时间和/或 UID 来动态生成而没有太多参考意义,本文将其具体 flag 值替换为 `1a2b3c4d`。
## 原始视频
* (https://www.bilibili.com/video/BV1ap421R7VS)
* (https://github.com/ganlvtech/52pojie-2024-challenge/blob/d0f7243/吾爱破解2024年春节解题红包视频.mp4)
## 视频中隐藏的 flag
为了方便后续处理,你可能需要提前提取出原始视频的所有帧。
执行下述指令抽取视频内的所有帧:
```sh
# 「#」开头的行可以不用复制执行,这是一行注释。
# ↓ 建立名为 `frames` 的目录
mkdir frames
# ↓ 使用 ffmpeg 以 30fps 提取视频的每一帧内容,并存储至 `frames` 目录。
ffmpeg -i "吾爱破解2024年春节解题红包视频.mp4" -r 30/1 frames/%03d.png
```
其中初级的 1、3 题和中级的 7 题提示信息隐藏在视频中。
此外,在视频的 `00:03` 至 `00:08` 处展示了被切割为 4 块的 QR 码图案,将对应的四帧提取出来合并:
(不需要抠得太细;直接把字抠掉后半透明叠加,合并所有图层,然后应用颜色阈值处理即可)
其它的 flag 都在 [`2024challenge.52pojie.cn`](https://2024challenge.52pojie.cn/) 这个域名下对抗。
相比去年,大部分 flag 都标注好应该在哪个页面寻找,不需要靠蒙了。
此外建议在访问该域名时,全程开启 Charles (或其它中间人流量分析程序)来捕捉流量,方便查找漏掉的 flag。
## 解题领红包之八 {Web 初级题}
> 出题老师:Ganlv
感想:思路大概是不需要使用特别的工具也能找到吧。
### flag1
> 源码定位:[`/gen_flag1`](https://github.com/ganlvtech/52pojie-2024-challenge/tree/9fb4cb1/gen_flag1)
视频 `00:02~00:03` 部分有个波纹点阵效果。将对应的帧提取到独立的图层,并调整为半透明的状态叠加起来。
不需要处理得很完美,只要能隐约看到即可;在菜单选择「颜色(Color)」→「阈值(Threshold)」,调整强度直到文字清晰可见。
得到 `flag1{52pj2024}`。
### flag2
> 源码定位:[`服务器/main.go:207`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/main.go#L207-L210)
输入 `uid` 登入,发现 `/auth/login` 请求完成后会重定向到 `/`:
```http
GET / HTTP/1.1
Host: 2024challenge.52pojie.cn
HTTP/1.1 302 Found
X-Flag2: flag2{xHOpRP}
Location: /index.html
```
得到 `flag2{xHOpRP}`。
### flag3
> 源码定位:[`/gen_flag3`](https://github.com/ganlvtech/52pojie-2024-challenge/tree/9fb4cb1/gen_flag3)
flag3 在视频第一秒。将前两帧叠加并将顶部的图层设置为「不同内容(Difference)」。
然后切换到「移动图层」工具,使用方向键左右移动其中一个图层,直到背景重叠消除。
最后,和 flag1 一样,加个阈值过滤来增强可读性:
得到 `flag3{GRsgk2}`。
### flag4
> 源码定位:[`/gen_flag4_flag10`](https://github.com/ganlvtech/52pojie-2024-challenge/tree/9fb4cb1/gen_flag4_flag10)
查看首页源码,发现 `flag4_flag10.png` 图片资源。
下载下来并使用 GIMP 打开,得到透明背景白色字符的图片一张。
得到 `flag4{YvJZNS}`。
### 动态 flag - A
> 源码定位:
> - `/auth/login` - [`服务器/main.go:53`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/main.go#L53-L58)
> - `/auth/uid` - [`服务器/main.go:70`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/main.go#L70-L72)
登录后会设置 Cookie ,内容是加密的。
```http
POST /auth/login HTTP/1.1
Host: 2024challenge.52pojie.cn
Content-Length: 10
uid=176017
HTTP/1.1 302 Found
Location: /
Set-Cookie: uid=VrF5LAP/icJXSrouDIa+vsjQjUQErhlUxSgVAkQV5zpETg==; path=/; SameSite=Lax
Set-Cookie: flagA=Ynkg54ix6aOe55qE54yrIChqaXh1bi51aykgbGljL0NDLWJ5LUNBIDQuMA==; expires=...; path=/; SameSite=Lax
```
应该是使用某种加密算法保护该值(说不定是同一套哦)。
观察首页,脚本会通过另一个接口来获取当前用户 uid 并展示:
```js
fetch('/auth/uid').then(res => res.text()).then(res => {
if (res) {
document.querySelector('#uid').textContent = res;
document.querySelector('#logout-form').style.display = '';
document.querySelector('#login-form').style.display = 'none';
}
});
```
改写一下请求,将 `flagA` 的 Cookie 值挪到 `uid` 中再次请求(Windows 下需要将 `\` 和后面的换行删除):
```sh
curl "https://2024challenge.52pojie.cn/auth/uid" \
-H "Cookie: uid=Ynkg54ix6aOe55qE54yrIChqaXh1bi51aykgbGljL0NDLWJ5LUNBIDQuMA=="
```
可以发现程序已经错误的将我们传入的值成功解密了,得到 `flagA{1a2b3c4d}`。
## 解题领红包之九 {Web 中级题}
> 出题老师:Ganlv
感想:需要一点运气,或花时间暴力穷举也能得到答案。
### flag5
首页右键「查看源码」,发现标记 `<!-- flag5 flag9 -->`,紧跟着 `<pre>` 预格式化标签。
看着头疼,对着 CSS 一通乱改(修改排版)后可以看到密文了:
```css
pre {
width: 110.9em !important;
color: green !important;
user-select: all !important;
word-break: break-all !important;
padding-left: 10px;
font-family: monospace;
z-index: 9 !important;
position: relative !important;
}
```
得到 `flag5{P3prqF}`。
※ 如果显示不正确,也可以在浏览器控制台执行以下代码来强制按照字符数量换行:
```js
const text = document.querySelector('pre').textContent.trim();
document.body.textContent = text.replace(/(.{221})/g, "$1\n");
document.body.style.cssText = `
font-family: monospace;
padding: 2em;
color: green;
`;
```
### flag6
根据首页提示访问 [`/flag6/index.html`](https://2024challenge.52pojie.cn/flag6/index.html)。
界面很简洁,就一个计算按钮。直接查看网页源码,并稍作整理:
```js
document.querySelector("button").addEventListener("click", () => {
const t0 = Date.now();
for (let i = 0; i < 1e8; i++) {
if ((i & 0x1ffff) === 0x1ffff) ; // 进度更新,忽略
// 检查当前序号是否等于相应的 MD5 值
if (MD5(String(i)) === "1c450bbafad15ad87c32831fa1a616fc") {
// 根据格式构造需要的 flag。
document.querySelector("#result").textContent = `flag6{${i}}`;
break;
}
}
});
```
可以发现就是枚举 `0~1e8 (1_0000_0000)` 范围内的所有数字,检查其 MD5 值是否相等。
已知 MD5 不可逆,但我们可以查表;直接拜访 (https://www.cmd5.com/) 查询即可。
得到对应的明文为 `20240217`,再根据代码构建对应的 flag 字符串 `flag6{${i}}`。
最终得到 `flag6{20240217}`。
※ 彩虹表也可以哦。
### flag7
视频 `00:21` 处有一个地址,指向 GitHub 仓库 [`ganlvtech/52pojie-2024-challenge`](https://github.com/ganlvtech/52pojie-2024-challenge)。
观察提交记录,可以发现「[`6bbac03` 删除不小心提交的flag内容](https://github.com/ganlvtech/52pojie-2024-challenge/commit/6bbac03)」。
点开该提交记录,得到 `flag7{Djl9NQ}`。
### flag8
> 源码定位:
> - 接口定义: [`服务器/main.go:101`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/main.go#L101-L204)
> - 游戏实现: [`服务器/game2048`](https://github.com/ganlvtech/52pojie-2024-challenge/tree/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/game2048)
根据首页提示访问 [`/flagB/index.html`](https://2024challenge.52pojie.cn/flagB/index.html)。
写个脚本硬刷即可,从 (https://github.com/ovolve/2048-AI) 抠了些代码来帮助判断下一步走向。
使用方法:
* 解压到某处
* 打开终端,使用 NodeJS v20 启动服务端:`npm start`
* 打开从压缩包解压/展开的 `user.js`,将其复制到浏览器的开发者控制台即可。
※ 其实也可以不依赖服务器,直接将整段代码塞到前端里的… 这个就交给读者了
全部配置好后的效果如下:
预估速率是每分钟 1000 到 2000 金币左右,因此挂个十几分钟,等刷到 10000 时停止即可;刷金币的时候可以先看看其它题。
购买一个 `flag8` 然后使用,得到 `flag8{OaOjIK}`。
※ 如果不想跑脚本,也可以修改为我跑到 2 万分的 Cookie:
```text
game2048_user_data=7gi/Ba+cqqXN3+BpaIGvkt/2i39JIT2a6+9HUNQ7g5VlF624rYoRFXQpRwaRXKH808iY9lwLuKVv5OZpNwtAca6n36qPUpQETcQnjOhWYEQnlHEx0uu/VpCoHzTjTVCzTFKFAYULuHMrQH0giedmNqWmnCf/EKmRLg9cihRCZ3AxwUoS1loZVEymxH4nNQ==
```
解密后(还记得动态口令 A 吗?):
```json
{
"game_data": {
"tiles": ,
"score": 384,
},
"money_count": 20332,
"double_money_count": 1
}
```
### 动态 flag - B
> 源码定位:
> - 接口定义: [`服务器/main.go:101`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/main.go#L101-L204)
> - 游戏实现: [`服务器/game2048`](https://github.com/ganlvtech/52pojie-2024-challenge/tree/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/game2048)
根据首页提示访问 [`/flagB/index.html`](https://2024challenge.52pojie.cn/flagB/index.html)。
先照着 flag8 的脚本稍微刷几百个金币备用。另外该题需要在首页提前登入。
购买 3 号道具「v我50」并使用,可以得到提示 `作为奖励呢,我就提示你一下吧,关键词是“溢出”`。
试了几个大数字,要么提示我没钱 🥲,要么提示我溢出后钱比买之前还要更多,阻止请求。
灵光一闪尝试了个数字:
```js
0xFFFFFFFF_FFFFFFFF / 999063388
// => 等于 18464037713
```
将 `18464037713` 提交到购买数量,并购买 5 号道具… 成功了!显示信息如下:
| ID| 道具名称 | 说明 | 数量 | 使用 |
| --- | -------- | --------------- | ----------- | ------ |
| 5 | 消除道具 | 获取 flagB 内容 | 18464037713 | [使用] |
得到 `flagB{1a2b3c4d}` 及提示 `过期时间: 2024-02-30 00:00:00`。
对应的 Cookie:
```text
game2048_user_data=tI505/NihiSqzTIE3cMhfXPZ6sMOoCmkkkfRHljUeDL0VonRnWl5lEIFKTrY64PkkUlDdJmBKVzZrhEhQyS6wVKe7fdl5pUaHODwCgWp2N0LGwThjeupFO/PZH9ZJXJiEI4WrlwAj1bWbR6y3KVk2537/bJfqajlZ6Av3qdS11jNgCt4NiaE47pRUL5lP4/owvqZKfE85efvE6YAt3GjQdfPpaWfp9k8Xg==
```
解密后:
```json
{
"game_data": {
"tiles": ,
"score": 384,
},
"money_count": 20304,
"double_money_count": 1,
"flag_b_count": 18464037713
}
```
- ※ 注 1 - ~~还是没搞懂为什么某些数字可以某些又不可以… 得对照公开后的源码看了。~~
- 事后在[吾爱破解 2024 年春节解题领红包活动非官方题解暨吐槽](https://www.52pojie.cn/thread-1893071-1-1.html)了解到缘由。比较笼统的解释是需要保证 `价格 * 数量` 的乘积的低 64 位需要为正整数。
- 我挑的这个答案计算则刚好溢出到一个比较小的正整数 - `(18464037713 * 999063388) % (2 ** 64)`,即 `28` 金币(使用 Python 计算该表达式)。
- ※ 注 2 - 若是刷金币照每分钟 3000 金币来算,需要不停的刷 `999063388 / 3000 / 60 / 24 ≅ 231` 日,即差不多一年才能得到。
## 解题领红包之十 {Web 高级题}
> 出题老师:Ganlv
感想:相比之前的两个难度,脑洞比较大,但也留了能逃课的地方。
### flag9
参考 `flag5` 的过程得到正确拼接的图案,右侧符号所组成的图形就是我们找的旗,`flag9{KHTALK}`。
### flag10
> 源码定位:[`/gen_flag4_flag10`](https://github.com/ganlvtech/52pojie-2024-challenge/tree/9fb4cb1/gen_flag4_flag10)
参考 `flag4` 的过程得到 `flag4_flag10.png` 图片资源。
使用 GIMP 打开,切换到图层右边的「颜色通道(Channels)」选项卡,取消勾选「透明通道(Alpha)」,效果如下:
得到白底黑字的 `flag10{6BxMkW}`。
### flag11
根据首页提示访问 [`/flag11/index.html`](https://2024challenge.52pojie.cn/flag11/index.html)。
右键→选择「审查元素」,定位到 `<body>` 标签。
找到 CSS 属性面板(通常在开发者工具的右侧),找到如下 CSS 属性:
```css
:root {
--var1: 0;
--var2: 0;
}
```
点击 0 然后使用键盘的上下来调整该值吧!照着自己的直觉来完成这个拼图即可。
调整后的值为:
```css
:root {
--var1: 71;
--var2: 20;
}
```
效果如下:
得到 `flag11{HPQfVF}`。
### flag12
> 源码定位:[`flag12/src/lib.rs:9`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/flag12/src/lib.rs#L9)
根据首页提示访问 [`/flag12/index.html`](https://2024challenge.52pojie.cn/flag12/index.html)。
是一个 WebAssembly 题目:
```js
WebAssembly.instantiateStreaming(fetch('flag12.wasm'))
.then(({instance}) => {
const get_flag12 = (secret) => {
let num = instance.exports.get_flag12(secret);
let str = '';
while (num > 0) {
str = String.fromCodePoint(num & 0xff) + str;
num >>= 8;
}
return `flag12{${str}}`;
}
document.querySelector('button').addEventListener('click', (e) => {
e.preventDefault();
document.querySelector('#result').textContent
= get_flag12(parseInt(document.querySelector('input').value));
});
});
```
看起来很唬人,有用的内容不多;基本上就是将输入(整数型)传递给 WASM 的一个函数调用,并组合成 flag。
编译后的 WASM 没有额外的内容,分析起来不是很难(开发者工具的网络标签页选择 wasm 文件,然后选择「预览/Preview」选项卡):
```js
(module
(memory $memory (;0;) (export "memory") 16)
(global $__stack_pointer (;0;) (mut i32) (i32.const 1048576))
(global $__data_end (;1;) (export "__data_end") i32 (i32.const 1048576))
(global $__heap_base (;2;) (export "__heap_base") i32 (i32.const 1048576))
(func $get_flag12 (;0;) (export "get_flag12") (param $var0 i32) (result i32)
i32.const 1213159497 // 入栈两个值
i32.const 0
local.get $var0
i32.const 1103515245
i32.mul // 将输入与该值相乘
i32.const 1
i32.eq // 对比结果是否等于 1
select // 根据对比结果,选择一开始入栈的两个值其中一个
)
)
```
因为在得到正确答案时返回 `0` 的可能性太低,我们可以跳过具体的验证过程,猜它在正确的情况下返回 `1213159497` 即可。
将其补回原始代码,放到浏览器执行:
```js
const get_flag12 = (secret) => {
let num = secret;
let str = '';
while (num > 0) {
str = String.fromCodePoint(num & 0xff) + str;
num >>= 8;
}
return `flag12{${str}}`;
}
console.log(get_flag12(1213159497));
```
得到 `flag12{HOXI}`。
### 动态 flag - C
> 源码定位:
> - 接口定义: [`服务器/main.go:74`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/main.go#L74-L98)
> - 校验逻辑: [`服务器/yolov5verify/api.go:L239`](https://github.com/ganlvtech/52pojie-2024-challenge/blob/9fb4cb1/%E6%9C%8D%E5%8A%A1%E5%99%A8/yolov5verify/api.go#L239)
根据首页提示访问 [`/flagC/index.html`](https://2024challenge.52pojie.cn/flagC/index.html)。
很有趣的一个题目,在浏览器进行图像识别,然后将识别结果推送到远程服务器,最后服务器告知那些判定是正确或错误。
为了方便调试,首先抠代码出来备用:
```js
const modelHeight = 640;// yolov5.inputs.shape;
const modelWidth = 640;// yolov5.inputs.shape;
const imgSource = document.querySelector('img');
const imgHeight = imgSource instanceof HTMLVideoElement ? imgSource.videoHeight : imgSource.naturalHeight;
const imgWidth = imgSource instanceof HTMLVideoElement ? imgSource.videoWidth : imgSource.naturalWidth;
const fittedWidth = Math.min(modelWidth, Math.floor(modelHeight * imgWidth / imgHeight));
const fittedHeight = Math.min(modelHeight, Math.floor(modelWidth * imgHeight / imgWidth));
const verify = (boxes, scores, classes) => fetch('/flagC/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
boxes,
scores,
classes,
}),
credentials: "include",
}).then((res) => res.json()).then((res) => {
const {hint, labels, colors} = res;
document.querySelector('#result').textContent = hint; // 错误时显示提示,正确时显示 flag
// 绘制识别结果
document.querySelector('svg').setAttribute('viewBox', `0 0 ${1000 * fittedWidth / modelWidth} ${1000 * fittedHeight / modelHeight}`);
document.querySelector('svg').innerHTML = labels.map((label, i) => ({
box: boxes.slice(i * 4, i * 4 + 4),
label,
color: colors,
})).filter((item) => item.label !== '').map((item) => `<g>
<rect x="${1000 * item.box}" y="${1000 * item.box}" width="${1000 * (item.box - item.box)}" height="${1000 * (item.box - item.box)}" fill="none" stroke="#${item.color}" stroke-width="2"></rect>
<text x="${1000 * item.box}" y="${1000 * item.box - 2}" font-size="${16}" fill="#${item.color}">${item.label}</text>
</g>`).join('');
console.log('hint: %s', hint);
});
// 可选:将图片变成纯黑
const $img = document.querySelector('img');
$img.style.opacity = 0;
$img.parentNode.style.background = 'black';
```
首先尝试看看都有什么分类可以用:
```js
verify(new Array(100*4).fill(0), new Array(100).fill(0.9), new Array(100).fill(0).map((x, i) => i));
```
查看网络选项卡,可以看看返回信息:
请求了 100 个类型,有 80 个类型返回结果,但只有两个是合法的类别:
```text
6: train 种类正确 位置错误
9: traffic light 种类正确 位置错误
```
然后就是继续构建测试代码:
```js
const CLASS_TRAIN = 6;
const CLASS_TRAFFIC_LIGHT = 9;
async function scan_grid(value) {
const boxes = [];
const scores = [];
const classes = [];
const addItem = (x, y, step, value) => {
const offset = (step - 0.02) / 4;
boxes.push(x + offset, y + offset, x + step, y + step);
scores.push(0.9 + Math.random() / 10);
classes.push(value);
};
const step = 0.1;
for (let x = 0; x < 1; x += step) {
for (let y = 0; y < 1; y += step) {
addItem(x, y, step, value);
}
}
return verify(boxes, scores, classes);
}
// 手动调用扫描
scan_grid(CLASS_TRAIN);
scan_grid(CLASS_TRAFFIC_LIGHT);
```
扫描结果如下:
然后就是根据反馈来构建请求了:
```js
async function submit_answer() {
const boxes = [];
const scores = [];
const classes = [];
const addItem = (x, y, step, value) => {
const offset = (step - 0.02) / 4;
boxes.push(x + offset, y + offset, x + step, y + step);
scores.push(0.9 + Math.random() / 10);
classes.push(value);
};
const addByIdx = (idx, step, value) => {
let grid_width = (1 / step) | 0;
let x = idx % grid_width | 0;
let y = (idx / grid_width) | 0;
addItem(x * step, y * step, step, value);
};
const addByIdxPos = (idx_x, idx_y, step, value) => {
let grid_width = (1 / step) | 0;
addByIdx(idx_y * grid_width + idx_x, step, value);
};
// 加入火车
addByIdxPos(0, 0, 0.1, CLASS_TRAIN);
addByIdxPos(0, 5, 0.1, CLASS_TRAIN);
// 加入红绿灯
addByIdxPos(5, 0, 0.1, CLASS_TRAFFIC_LIGHT);
addByIdxPos(5, 5, 0.1, CLASS_TRAFFIC_LIGHT);
await verify(boxes, scores, classes);
}
submit_answer();
```
执行上述代码后的效果:
得到 `flagC{1a2b3c4d}`。
强势围观{:301_1000:} 大佬牛掰 我发现论坛的大佬很多.我是真不会{:1_907:} 爱飞的猫 发表于 2024-2-25 06:22
此为 Web 题解。
其中 1、2、3、4、A 为初级;5、6、7、8、B 为中级;以及 9、10、11、12、C 为高级 ...
这个对于小白的我 绝对是天书既视感 流程很清晰,部分知识是我的盲区,还得不断学习了{:1_921:}{:1_921:}