爱飞的猫 发表于 2024-2-25 04:30

吾爱破解 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-25 04:50

本帖最后由 爱飞的猫 于 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,这样的话又能继续分析了。

> 强壳我唯唯诺诺,没壳我重&#128074;出击!

本题利用了很多反调试检测和异常处理机制来增大逆向分析难度。

> 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 时的时候有人报告验证错误也修正了问题,但没给我补发提交次数,继续哭哭… 还好次日凌晨提交成功,至少证明我当时的分析是没有问题的 &#128526;

题外话:我在[去年 8 月制作的一个 CM](https://www.52pojie.cn/thread-1817466-1-1.html) 也用到了类似的加密方案来处理密钥(无壳无反调试),没看到有人折腾出有效的算法注册机。欢迎挑战哦。


爱飞的猫 发表于 2024-2-25 06:06

本帖最后由 爱飞的猫 于 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` 吧 &#128578;

打开它,查看 `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 的控制流混淆等),放弃。

LuckyClover 发表于 2024-2-25 07:32

大佬还是强

爱飞的猫 发表于 2024-2-25 06:22

本帖最后由 爱飞的猫 于 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」并使用,可以得到提示 `作为奖励呢,我就提示你一下吧,关键词是“溢出”`。

试了几个大数字,要么提示我没钱 &#129394;,要么提示我溢出后钱比买之前还要更多,阻止请求。

灵光一闪尝试了个数字:

```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}`。

嘟嘟 发表于 2024-2-25 07:38

强势围观{:301_1000:}

仿佛_一念成佛 发表于 2024-2-25 07:43

大佬牛掰

flash1688 发表于 2024-2-25 07:55

我发现论坛的大佬很多.我是真不会{:1_907:}

yu8556 发表于 2024-2-25 09:12

爱飞的猫 发表于 2024-2-25 06:22
此为 Web 题解。

其中 1、2、3、4、A 为初级;5、6、7、8、B 为中级;以及 9、10、11、12、C 为高级 ...

这个对于小白的我 绝对是天书既视感

jackyyue_cn 发表于 2024-2-25 09:14

流程很清晰,部分知识是我的盲区,还得不断学习了{:1_921:}{:1_921:}
页: [1] 2 3
查看完整版本: 吾爱破解 2024 春节红包活动题解(除七)