eatsuki5/Write-up-Cinderbound-Reverse-Challenge-The-Salt-Crown

GitHub: eatsuki5/Write-up-Cinderbound-Reverse-Challenge-The-Salt-Crown

该仓库是一篇 CTF 逆向挑战的详细解题报告,展示了如何反汇编并分析 MicroPython 字节码文件以还原验证逻辑。

Stars: 0 | Forks: 0

# Write-up-Cinderbound-Reverse-Challenge-The-Salt-Crown 本仓库旨在解析 The Salt Crown CTF 中 Cinderbound 逆向挑战的解题过程 # Write-up-Cinderbound-Reverse-Challenge-The-Salt-Crown 本仓库旨在解析 The Salt Crown CTF 中 Cinderbound 逆向挑战的解题过程 | 字段 | 详细信息 | | ---------- | ------------------------------------- | | 赛事 | Cyber Apocalypse 2026: The Salt Crown | | 挑战名称 | Cinderbound | | 类别 | 逆向 | | 作者 | danou_78 | | 战队 | ShadowNet Community | | 最终 Flag | HTB{c1nd3rbound_v0w5} | 你好,这里是关于逆向分类中名为 Cinderbound 的非常简单的挑战的 write up。我想为我的语法错误道歉,因为英语不是我的母语。这篇 write up 是以对新手友好的方式编写的。 ## 1 理解真正的问题 当我们下载 cinderbound zip 文件时,我们会遇到一个问题。我们找到了一个 .mpy 文件。什么是 mpy 文件?mpy 文件代表 micropython,是 python 的一种派生形式。`.mpy` 是 MicroPython 使用的编译字节码格式。 如果我们尝试用 python 执行 .mpy 文件,我们会得到一个错误,这是因为 mpy 文件只能在 micropython 环境中执行。那么我们该如何分析它的代码呢?我们需要安装 mpy-tools,可以从 micropython 的 git 仓库中获取它。 ``` git clone https://github.com/micropython/micropython.git ``` 之后我们需要 cd 进入 micropython 目录,然后再进入 tools 目录。 接着我们需要执行它。 ``` ./mpy_tool.py -xd cinderbound.mpy ``` (-x 和 -d 分别代表 hexdump 和 --disassemble,用于查看指令) 然后我们会发现一大块指令,我将尝试用两个代码块,一个用来解释低级指令,另一个用来展示其对应的 Python 代码。 第一行我们会看到魔术数以及其他你可能会觉得熟悉的内容,比如函数。 ` ``` 00000000: 4d06 001f 0801 186a 7564 6765 5f73 7263 M......judge_src judge_src.py 00000010: 2e70 7900 0f0a 6a75 6467 6500 7910 7379 .py...judge.y.sy judge append syllable 00000020: 6c6c 6162 6c65 0081 5781 6f81 590a 1007 llable..W.o.Y... len ord list 00000030: 0235 3707 0331 3239 0703 3135 3407 0233 .57..129..154..3 57 129 154 31 00000040: 3107 0331 3939 0703 3139 3207 0237 3307 1..199..192..73. 199 192 73 00000050: 0332 3433 0702 3433 0703 3137 3607 0332 .243..43..176..2 243 43 176 255 00000060: 3535 0703 3137 3307 0235 3407 0332 3033 55..173..54..203 173 54 203 00000070: 0702 3637 0702 3135 4c00 0201 3200 1602 ..67..15L...2... 67 15 00000080: 5163 0185 4059 1402 0420 2324 232a 322e Qc..@Y... #$#*2. judge 00000090: 3023 00c1 2280 5ac2 2b00 c312 05b0 3401 0#..".Z.+.....4. ` ``` 之后,在代码被混淆前的指令中,我们会发现它的真实名称,源文件被称为 `judge_src.py`。(.mpy 扩展名是在 python 中指示 mpy 模块后创建的)。 ## 2 从指令翻译为真实代码 然后我们会看到如下内容 ``` obj_table: [(57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15)] simple_name: raw bytecode: 9 00:02:01:32:00:16:02:51:63 prelude: (1, 0, 0, 0, 0, 0) args: [] line info: 32:00 MAKE_FUNCTION 0 16:02 STORE_NAME judge 51 LOAD_CONST_NONE 63 RETURN_VALUE children: [judge] ``` 据我理解,module 是我们在 mpy 环境中所谓的父函数,而 children [judge] 简单来说就是我们要处理的“main”函数。看到 simple_name 上面的 obj_table 了吗?它会派上用场的(如果你问为什么 obj_const 在一个列表里,那是因为 mpy 环境将所有 const 对象放在一个列表中以便于访问;如果有第二个对象,它可能会写成这样 `[(list),(another_list)]`)。 然后我们终于看到了名为 judge 的 main 函数 ``` simple_name: judge raw bytecode: 88 59:14:02:04:20:23:24:23:2a:32:2e:30:23:00:c1:22:80:5a:c2:2b:00:c3:12:05:b0:34:01:80:42:6b:57:c4:12:06:b0:b4:55:34:01:b2:ee:b4:8d:f4:22:81:7f:ef:ee:c5:b2:12:06:b0:b4:55:34:01:f2:22:81:7f:ef:c2:b3:14:03:b5:36:01:59:81:e5:58:5a:d7:43:10:59:59:b3:12:07:b1:34:01:d9:63 prelude: (12, 0, 0, 1, 0, 0) args: ['syllable'] ``` 它接收 `syllable` 作为参数,我们现在可以开始将这些指令翻译为真实代码了。 ``` def judge(syllable): ``` 我将尝试每次展示至少 10 条指令: ``` LOAD_CONST_OBJ (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) c1 STORE_FAST 1 22:80:5a LOAD_CONST_SMALL_INT 90 c2 STORE_FAST 2 2b:00 BUILD_LIST 0 c3 STORE_FAST 3 12:05 LOAD_GLOBAL len b0 LOAD_FAST 0 34:01 CALL_FUNCTION 1 80 LOAD_CONST_SMALL_INT 0 ``` 这里我们有很多信息,让我们试着找出它们的含义。 `LOAD_CONST_OBJ` 加载存储在常量表中的对象。在这里,它是稍后用于验证的 tuple。 `LOAD_FAST` 可以理解为变量,所以我将其命名为 FAST X(X 代表数字)。 然后我们有作为 const 对象的 FAST 1,之后创建了一个存储在 FAST 2 中的整数,一个存储在 FAST 3 中的空列表,以及一个用于整个函数的 len 函数,`LOAD_FAST 0` 意味着我们将尝试访问第一个变量 0,也就是参数 `syllable`。 为了更清楚地说,我们有: FAST 0 : syllable FAST 1 : object tuple FAST 2 : 90(因为这是一个整数) FAST 3 : 设为 0 的空列表 [] 在 Python 中我们将得到: ``` def judge(syllable): const = (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) fast_2 = 90 fast_3 = [] length = len(syllable) ``` 让我们跳过接下来的 10 条指令。 ``` LOAD_CONST_SMALL_INT 0 42:6b JUMP 43 57 DUP_TOP c4 STORE_FAST 4 12:06 LOAD_GLOBAL ord b0 LOAD_FAST 0 b4 LOAD_FAST 4 55 LOAD_SUBSCR 34:01 CALL_FUNCTION 1 ``` 这里我们设置了一个值为整数 0 的变量,我们称之为: `x = 0` 它被存储在栈顶(我们需要跳转到偏移量 43 处,稍后再完善我们的代码)。 JUMP 主要用于循环指令,这意味着例如在一个 while 循环中,代码首先会检查条件是否为真,如果不为真,则什么也不做。 `DUP_TOP` 意味着复制栈顶元素。 之后我们有了 `LOAD_FAST 4` 指令,这会在我们的变量库中添加另一个变量 FAST = 0,并且设置了另一个函数,即 `ord()`。 所以 FAST 4 = 0。 这里有一条新指令 `LOAD_FAST`,意味着程序将从上面解释过的地址 FAST 1 和 FAST 0 中获取值或数据。 ``` def judge(syllable): const = (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) fast_2 = 90 fast_3 = [] length = len(syllable) x = 0 while #we don't know at this moment ``` `LOAD_SUBSCR` 用于索引序列,比如访问 list 或 tuple 的元素。 `LOAD_FAST 2` 意味着我们将“调用”或访问变量 FAST 2 的值,在这里也就是 `fast_2`。 ``` b2 LOAD_FAST 2 ee BINARY_OP 23 __xor__ b4 LOAD_FAST 4 8d LOAD_CONST_SMALL_INT 13 f4 BINARY_OP 29 __mul__ 22:81:7f LOAD_CONST_SMALL_INT 255 ef BINARY_OP 24 __and__ ee BINARY_OP 23 __xor__ c5 STORE_FAST 5 ``` 这里开始变得有点复杂了,这里的 `__xor__` 意味着将进行一次 xor 运算,我们访问值为 0 的 `fast_4` 变量,加载设为 90 的变量 FAST 2,与值为 0 的 `LOAD_FAST 4` 进行 xor 运算,接着我们有两个新值 13 和 255,以及新的乘法运算和 and 运算,还有一个用于存储结果的变量 FAST 5。因此: FAST 5 = 某个结果 然后我们开始将这些混合起来。 我们可以得到类似这样的代码: `fast_5 = (ord(syllable[fast_4]) ^ fast_2) ^ ((fast_4 * 13) & 255)` 我们这里得到了什么?`fast_5` 是结果变量,我们对 `judge` 参数 `syllable` 应用了 `ord` 函数(索引为 `fast_4`),因此 `fast_4` 是我们 while 循环的条件之一。我们对它应用值为 90 的 `fast_2`,然后再与索引乘以 13 的结果进行一次 xor 运算,最后与 255 进行 and 运算。 为了让它更容易理解,我们可以这样写(抱歉,我觉得这可能有点不太对): `result = (ord(syllable[x])^90)^ ((x * 13)&255)` 我们的 Python 翻译开始变得越来越清晰了: ``` def judge(syllable): const = (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) fast_2 = 90 fast_3 = [] length = len(syllable) x = 0 while x # Always unclear at this stage: result=(ord(syllable[x])^90)^((x * 13)&255) ``` 接下来的 10 条指令会更简单,因为我们已经见过相同的指令了。 ``` b2 LOAD_FAST 2 12:06 LOAD_GLOBAL ord b0 LOAD_FAST 0 b4 LOAD_FAST 4 55 LOAD_SUBSCR 34:01 CALL_FUNCTION 1 f2 BINARY_OP 27 __add__ 22:81:7f LOAD_CONST_SMALL_INT 255 ef BINARY_OP 24 __and__ c2 STORE_FAST 2 ``` 代码加载了 `FAST_2` 和 `ord` 函数,以及其他变量 `FAST_0` 和 `FAST_4`,还有同样的 `SUBSCR` 指令。我们对 `fast_2` 和包含 `fast_0` 及 `fast_4` 的 `ord` 函数应用加法运算(`+`),然后对结果与 255 应用 and 操作,并将结果存回 `FAST_2` 本身。有了这些信息,我们可以写出这个: `fast_2 = (fast_2 + ord(syllable[fast_4])) & 255` 或者像这样: `fast_2 = (fast_2 + ord(syllable[x]))& 255`。 然后在 Python 中: ``` def judge(syllable): const = (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) fast_2 = 90 fast_3 = [] length = len(syllable) x = 0 while x # Always unclear at this stage: result=(ord(syllable[x])^90)^((x * 13)&255) fast_2 = (fast_2 + ord(syllable[x]))& 255 ``` 这表明 `fast_2` 会随着循环次数而改变,因此 90 在每次迭代中都会发生变化。 接下来我们有两个新指令。 ``` b3 LOAD_FAST 3 14:03 LOAD_METHOD append b5 LOAD_FAST 5 36:01 CALL_METHOD 1 59 POP_TOP 81 LOAD_CONST_SMALL_INT 1 e5 BINARY_OP 14 __iadd__ 58 DUP_TOP_TWO 5a ROT_TWO d7 BINARY_OP 0 __lt__ ``` 这里我们有 2 个变量和一个 `append` 函数,我们可以直接补全: ``` fast_3.append(result) ``` 这条指令与 `CALL_METHOD` 一起会将值推入栈顶,其结果为 `None`(这就是为什么我在 write up 开头纠正了一个由于误解而产生的错误)。 我们需要跟踪栈的变化以便更清楚: 在 `call_method` 处,`append` 函数返回了 `None`,随后将被 `POP_TOP` 清理掉。 | 常量为 0 时的初始 TOP | CALL_METHOD | 在 POP_TOP 之后 | 加载常量 1 | iaad 运算符 | TOP_DUP_TWO | ROT_TWO | | ------------------------------------ | ----------- | ----------------- | --------------- | ------------- | ----------- | ---------- | | x | None | x | 1 | x1 | x1 | len(const) | | len(const) | x | len(const) | x | len(const) | len(const) | x1 | | | len(const) | | len(const) | | x1 | x1 | | | | | | | len(const) | len(const) | 所以在这里我们终于可以找到 while 循环的两个条件,因为 `__lt__` 代表小于,它会尝试执行 while 循环中的 `x1 < len(const)`。 现在我们的 Python 代码可以这样写: ``` def judge(syllable): const = (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) fast_2 = 90 fast_3 = [] length = len(syllable) x = 0 while x < len(const) result=(ord(syllable[x])^90)^((x * 13)&255) fast_2 = (fast_2 + ord(syllable[x]))& 255 # Here the fast_3 and fast_5 fast_3.append(result) x+=1 ``` | TOP_DUP_TWO | ROT_TWO | POP_JUMP_IF_TRUE | | ----------- | ---------- | ---------------- | | x1 | len(const) | True | | len(const) | x1 | x1 | | x1 | x1 | len(const) | | len(const) | len(const) | | 在这里 `x1` 和 `len(const)` 变成了一个布尔值,`pop_jump_if_true` 会将布尔值移出栈,栈中只剩下 `x1` 和 `len(const)`,最后栈将随着接下来的 2 条 `POP` 指令被完全清空。 ``` 43:10 POP_JUMP_IF_TRUE -48 59 POP_TOP 59 POP_TOP b3 LOAD_FAST 3 12:07 LOAD_GLOBAL list b1 LOAD_FAST 1 34:01 CALL_FUNCTION 1 d9 BINARY_OP 2 __eq__ 63 RETURN_VALUE ``` 之后我们加载 `fast_3`(包含所有的 result 值)和作为 list 的 `fast_1`,以及代表等于的 `__eq__` 运算符,我们使用它来返回 `fast_3` 和 `fast_1` 之间的相等性比较结果。 ``` def judge(syllable): const = (57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15) fast_2 = 90 fast_3 = [] length = len(syllable) x = 0 while x < len(const) result=(ord(syllable[x])^90)^((x * 13)&255) fast_2 = (fast_2 + ord(syllable[x]))& 255 # Here the fast_3 and fast_5 fast_3.append(result) x+=1 return fast_3 == list(fast_1) ``` 至此我们终于完成了翻译。但是我们该如何尝试找到 flag 呢?虽然我们现在还不能直接得到 flag,但既然我们已经理解了使用 `chr()` 函数(作为 `ord()` 的逆操作,能将十进制转换为 ASCII 字符)的代码,接下来的工作就会简单得多。 我们将从创建常量对象,也就是我们的第一个 tuple 开始。 ``` const = ( 57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15 ) def decryption(): const1 = 90 flag = [] # We will use enumerate function for x, value in enumerate(const): # for each index for example 0 *13 = 0 and 255 will also make 0 and this is for each index of the const tuple result1 = (x * 13)&255 # Here we try to exchange the xored values of const by applying one of the xor rule a ^b = c soo c^b=a for example in our first code result was each index of syllable xored by 90 and xored with index multiply with 13 and "and" operand result2 = value ^ result1 ^ const1 #Here we change the decimal integer into ASCII result_final = chr(result2) flag.append(result_final) #Here same as for judge const1 = (const1 + result2) & 255 return "".join(flag) print(decryption()) ``` 最终我们得到了 flag `{c1nd3rbound_v0w5}`。我还想补充一点,通过展示最终转换为 ASCII 的十进制数值的演变过程。 首先我们将用 `result1` 进行尝试。 ``` const = ( 57, 129, 154, 31, 199, 192, 73, 243, 43, 176, 255, 173, 54, 203, 67, 15 ) const1 = 90 flag = [] result1_value = [] for x, value in enumerate(const): result1 = (x * 13)&255 result1_value.append(result1) result2 = value ^ result1 ^ const1 result_final = chr(result2) flag.append(result_final) const1 = (const1 + result2) & 255 print(result1_value) ``` 在这里,通过将 `result1` 放入一个新列表,我们得到: `[0, 13, 26, 39, 52, 65, 78, 91, 104, 117, 130, 143, 156, 169, 182, 195]` 将 `result1` 替换为 `result2`,我们得到: `[99, 49, 110, 100, 51, 114, 98, 111, 117, 110, 100, 95, 118, 48, 119, 53]` 最后使用 `result_final`: `['c', '1', 'n', 'd', '3', 'r', 'b', 'o', 'u', 'n', 'd', '_', 'v', '0', 'w', '5']` 感谢您阅读这篇 write up,它非常长,我尽力做到尽可能清晰。如果您有任何建议,请随时在 Discord 上私信我:danou_78
标签:MicroPython, Writeup, 云资产清单, 逆向工具, 逆向工程