吾爱破解 - LCG - LSG |安卓破解|病毒分析|www.52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 2274|回复: 26
收起左侧

[CTF] 吾爱破解 2024 春节红包活动题解(除七)

[复制链接]
爱飞的猫 发表于 2024-2-25 04:30
本帖最后由 爱飞的猫 于 2024-2-27 09:31 编辑

今年参与「吾爱破解 2024 春节红包活动」做的题解。除了「七/安卓高级题」以外,其它活动均挑战成功。

本届春节红包活动有三大方向,分别是 Windows、Android 和 Web 方向。

题解导航

更新记录

  • 2024.02.26
    • 重新分析了下 Win 高级题中的码表计算(天堂之门的 64 位代码)以及调试器检测部分(SEH/硬件断点)。
    • 修正部分错字,重新理了下 Win 高级题的流程。希望能够更容易理解一些。
    • 重新设计了导航。
  • 2024.02.27
    • 在“解题领红包之六 {Windows 高级题}”添加了简易的可行脱壳方案。

免费评分

参与人数 7吾爱币 +21 热心值 +5 收起 理由
侃遍天下无二人 + 4 + 1 用心讨论,共获提升!
DaKxhq54zDH + 1 我很赞同!
ts73033 + 1 + 1 感谢您的宝贵建议,我们会努力争取做得更好!
Hovard + 1 + 1 谢谢@Thanks!
dongye + 1 用心讨论,共获提升!
正己 + 10 + 1 给师傅打call!
苏紫方璇 + 3 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

 楼主| 爱飞的猫 发表于 2024-2-25 04:50
本帖最后由 爱飞的猫 于 2024-2-27 09:34 编辑

Windows 题解在此。

前言/注意事项

使用工具:

该文章可能新手不友好的地方:

  • 你需要熟悉 x64dbg 以及 IDA Pro 的基础操作。
  • 你需要熟悉 C/C++ 语法
    • 本文会引用 C++ 2020 新增的语言特性。
  • 部分基础或算法无关内容的操作可能被省略。
    • 如果你觉得部分内容需要更详细的解释,可以发表评论指出。

前置信息/MSVC 容器结构体

使用微软 Visual Studio(MSVC)编译的 std::string 容器,大致可以用如下结构体来描述:

struct MsvcStdString {
  union {
    char[16] inlined;
    char* far_ptr;
  } buff; // sizeof(buff) = 16

  size_t length;
  size_t capacity;
}

其它容器的结构体基本也大同小异 - 指针或在结构体内储存;含有一个 capacitylength 成员表示其最大大小和当前大小。

解题领红包之二 {Windows 初级题}

出题老师:云在天

直接打开程序观察行为。

根据提示随机输入测试字符串「123456」回车,得到提示字符串「Error, please try again」:

Please input password:
123456
Error, please try again
请按任意键继续. . .

因为开始前偷偷用调试器看了下区段名,没有加壳;直接偷懒使用 IDA 打开程序;随手翻翻整理下,代码还是比较清晰的。

在一个看着像 memcmp 的实现后面,紧接着提示成功或失败字符串:

if ( flag_code_incorrect ) {
  msg = "Wrong,please try again.";
} else {
  msg = "Success";
}

反推条件,有一个检查是否等于 36,以及几个循环检查输入内容是否相等 ⇒ 检查输入字串长度 + 内容。

检查字符串内容前有两个可疑的调用;第二个函数是「解密密文」:

_004022C0:
  mov  edx, 0FFFFFFFDh ; "-3"
  lea  ecx, [ebp-0x40]
  call sub_401FE0
_004022CD:
  add esp, 0x18

直接在「解密」结束后的 004022CD 下一个断点,然后输入构造的 36 位随机密码,等待断点触发。

触发后可以发现内存 [ebp-0x40] 处存在我们要找的 flag,将其提交即可:

01216D40  66 6C 40 67 7B 48 40 70 70 79 5F 4E 33 77 5F 65  fl@g{H@ppy_N3w_e  
01216D50  40 72 21 32 6F 32 34 21 46 69 67 68 74 69 6E 67  @r!2o24!Fighting  
01216D60  21 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 循环逐字节解密的实现即可:

for ( ; i != len; ++i )
  *v6++ += key % 26;

简单来说将整个字符串循环一遍,然后每次和 key 相加(参数,为固定值 -3)。

对应的 C++ 代码实现:

#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 名的命名共享内存来通信。

脱壳

本文的脱壳步骤需要提前将两个可执行文件的 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)后回车。

pe-bear_client.png

最后右键文件并选择【另存为】,输入或选择现有的文件名来保存即可。

pe-bear_client_save.png

提示:你也可以使用 StudyPE+ 来“一键固定基址”,原理是一样的:

study_pe_fixed_addr.png

客户端脱壳

客户端程序 crackme2024.exe 是 x64 程序,使用魔改的 UPX 壳。

推荐参考内容:

下方为适用于该挑战的脱壳教程:

  • 使用 x64dbg 打开该程序。
    • 如果地址不是 0000000140027A00,那么你可能停在了 ntdll 的系统入口。按下 F9 继续运行即可。
  • 按下 Ctrl-B 打开搜索框
    • 在底下随意输入几个 00,例如重复 5 次的 00 00 00 00 00
    • (注意这是 UPX 壳的特性,其它壳不一定适用)
    • 回车确认来完成搜索
  • 检索完毕后,第一条检索命中的地址应为 0000000140027C49
    • 双击进入该地址
    • 向上滚动,找到 E9 大跳 - 0000000140027C40 | E9 97BCFDFF | jmp crackme2024.1400038DC |
      x64dbg_upx_jmp_oep.png
    • 选中它,并按下 F4 让程序运行到该行,然后再按下 F8 步进到 00000001400038DC 这个地址。
  • 在 x64dbg 的菜单,选择【插件】→【Scylla】
    • 此时 OEP 应当自动填充 00000001400038DC
    • 按下左侧的【IAT Autosearch】按钮,自动搜寻 IAT。若是询问是否使用“IAT Search Advanced result”,选择【是】。随后确定。
    • 按下左侧的【Get Imports】按钮,获取导入表。此时上方导出应看见 kernel32.dllole32.dll
    • 按下右侧的【Dump】按钮,将进程转储到硬盘,默认为 crackme2024_dump.exe
    • 按下右侧的【Fix Dump】按钮,选择刚刚 Dump 时写出的文件名。生成 crackme2024_dump_SCY.exe

这个新的 crackme2024_dump_SCY.exe 就是脱壳后可正常运行的可执行文件了。

服务端脱壳

服务端程序 crackme2024service.exe 是 x86 程序,使用 ASPack 压缩壳处理。

对脱壳有兴趣可以参考下述教程:

和客户端的处理大同小异,但是这次使用 ESP 定律来做;下方为适用于该挑战的脱壳教程:

  • 使用 x32dbg 打开该程序,确保调试器停在了 00424001 这个入口处。如果不是,尝试按下 F9 继续执行即可。
  • 按一下 F8 步进来执行 pushad 指令,当前指令地址切换到 00424002
  • 在下方的命令窗口,输入 bph esp, r, 1 并回车来对 ESP 地址设置访问断点:
    x32dbg_aspack_dump.png
    • 成功后将提示成功设置断点 已于 xxxxxxxx 处设置硬件断点!
    • 如果你重复执行该指令,则会提示 该地址已存在硬件断点!

此时按下 F9 继续执行,可以看到如下指令:

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 来步进:

00407170 | E8 91040000 | call 0x00407606 | <- 最后一次步进后,抵达此处
00407175 | E9 78FEFFFF | jmp 0x00406FF2  |
0040717A | CC          | int3            |

抵达地址 0x407170 后,同刚才客户端的 Scylla 工具步骤一样,转储内存并修正导入表即可。

与刚才不同的一点是,按下【Get Imports】后会显示一个 ❌ 标记的项目,右键它并选择【Delete tree node】即可:

x32dbg_scylla_delete_node.png

客户端

脱壳 - 这个不会,脱下来的无法正常运行,因此只是脱下来方便静态分析。 逼着自己看了下坛子里的教程,整了个能脱了后还能运行的步骤。

首先看看客户端。打开时提示输入 UID 和序列号,会检查长度,要求 35 或更长。

serial_len = -1;
do {
  ++serial_len;
} while ( serial[serial_len] );                 // strlen(serial)

if ( serial_len >= 35 ) {
  if ( server_validate(uid, serial) ) {      // 正题
    // ... 省略 ...
  } else {
    printf("\nERROR: uid or serial is error\n");
  }
}

本体的通信代码是 COM 组件,不是很懂;稍微分析后进行一番整理后的代码如下:

// `vtable` 这个名字大概不准确,其实是调用 COM 的接口。

bool __fastcall server_validate(unsigned int uid, const char *serial)
{
  CrackmePpvObj *pCrackMe; // [rsp+30h] [rbp-F8h] BYREF
  uint64_t p_serial_good; // [rsp+38h] [rbp-F0h] BYREF
  char szSerial[208]; // [rsp+40h] [rbp-E8h] 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 混淆算法如下:

uint64_t __fastcall sub_7FF695491A90(uint64_t a1) {
  return (a1 * a1 * a1) ^ uint64_t{0x323032796C6C6F73};
}

服务端

不是很知道怎么下手,直接翻翻字符串,在 00418F38 发现 crackme2024 字样。

下面没多远就是 C++ 的类函数指针集合了。

.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。

int CrackMeObjSolly__SetUID(CrackMeObjSolly *ctx, int uid)
{
  *(_DWORD *)&ctx->p_map_view[8 + ctx->code_has_ebfe] = 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

通知服务器当前密钥。

代码稍微整理下:

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[array_idx++] = decoded;
    p_serial = p_next;
  }
  // 写出最后一个 '-' 号后的值
  ctx->decoded_serial[array_idx] = strtoul(p_serial, nullptr, 16);

  // 记录共享内存的两个值
  ctx->mapview_offset_00 = *(uint32_t*)ctx->p_map_view[0];
  ctx->mapview_offset_04 = *(uint32_t*)ctx->p_map_view[4];
  return 0;
}

简单来说就是使用 - 符号来分割,每一段都交给 strtoul 来将字符作为十六进制文本解析,并依次放入 ctx->decoded_serial 中。

Check

请求服务器检查之前的 UID 和密钥是否匹配。

前两个方法看起来还不算太难;那就接着看看最后一个吧(代码经过手动优化整理):

int __stdcall CrackMeObjSolly__Check(CrackMeObjSolly *ctx, bool *a2)
{
  auto value_at_00 = *(uint64_t *)&ctx->mapview_offset_00;

  uint8_t decoded_str[17]{};
  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[z + 0];
    uint32_t right = p_result[z + 1];

    for(int i = 17; i >= 2; i--) {
      temp = left  ^ g_table_1[i];
      left = right ^ (g_ss_table_3[0xff & (temp >> 24)]
                    + (g_ss_table_2[0xff & (temp >> 16)]
                      ^ (g_ss_table_0[0xff & (temp)]
                        + g_ss_table_1[0xff & (temp >> 8)])
                      )); // depend on left, right
      right = temp; // depend on left
    }
    new_right = left ^ g_table_1[1];
    new_left  = temp ^ g_table_1[0];

    p_result[z + 1] = new_right;
    p_result[z + 0] = new_left;
  }

  decoded_str[16] = 0; // 截断字符串
  *a2 = value_at_00 == _strtoui64(decoded_str, 0, 16);
  return 0;
}

如果读者尝试过实现 DES 或类似的算法,会发现这个和 DES 内使用的费斯妥密码结构(Feistel cipher)很像:将数据分为左右两半,进行数轮加密(DES 中为 16 轮),每轮加密一半的数据并交换左右两半。

费斯妥密码结构

费斯妥密码结构
(图改自百科对应条目示意图;其中 F 可以是不可逆函数;K 为当轮参与 F 函数加密的密钥;L/R 代表左右两半的输入数据;⊕ 表示 XOR 操作)

逐步反推,整理后的反向加密算法如下:

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[d] + (g_ss_table_2[c] ^ (g_ss_table_0[a] + g_ss_table_1[b])));
}

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[0];
    auto left = last_right ^ g_table_1[1];

    for (int i = 2; i <= 17; i++)
    {
        auto next_right = right;
        auto next_left = left;

        left = next_right ^ g_table_1[i];
        auto result = transform_block(left ^ g_table_1[i]);
        right = result ^ next_left;
    }

    *p_left = left;
    *p_right = right;
}

通过调试验证反向加密算法

毕竟是一个非标准算法,将其当为“黑盒”来搜集输入及其对应的输出(解密内容)。

加入下述注册表选项,可以让 COM 服务器组件主程序在启动时自动附加调试器(记得修改路径):

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\crackme2024service.exe]
"Debugger"="C:\\RE\\Tools\\x64dbg\\x32\\x32dbg.exe"

构造正确格式的密钥并输入,在调用 __strtoui64 前观察其解密后的字符串内容:

.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 脚本,然后贴入下述内容:

define(address,"crackme2024.exe"+1DDC)
define(bytes,48 8B 01 FF 50 48)

[ENABLE]
assert(address,bytes)
alloc(newmem,$1000,"crackme2024.exe"+1DDC)

label(code)
label(return)

newmem:

code:
  jmp code // 如果需要继续执行,可以利用 CE 的反汇编窗口将其改为 `nop`。

  mov rax,[rcx]
  call qword ptr [rax+48]
  jmp return

address:
  jmp newmem // E9 远跳
  db 00 // 随便改改。

return:

[DISABLE]

address:
  db bytes
  // mov rax,[rcx]
  // call qword ptr [rax+48]

dealloc(newmem)

※ 注意程序会扫描 EB FE;此处利用 CE 生成的 E9 大跳跳板刚好。

启动客户端本体后启用该脚本(应用更改),这样就能在输入 UID 和密钥后唤出服务器,但不进行密钥验证以及后续过程;
此时观察任务管理器,可以发现 crackme2024.execrackme2024service.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 字节(这些数据一起,导出到一个大数组):

/* crackme2024service.exe (67890)
   起始位置(h): 0092E8D0, 结束位置(h): 0092F8CF, 长度(h): 00001000 */

unsigned char rawData[4096] = {
  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) 字节:

/* crackme2024service.exe (6908) (23/2/2024 上午 10:07:17)
   起始位置(h): 0092F8D0, 结束位置(h): 0092F917, 长度(h): 00000048 */

unsigned char rawData[72] = {
  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
};

将之前逆向出来的算法内容全部拼接到一起(留心处理下格式),就得到注册机了。

具体代码请参考下方附件的完整代码:

52pj_solly_2024.zip (10.53 KB, 下载次数: 15, 售价: 1 CB吾爱币)

UID 176017 计算出来的对应密钥并大写处理后为 ECEAD171-E26F0C10-F9758DC1-4AA28386

2023.02.26 更新 - 初始码表计算

00401B50 初始化方法中,会进行初始化码表的操作:

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[i] = sub_406610(p_table_1_data[i], 8 * next_rotl_count);// init g_table_1
}

sub_406610 则隐藏了一段 64 位程序代码,一开始分析的时候还没搞明白这是什么;后来发现 33 远跳有点耳熟,搜了下果然是以前看过的“天堂之门”技巧

将整个函数都 dump 下来到 heavens-gate.bin,然后使用 ndisasm 来分析吧。注意我在此处 dump 的基址为 0x330000,读者需要自行针对基址进行一个修正。

; 基址为 0x330000
;   32 位代码使用下述内容反汇编
;   将 00336610 至 00336663 的内存 dump 到 `heavens-gate.bin` 文件
; ndisasm -o 0x336610 -b 32 -e 0 -p intel heavens-gate.bin
00336610  55                push ebp
00336611  8BEC              mov ebp,esp
00336613  51                push ecx
00336614  8B4508            mov eax,[ebp+0x8]         ; 参数 1 - 表数据 (低位)
00336617  8B550C            mov edx,[ebp+0xc]         ; 参数 2 - 表数据 (高位) 
0033661A  8B4D10            mov ecx,[ebp+0x10]        ; 参数 3 - 移动位数
0033661D  53                push ebx
0033661E  EA256633003300    jmp 0x33:0x336625         ; 进入天堂之门(64 位代码执行模式)

; 使用 NASM 套件的 ndisasm 来使用 x64 模式反编译
;   ndisasm -o 0x336625 -e 0x15 -b 64 -p intel heavens-gate.bin
00336625  48C1E020          shl rax,byte 0x20
00336629  480FA4C220        shld rdx,rax,0x20         ; rdx = (rax & 0xFFFFFFFF) | uint64_t{rdx << 32}
0033662E  48B8736F6C6C7978  mov rax, 'sollyx64'       ; rax = 0x343678796c6c6f73
         -3634
00336638  48D3C0            rol rax,cl
0033663B  4833C2            xor rax,rdx               ; rax = (0x343678796c6c6f73 rol cl) ^ rdx
0033663E  488BD0            mov rdx,rax               
00336641  48C1EA20          shr rdx,byte 0x20         ; rdx = rax >> 0x20
                                                      ; 将高 32 位导出到 edx 中,低 32 位仍然保留在 eax 中。

                            ; 准备退出天堂之门
00336645  6A00              push byte +0x0
00336647  6857663300        push qword 0x336657
0033664C  C744240423000000  mov dword [rsp+0x4],0x23
00336654  FF2C24            jmp dword far [rsp]

; 后续回到 32 位现场,使用下述指令进行反汇编
;   ndisasm -o 0x336657 -e 0x47 -b 32 -p intel heavens-gate.bin

                            ; 还原现场 (退出时的 jmp 入栈了 2 个 uint64_t 大小值)
00336657  59                pop ecx
00336658  59                pop ecx
00336659  59                pop ecx
0033665A  59                pop ecx

                            ; 还原现场,准备退出
0033665B  5B                pop ebx
0033665C  59                pop ecx
0033665D  8BE5              mov esp,ebp
0033665F  5D                pop ebp
00336660  C20C00            ret 0xc

理解后就可以在 C++ 重新实现了:

#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 对应的初始值才能得到最终的码表。

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 处理好的码表更方便…?

如果需要参考完整的码表计算实现,可以查看下方的附件:

52pj_solly_2024_table.zip (7.27 KB, 下载次数: 1, 售价: 1 CB吾爱币)

2023.02.26 更新 - 服务器端的调试器检测与绕过

程序在 00401CEB 开始处手动注册了个结构化错误处理(SEH)处理器来接管错误:

.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:[0] 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 链表

随后立即抛出错误:

.text:00401CFF 33 04 24                                xor     eax, [esp]      ; eax 置零
.text:00401D02 31 00                                   xor     [eax], eax      ; 写空指针导致内存访问错误

来到错误处理方法:

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], eax 指令
    return 0;
  }

  // 省略 ...
}

回到源程序,eax 被设置为 0x00401D02 (字节码 31 00);获取偏移 1 位置的值则是 00

.text:00401D04 3E 0F B6 40 01                          movzx   eax, byte ptr [eax+1] ; eax = 0

.text:00401D09                         将初始值储存到 ebp-18 和 ebp-14 的缓冲区中。
.text:00401D09 89 45 EC                                mov     [ebp-14h], eax
.text:00401D0C 89 45 E8                                mov     [ebp-18h], eax

终于把正确的 Seed 给找到了。之前调试器被检测到,所以得到的初始值是 0x40 而计算错误。

另外如果需要补丁服务器端来绕过检测,可以做出如下更改:

00401CAB - 改 and eax, 0
00406794 - 改 and eax, 0
004019C0 - 改 and eax, 0
00406514 - 改强制跳转 "EB 45"

(希望我没有遗漏…)

关于 SEH 相关技巧的参考内容:

用来测试 SEH 和硬件断点对抗的代码:

#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】发帖 表示自己找到答案但无法提交,以为次日就截止没法提交了在哭哭;

然后当日下午发现,早上 11 时的时候有人报告验证错误也修正了问题,但没给我补发提交次数,继续哭哭… 还好次日凌晨提交成功,至少证明我当时的分析是没有问题的 😎

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


 楼主| 爱飞的猫 发表于 2024-2-25 06:06
本帖最后由 爱飞的猫 于 2024-2-26 08:50 编辑

Android 题解在此。

前言/注意事项

使用工具:

该文章可能新手不友好的地方:

  • 你需要知晓「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 语法来表示更改:

- 行首为负号,应当删除此行内容
  行首为空格,不应更改此行内容
+ 行首为加号,应当添加此行内容
@ 行首为其他字符,视为注释即可

前言/MT 过签名校验

在 MT 管理器中,导航到 apk 文件所在目录,点选该文件。

然后依次选择「功能」→「去除签名校验」,按照默认(参考下图)选项确认后点击「确定」完成:

mt_sig_kill-fs8.png

最后得到一个 _kill.apk 后缀的过签名校验的新文件。

NP 管理器的操作应该类似。

解题过程

解题领红包之三 {Android 初级题}

出题老师:正己(正常人)

使用 JADX 打开 APK,查看 AndroidManifest.xml 定位到入口 com.zj.wuaipojie2024_1.MainActivity

简单看看,可以发现它本质上就是加载了个网页,然后网页接口和应用交互(“特权”指令):

public void onCreate(Bundle bundle) {
  // … 省略 …
  copyAssets(); // 拷贝 `ys.mp4` 到用户数据目录,大概
  // … 省略 …
  this.webView.addJavascriptInterface(new MyJavaScriptInterface(this), "AndroidInterface");
}

MyJavaScriptInterface 就是处理“特权”指令的实现了。进去看看干了啥…

public class MyJavaScriptInterface {
  // … 省略无关代码 …
  @JavascriptInterface
  public void onSolverReturnValue(int i) {
    if (i == -1) {
      this.mContext.startActivity(new Intent(this.mContext, YSQDActivity.class));
    }
  }
}

导出了一个接口,监听成功事件;成功后加载另一个 YSQDActivity “窗口”,继续分析。

在底部的 extractDataFromFile 发现了 flag 字样,直接分析即可;简化整理后的代码如下:

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[30];
  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("\\}")[0] + "}";
    }
  }
}

大致含义就是从文件末尾开始找,最多查找 30 字节,提取 flag{...} 字样的内容。

直接使用十六进制编辑器打开 03.apk:/assets/ys.mp4 文件,看看文件结尾:

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
000EFAD0  30 66 6C 61 67 7B 68 61 70 70 79 5F 6E 65 77 5F | 0flag{happy_new_
000EFAE0  79 65 61 72 5F 32 30 32 34 7D                   | year_2024}

因为没有加密,一眼就找到密码 flag{happy_new_year_2024}

解题领红包之四 {Android 初级题}

出题老师:侃遍天下无二人

同第三题操作,直接打开定位到主窗口的 com.kbtx.redpack_simple.WishActivity 中。

看代码应该是模拟了手游抽卡,运气好一发入魂就能看到 flag;但都看到源码了,直接分析过去就行。

Toast.makeText(wishActivity, "恭喜你十连出金了,奖品为 flag 提示!", 1).show();
wishActivity.startActivity(new Intent(wishActivity, FlagActivity.class));

熟悉的启动新窗口,代码倒是挺简洁的。稍微整理下后如下:

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[0].toByteArray();
    for (int i = 0; i < secret.length; i++) {
      secret[i] ^= signature[i % signature.length];
    }

    // 提示内容
    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 片段

 <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 主窗口,添加提取代码:

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[0].toByteArray();
    String b64_cert = Base64.encodeToString(signature, Base64.DEFAULT);
    android.util.Log.i("CERT", b64_cert);
  } catch (Exception e) {
    e.printStackTrace();
  }
}

然后在 onCreate 内调用它:

@ 此为 diff 片段

 protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
+    this.DumpCert();
     setContentView(R.layout.activity_main);

使用 Android Studio 推送应用到设备,并打开 Logcat 观察日志,得到 base64 编码后的证书信息。

得到证书之后用 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[i] = flag[i] xor cert[i]
    }

    println("flag: ${String(flag)}");
}

运行后输出过关密码:

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 方法进行检查。

该方法代码稍微整理后如下:

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.available()];
    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 文件时检查校验值([dex-input] verify dex file checksum before load)」设定为 no 来关闭。

然后继续动态加载执行:

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 中是被抽取了(应该是指向错误的函数实现),若查看反编译代码会得到下述代码块:

  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 片段

     .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 的代码已经出现(下述代码经过整理):

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

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 片段

     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 计算提示:

public static String d(String str) {
  return "机缘是{" + Utils.md5(Utils.getSha1("password+你的uid".getBytes())) + "}";
}

这两个方法照搬即可。注意 md5 方法内「转十六进制代码」的补零部分有点毛病,不清楚校验服务器会不会照搬这个「特性」。

最后,将上面获得的信息使用 Kotlin 编写 flag 获取程序:

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 片段

 .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 片段

-.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 的控制流混淆等),放弃。

免费评分

参与人数 1吾爱币 +1 热心值 +1 收起 理由
BonnieRan + 1 + 1 谢谢@Thanks!

查看全部评分

 楼主| 爱飞的猫 发表于 2024-2-25 06:22
本帖最后由 爱飞的猫 于 2024-2-26 09:45 编辑

此为 Web 题解。

前言/注意事项

使用工具:

  • 编辑工具 - VSCode
  • 图像处理 - GIMP
  • 终端 - 部分指令可能需要一个 Linux 环境才能正常使用,例如 WSL 2:

该文章可能对新手不友好的地方:

  • 你需要熟悉浏览器开发者工具(网络、调试器、审查元素)
  • 你需要熟悉部分 HTML、JavaScript 或 CSS 知识。
  • 你可能需要使用终端或类 Unix 环境来执行部分命令。

在 WSL 2 Ubuntu 或 Ubuntu 环境可以使用下述指令安装上述所需要的终端指令:

sudo apt update  -y
sudo apt install -y ffmpeg curl

相比去年,没有那么多套路(去年有东西藏在音频里),更多的是在算法和技术上的对抗了。

动态题调试题 C 会根据 UID 不同而返回不同的校验条件。你需要在登录界面使用我的 UID 176017 来复现与本文相同的题目。

※ Web 题源码已公布,可以在 GitHub: ganlvtech/52pojie-2024-challenge 查看源码。
※ 动态密码因为是根据时间和/或 UID 来动态生成而没有太多参考意义,本文将其具体 flag 值替换为 1a2b3c4d

原始视频

视频中隐藏的 flag

为了方便后续处理,你可能需要提前提取出原始视频的所有帧。

执行下述指令抽取视频内的所有帧:

# 「#」开头的行可以不用复制执行,这是一行注释。

# ↓ 建立名为 `frames` 的目录
mkdir frames

# ↓ 使用 ffmpeg 以 30fps 提取视频的每一帧内容,并存储至 `frames` 目录。
ffmpeg -i "吾爱破解2024年春节解题红包视频.mp4" -r 30/1 frames/%03d.png

其中初级的 1、3 题和中级的 7 题提示信息隐藏在视频中。

此外,在视频的 00:0300:08 处展示了被切割为 4 块的 QR 码图案,将对应的四帧提取出来合并:

下一站的 QR 码地址

下一站的 QR 码地址
(不需要抠得太细;直接把字抠掉后半透明叠加,合并所有图层,然后应用颜色阈值处理即可)

其它的 flag 都在 2024challenge.52pojie.cn 这个域名下对抗。

相比去年,大部分 flag 都标注好应该在哪个页面寻找,不需要靠蒙了。

此外建议在访问该域名时,全程开启 Charles (或其它中间人流量分析程序)来捕捉流量,方便查找漏掉的 flag。

解题领红包之八 {Web 初级题}

出题老师:Ganlv

感想:思路大概是不需要使用特别的工具也能找到吧。

flag1

源码定位:/gen_flag1

视频 00:02~00:03 部分有个波纹点阵效果。将对应的帧提取到独立的图层,并调整为半透明的状态叠加起来。

不需要处理得很完美,只要能隐约看到即可;在菜单选择「颜色(Color)」→「阈值(Threshold)」,调整强度直到文字清晰可见。

flag1 截图

flag1 截图

得到 flag1{52pj2024}

flag2

源码定位:服务器/main.go:207

输入 uid 登入,发现 /auth/login 请求完成后会重定向到 /

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

flag3 在视频第一秒。将前两帧叠加并将顶部的图层设置为「不同内容(Difference)」。

然后切换到「移动图层」工具,使用方向键左右移动其中一个图层,直到背景重叠消除。

最后,和 flag1 一样,加个阈值过滤来增强可读性:

flag3 截图

flag3 截图

得到 flag3{GRsgk2}

flag4

源码定位:/gen_flag4_flag10

查看首页源码,发现 flag4_flag10.png 图片资源。

下载下来并使用 GIMP 打开,得到透明背景白色字符的图片一张。

得到 flag4{YvJZNS}

动态 flag - A

源码定位:

登录后会设置 Cookie ,内容是加密的。

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 并展示:

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 下需要将 \ 和后面的换行删除):

curl "https://2024challenge.52pojie.cn/auth/uid" \
  -H "Cookie: uid=Ynkg54ix6aOe55qE54yrIChqaXh1bi51aykgbGljL0NDLWJ5LUNBIDQuMA=="

可以发现程序已经错误的将我们传入的值成功解密了,得到 flagA{1a2b3c4d}

解题领红包之九 {Web 中级题}

出题老师:Ganlv

感想:需要一点运气,或花时间暴力穷举也能得到答案。

flag5

首页右键「查看源码」,发现标记 <!-- flag5 flag9 -->,紧跟着 <pre> 预格式化标签。

看着头疼,对着 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;
}

flag 5 & 9

flag 5 & 9

得到 flag5{P3prqF}

※ 如果显示不正确,也可以在浏览器控制台执行以下代码来强制按照字符数量换行:

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

界面很简洁,就一个计算按钮。直接查看网页源码,并稍作整理:

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 不可逆,但我们可以查表;直接拜访 cmd5 查询即可。

得到对应的明文为 20240217,再根据代码构建对应的 flag 字符串 flag6{${i}}

最终得到 flag6{20240217}

※ 彩虹表也可以哦。

flag7

视频 00:21 处有一个地址,指向 GitHub 仓库 ganlvtech/52pojie-2024-challenge

观察提交记录,可以发现「6bbac03 删除不小心提交的flag内容」。

点开该提交记录,得到 flag7{Djl9NQ}

flag8

源码定位:

根据首页提示访问 /flagB/index.html

写个脚本硬刷即可,从 ovolve/2048-AI 抠了些代码来帮助判断下一步走向。

2048-solve.zip (820.29 KB, 下载次数: 7, 售价: 1 CB吾爱币)

使用方法:

  • 解压到某处
  • 打开终端,使用 NodeJS v20 启动服务端:npm start
  • 打开从压缩包解压/展开的 user.js,将其复制到浏览器的开发者控制台即可。

※ 其实也可以不依赖服务器,直接将整段代码塞到前端里的… 这个就交给读者了

全部配置好后的效果如下:

flag8: 自动跑 2048

flag8: 自动跑 2048

预估速率是每分钟 1000 到 2000 金币左右,因此挂个十几分钟,等刷到 10000 时停止即可;刷金币的时候可以先看看其它题。

购买一个 flag8 然后使用,得到 flag8{OaOjIK}

※ 如果不想跑脚本,也可以修改为我跑到 2 万分的 Cookie:

game2048_user_data=7gi/Ba+cqqXN3+BpaIGvkt/2i39JIT2a6+9HUNQ7g5VlF624rYoRFXQpRwaRXKH808iY9lwLuKVv5OZpNwtAca6n36qPUpQETcQnjOhWYEQnlHEx0uu/VpCoHzTjTVCzTFKFAYULuHMrQH0giedmNqWmnCf/EKmRLg9cihRCZ3AxwUoS1loZVEymxH4nNQ==

解密后(还记得动态口令 A 吗?):

{
  "game_data": {
    "tiles": [4, 64, 0, 0, 8, 16, 0, 2, 4, 8, 0, 0, 0, 0, 0, 0],
    "score": 384,
  },
  "money_count": 20332,
  "double_money_count": 1
}

动态 flag - B

源码定位:

根据首页提示访问 /flagB/index.html

先照着 flag8 的脚本稍微刷几百个金币备用。另外该题需要在首页提前登入。

购买 3 号道具「v我50」并使用,可以得到提示 作为奖励呢,我就提示你一下吧,关键词是“溢出”

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

灵光一闪尝试了个数字:

0xFFFFFFFF_FFFFFFFF / 999063388
// => 等于 18464037713

18464037713 提交到购买数量,并购买 5 号道具… 成功了!显示信息如下:

ID 道具名称 说明 数量 使用
5 消除道具 获取 flagB 内容 18464037713 [使用]

得到 flagB{1a2b3c4d} 及提示 过期时间: 2024-02-30 00:00:00

对应的 Cookie:

game2048_user_data=tI505/NihiSqzTIE3cMhfXPZ6sMOoCmkkkfRHljUeDL0VonRnWl5lEIFKTrY64PkkUlDdJmBKVzZrhEhQyS6wVKe7fdl5pUaHODwCgWp2N0LGwThjeupFO/PZH9ZJXJiEI4WrlwAj1bWbR6y3KVk2537/bJfqajlZ6Av3qdS11jNgCt4NiaE47pRUL5lP4/owvqZKfE85efvE6YAt3GjQdfPpaWfp9k8Xg==

解密后:

{
  "game_data": {
    "tiles": [4, 64, 0, 0, 8, 16, 0, 2, 4, 8, 0, 0, 0, 0, 0, 0],
    "score": 384,
  },
  "money_count": 20304,
  "double_money_count": 1,
  "flag_b_count": 18464037713
}
  • ※ 注 1 - 还是没搞懂为什么某些数字可以某些又不可以… 得对照公开后的源码看了。
    • 事后在吾爱破解 2024 年春节解题领红包活动非官方题解暨吐槽了解到缘由。比较笼统的解释是需要保证 价格 * 数量 的乘积的低 64 位需要为正整数。
    • 我挑的这个答案计算则刚好溢出到一个比较小的正整数 - (18464037713 * 999063388) % (2 ** 64),即 28 金币(使用 Python 计算该表达式)。
  • ※ 注 2 - 若是刷金币照每分钟 3000 金币来算,需要不停的刷 999063388 / 3000 / 60 / 24 ≅ 231 日,即差不多一年才能得到。

解题领红包之十 {Web 高级题}

出题老师:Ganlv

感想:相比之前的两个难度,脑洞比较大,但也留了能逃课的地方。

flag9

参考 flag5 的过程得到正确拼接的图案,右侧符号所组成的图形就是我们找的旗,flag9{KHTALK}

flag 5 & 9(重复贴)

flag 5 & 9(重复贴)

flag10

源码定位:/gen_flag4_flag10

参考 flag4 的过程得到 flag4_flag10.png 图片资源。

使用 GIMP 打开,切换到图层右边的「颜色通道(Channels)」选项卡,取消勾选「透明通道(Alpha)」,效果如下:

关闭 Alpha 透明通道来查看隐藏内容

关闭 Alpha 透明通道来查看隐藏内容

得到白底黑字的 flag10{6BxMkW}

flag11

根据首页提示访问 /flag11/index.html

右键→选择「审查元素」,定位到 <body> 标签。

找到 CSS 属性面板(通常在开发者工具的右侧),找到如下 CSS 属性:

:root {
    --var1: 0;
    --var2: 0;
}

点击 0 然后使用键盘的上下来调整该值吧!照着自己的直觉来完成这个拼图即可。

调整后的值为:

:root {
    --var1: 71;
    --var2: 20;
}

效果如下:

flag 11

flag 11

得到 flag11{HPQfVF}

flag12

源码定位:flag12/src/lib.rs:9

根据首页提示访问 /flag12/index.html

是一个 WebAssembly 题目:

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」选项卡):

(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 即可。

将其补回原始代码,放到浏览器执行:

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

源码定位:

根据首页提示访问 /flagC/index.html

很有趣的一个题目,在浏览器进行图像识别,然后将识别结果推送到远程服务器,最后服务器告知那些判定是正确或错误。

为了方便调试,首先抠代码出来备用:

const modelHeight = 640;// yolov5.inputs[0].shape[1];
const modelWidth = 640;// yolov5.inputs[0].shape[2];

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[i],
    })).filter((item) => item.label !== '').map((item) => `<g>
 <rect x="${1000 * item.box[0]}" y="${1000 * item.box[1]}" width="${1000 * (item.box[2] - item.box[0])}" height="${1000 * (item.box[3] - item.box[1])}" fill="none" stroke="#${item.color}" stroke-width="2"></rect>
 <text x="${1000 * item.box[0]}" y="${1000 * item.box[1] - 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';

首先尝试看看都有什么分类可以用:

verify(new Array(100*4).fill(0), new Array(100).fill(0.9), new Array(100).fill(0).map((x, i) => i));

查看网络选项卡,可以看看返回信息:

查看类别

查看类别

请求了 100 个类型,有 80 个类型返回结果,但只有两个是合法的类别:

6: train 种类正确 位置错误
9: traffic light 种类正确 位置错误

然后就是继续构建测试代码:

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

扫描结果如下:

查看位置错误信息

查看位置错误信息

然后就是根据反馈来构建请求了:

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

执行上述代码后的效果:

最终 Flag

最终 Flag

得到 flagC{1a2b3c4d}

免费评分

参与人数 3吾爱币 +4 热心值 +3 收起 理由
seatop + 1 + 1 谢谢@Thanks!
156608225 + 2 + 1 谢谢@Thanks!
LuckyClover + 1 + 1 谢谢@Thanks!

查看全部评分

LuckyClover 发表于 2024-2-25 07:32
大佬还是强
嘟嘟 发表于 2024-2-25 07:38
强势围观
仿佛_一念成佛 发表于 2024-2-25 07:43
大佬牛掰
flash1688 发表于 2024-2-25 07:55
我发现论坛的大佬很多.我是真不会
yu8556 发表于 2024-2-25 09:12
爱飞的猫 发表于 2024-2-25 06:22
[md]此为 Web 题解。

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

这个对于小白的我 绝对是天书既视感
jackyyue_cn 发表于 2024-2-25 09:14
流程很清晰,部分知识是我的盲区,还得不断学习了
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则 警告:本版块禁止灌水或回复与主题无关内容,违者重罚!

快速回复 收藏帖子 返回列表 搜索

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-4-28 01:29

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表