1
0
Files
blog.xinshi.fun/source/special/wp/shctf-2024-rust.md
2025-10-01 10:58:30 +08:00

417 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
title: 「锦锈山河」SHCTF 2024 Rust 逆向出题 Writeup
date: 2024/11/01 18:30:00
updated: 2024/11/05 16:30:00
categories:
- CTF-Writeup
cover: ../../wp/shctf-2024-rust/image27.webp
permalink: wp/shctf-2024-rust/
---
> [附件(右键另存为)](https://blog.xinshi.fun/wp/shctf-2024-rust/jin-xiu-shan-he.zip)
>
> 难度: 中等
> 听我说🦀🦀你,因为有你,山河更美丽
>
> 3 次解出455 pts
> 一血l4n 二血cfbb 三血BKBQWQ
出题人认为 CTF 中的逆向从来不应该是为了折磨选手,而是让选手了解到某种程序实现的底层原理,在反复考虑难度后决定给出含完整函数名的 PDB 调试信息(**WP 后面附有源码**,可供对比观察)。
直接运行并观察输出,可猜测把输入的 flag 转换成了 emoji 字符串。往逆向里塞 emoji 是因为 Rust 有保证 Unicode 字符正确转换的语言特性,这部分没有任何 Misc 知识点可以把这种字符串完全当作抽象的“结构化数据流”转换过程只是简单的四则运算和位运算。WP 后附有用到的 Unicode 转换细节,供感兴趣的读者查阅。)
> 方便起见,下文 `i32` 表示 32 位有符号整数,`u8` 表示 8 位无符号整数,以此类推。
# 完整分析过程
先运行一下发现必须要输入至少48字符才会得到结果会输出88个emoji
输入相同时输出也相同;
尝试改变输入如果改变最后一个字符会使后面大约一半的emoji发生变化如果改变第一个字符会使全部emoji发生变化
![](../../wp/shctf-2024-rust/image27.webp)
> ~~*附件确实给了 pdb记得按 Yes*~~
>
> ![](../../wp/shctf-2024-rust/image28.webp)
![](../../wp/shctf-2024-rust/image29.webp)
先看 main
![](../../wp/shctf-2024-rust/image30.webp)
![](../../wp/shctf-2024-rust/image31.webp)
在 IDA 中显示正确的字符编码:
![](../../wp/shctf-2024-rust/image32.webp)
调试获取 key
> 启动调试时弹出计算器文件内容,是因为出题人把 pdb 里源文件路径改成了 `C:\Windows\System32\.\.\.\.\.\.\.\.\.\.\calc.exe`
![](../../wp/shctf-2024-rust/image33.webp)
Rust 编译产物中经常出现这种情况如果返回类型的大小大于usize第一个参数v15是“调用方”main的栈上地址“被调用方”generate_key往这个地址写返回类型的结构体。也就是说v15 是作为返回值用的。
第 57 行的函数名能看出返回类型:`Result<Vec<u8>, openssl::error::ErrorStack>`
这是 Rust 中的 Result 枚举,它有两种枚举成员 Ok 或 Err。`Ok` 成员表示操作成功,内部包含成功时产生的值。`Err` 成员则意味着操作失败,并且 `Err` 中包含有关操作失败的原因或方式的信息。
第 58 行检查枚举值,如果不为 Ok则提前结束 main传播错误给 main 的调用方。
双击 v15 查看数据(原本看到的是字节,已按 R 键设为 64 位整数):
![](../../wp/shctf-2024-rust/image34.webp)
本次调试中 24D40BE31A0 处的 16 字节就是 key
![](../../wp/shctf-2024-rust/image35.webp)
同理这是 main 第 86 行分配给输入的 Vec
*写WP过程中有多次重新开始调试部分地址可能前后不一致敬请谅解*
![](../../wp/shctf-2024-rust/image36.webp)
**控制流**不好跟踪的话,可以尝试跟踪**数据流**。在输入后,对这段内存打上读写断点:
![](../../wp/shctf-2024-rust/image37.webp)
调试发现 encrypt_flag 的参数分别为:
![](../../wp/shctf-2024-rust/image38.webp)
![](../../wp/shctf-2024-rust/image39.webp)
Rust 的标准库和第三方库crate基本都是源码分发的也可以查到文档
https://docs.rs/openssl/0.10.68/openssl/symm/fn.encrypt.html
![](../../wp/shctf-2024-rust/image40.webp)
返回的同样也是一个 Result 枚举,同样打上内存断点(就不截图了):
![](../../wp/shctf-2024-rust/image41.webp)
分组密码长度扩展时确实会补 1~16 个字节并达到 16 的倍数,所以明文的 48 字节变成了 64 字节。
值得一提的是encrypt_flag 取得了原本输入的 Vec 的所有权,并最后 drop 了它。
回到 main。接下来是 make_emoji_string
![](../../wp/shctf-2024-rust/image42.webp)
第 75 行点进去 10 层函数调用 *(不是出题人故意的,它编译后就长这样)*,可以看到 `jin_xiu_shan_he::util::make_emoji_string::closure$0` 函数把前面 Base64 得到的 0~63 按位或了 0x1F600于是映射到了这个范围
![](../../wp/shctf-2024-rust/image43.webp)
![](../../wp/shctf-2024-rust/image44.webp)
第 18 行的 String::push 把这个 char 转成 UTF-8 放到可变字符串里。
回到 main。最后是 check_flag
![](../../wp/shctf-2024-rust/image45.webp)
![](../../wp/shctf-2024-rust/image46.webp)
![](../../wp/shctf-2024-rust/image47.webp)
![](../../wp/shctf-2024-rust/image48.webp)
如果在这时尝试提取密文数据解密,会无法解码 SM4 或得到乱码。这是因为每迭代一次,在 `jin_xiu_shan_he::util::impl$0::next` 中会把整段密文修改一次。在做题时可能不容易发现这一点,但是如果对密文数据打了内存断点,会发现在 next 中它已被析构回收。
这时有两种做法,可以分析 next 函数的实现:
![](../../wp/shctf-2024-rust/image49.webp)
也可以不考虑对密文的具体处理 *(前提是其与输入无关,可通过内存断点验证)*,只在每次比对时,记录下处理后的密文:
![](../../wp/shctf-2024-rust/image50.webp)
![](../../wp/shctf-2024-rust/image51.webp)
每点一下运行,就会停在这里一次,可以手动记录下**一个**正确的密文。(我的附件第一次是 0x1F610
也可用 IDAPython 将其输出:
```python
import ida_dbg
print(hex(ida_dbg.get_reg_val("eax")), end=', ')
```
也可尝试精心 patch使得正确的密文被按序写入内存然后一次性提取。
# 解密脚本
如果是「记录处理后的密文」做法:
``` python
from gmssl import sm4
KEY = bytes([93, 129, 173, 248, 234, 102, 108, 239, 45, 66, 196, 204, 221, 97, 143, 181])
enc = [
0x1F610, 0x1F603, 0x1F627, 0x1F617, 0x1F612, 0x1F605, 0x1F63E, 0x1F617,
0x1F630, 0x1F606, 0x1F625, 0x1F600, 0x1F60F, 0x1F62B, 0x1F61A, 0x1F60E,
0x1F616, 0x1F61C, 0x1F613, 0x1F63A, 0x1F60E, 0x1F626, 0x1F631, 0x1F632,
0x1F634, 0x1F61B, 0x1F606, 0x1F623, 0x1F604, 0x1F60F, 0x1F62E, 0x1F604,
0x1F63C, 0x1F619, 0x1F607, 0x1F629, 0x1F601, 0x1F607, 0x1F609, 0x1F637,
0x1F602, 0x1F632, 0x1F634, 0x1F635, 0x1F61C, 0x1F62D, 0x1F612, 0x1F617,
0x1F61A, 0x1F62B, 0x1F62F, 0x1F610, 0x1F619, 0x1F62A, 0x1F63C, 0x1F616,
0x1F609, 0x1F634, 0x1F62E, 0x1F639, 0x1F611, 0x1F62E, 0x1F630, 0x1F623,
0x1F608, 0x1F616, 0x1F608, 0x1F63C, 0x1F604, 0x1F61D, 0x1F618, 0x1F619,
0x1F635, 0x1F63C, 0x1F632, 0x1F63D, 0x1F605, 0x1F635, 0x1F61B, 0x1F603,
0x1F60A, 0x1F60B, 0x1F636, 0x1F603, 0x1F63F, 0x1F600, 0x1F600, 0x1F600,
]
enc_target = [c & 0x3F for c in enc] # 高位不影响解密,只取低 6 位
sm4_enc = []
for i in range(0, len(enc_target), 4): # Base64 解码
d = enc_target[i:i + 4]
sm4_enc.extend([
(d[0] << 2 | d[1] >> 4) & 0xFF,
(d[1] << 4 | d[2] >> 2) & 0xFF,
(d[2] << 6 | d[3]) & 0xFF,
])
sm4_enc = sm4_enc[:64] # 去掉末尾的两个 0
sm4_instance = sm4.CryptSM4(mode=sm4.SM4_DECRYPT)
sm4_instance.set_key(KEY, sm4.SM4_DECRYPT)
flag = sm4_instance.crypt_cbc(reversed(KEY), bytes(sm4_enc)).decode()
print(flag)
```
如果是「分析对密文的处理」做法:
``` python
from gmssl import sm4
KEY = bytes([93, 129, 173, 248, 234, 102, 108, 239, 45, 66, 196, 204, 221, 97, 143, 181])
BOX = [
18, 15, 40, 10, 41, 36, 62, 23, 34, 12, 58, 57, 39, 46, 17, 7,
47, 44, 30, 26, 31, 14, 4, 19, 21, 61, 11, 3, 5, 55, 37, 28,
53, 27, 13, 9, 49, 25, 54, 33, 42, 16, 24, 0, 1, 43, 32, 6,
50, 63, 52, 48, 22, 45, 51, 2, 20, 59, 60, 29, 8, 35, 56, 38
]
emojis = '😩😝😹😒😟😱😘😌😎😟😭😼😑😯😀😍😨😖😍😮😗😗😘😽😠😻😱😗😉😏😈😀😿😷😗😴😠😧😻😹😚😯😤😥😪😅😩😬😼😰😁😝😐😧😵😍😅😪😝😼😃😂😾😕😷😛😎😷😊😙😠😁😸😕😪😬😓😹😾😝😇😇😭😎😩😳😟😷'
emojis = [ord(c) & 0x3F for c in emojis] # 高位字节不影响解密,只取低 6 位
enc_target = []
for i in range(88):
cur = emojis[i] & 0x3F
for j in range(i + 1): # 第 i 个 emoji 在被返回前,修改了 i+1 次
cur = BOX[cur]
cur = (cur + j) & 0x3F
enc_target.append(cur)
sm4_enc = []
for i in range(0, len(enc_target), 4): # Base64 解码
d = enc_target[i:i + 4]
sm4_enc.extend([
(d[0] << 2 | d[1] >> 4) & 0xFF,
(d[1] << 4 | d[2] >> 2) & 0xFF,
(d[2] << 6 | d[3]) & 0xFF,
])
sm4_enc = sm4_enc[:64] # 去掉末尾的两个 0
sm4_instance = sm4.CryptSM4(mode=sm4.SM4_DECRYPT)
sm4_instance.set_key(KEY, sm4.SM4_DECRYPT)
flag = sm4_instance.crypt_cbc(reversed(KEY), bytes(sm4_enc)).decode()
print(flag)
```
# 附源码
`cargo.toml`
``` toml
[package]
name = "jin-xiu-shan-he"
version = "0.1.0"
edition = "2021"
[dependencies]
openssl = "0.10.68"
[profile.release]
opt-level = 0
debug = "limited"
```
采用 release 配置文件,`opt-level = 0` 是反复考虑难度后决定不让标准库函数(例如 UTF-8 转 char内联影响分析`debug = "limited"` 是为了保留用户代码的函数名,但不保留变量类型。
`main.rs`
``` rust
mod util;
use std::error::Error;
use std::io::{self, Read, Write};
fn main() -> Result<(), Box<dyn Error>> {
let key = util::generate_key(b"\xF0\x9F\xA6\x80")?;
// [93, 129, 173, 248, 234, 102, 108, 239, 45, 66, 196, 204, 221, 97, 143, 181]
print!("👉 Enter your flag: ");
io::stdout().flush()?;
let mut flag = vec![0u8; 48];
io::stdin().read_exact(&mut flag)?;
let enc = util::encrypt_flag(flag, &key)?;
let enc = util::make_emoji_string(enc);
println!("{}", enc);
if util::check_flag(&enc) {
println!("🥳 You got it!");
} else {
println!("🤯 Try again!");
}
Ok(())
}
```
`util.rs`
``` rust
use std::str::Chars;
use openssl::error::ErrorStack;
use openssl::hash::{hash, MessageDigest};
use openssl::symm::{encrypt, Cipher};
struct Target(u8, String);
const BOX: [u8; 64] = [
18, 15, 40, 10, 41, 36, 62, 23, 34, 12, 58, 57, 39, 46, 17, 7, 47, 44, 30, 26, 31, 14, 4, 19,
21, 61, 11, 3, 5, 55, 37, 28, 53, 27, 13, 9, 49, 25, 54, 33, 42, 16, 24, 0, 1, 43, 32, 6, 50,
63, 52, 48, 22, 45, 51, 2, 20, 59, 60, 29, 8, 35, 56, 38,
];
const TARGET_EMOJIS: &str = "😷😯😞😻😞😊😏😡😷😅😉😙😜😤😮😘😑😮😨😭😬😺😒😊😙😳😎😧😜😡😝😜😶😤😤😸😫😳😦😴😿😈😫😹😑😨😽😒😡😦😧😺😷😖😐😶😘😭😞😠😑😁😧😮😣😮😳😄😣😗😦😑😝😭😛😱😯😄😍😒😻😠😝😂😮😳😟😷";
impl Iterator for Target {
type Item = char;
#[inline(never)]
fn next(&mut self) -> Option<Self::Item> {
let len = self.1.len();
if self.0 as usize >= len {
return None;
}
let mut new_string: Vec<char> = vec![];
for c in self.1.chars() {
new_string.push(
char::try_from(
c as u32 & 0xFFFFFFC0
| ((BOX[(c as u32 & 0x3F) as usize] + self.0) as u32 & 0x3F),
)
.unwrap(),
);
}
self.1 = String::from_iter(new_string.clone());
self.0 += 1;
Some(new_string[(self.0 - 1) as usize])
}
}
#[inline(never)]
pub fn generate_key(data: &[u8]) -> Result<Vec<u8>, ErrorStack> {
let mut data = Vec::from(data);
for _ in 0..202410 {
data = Vec::from(&*hash(MessageDigest::sha512(), &data)?)
.chunks(4)
.map(|x| x[0] ^ x[1] ^ x[2] ^ x[3])
.collect();
}
Ok(data)
}
#[inline(never)]
pub fn encrypt_flag(msg: Vec<u8>, key: &Vec<u8>) -> Result<Vec<u8>, ErrorStack> {
let mut iv = key.clone();
iv.reverse();
encrypt(
Cipher::sm4_cbc(),
key,
Some(&iv),
&msg,
)
}
#[inline(never)]
pub fn make_emoji_string(flag: Vec<u8>) -> String {
let mut v = String::new();
for d in flag.chunks(3) {
let d = match d.len() {
1 => &[d[0], 0, 0],
2 => &[d[0], d[1], 0],
_ => d,
};
[
d[0] >> 2,
d[0] << 6 >> 2 | d[1] >> 4,
d[1] << 4 >> 2 | d[2] >> 6,
d[2] << 2 >> 2,
].map(|b| v.push(char::try_from(b as u32 & 0x3F | 0x1F600).unwrap()));
}
v
}
#[inline(never)]
pub fn check_flag(enc: &String) -> bool {
let mut iter = enc.chars();
let mut target = Target(0, String::from(TARGET_EMOJIS));
let xor =
|x: &mut Chars, y: &mut Target| x.next().unwrap() as u32 ^ y.next().unwrap() as u32;
for _ in 0..88 {
if xor(&mut iter, &mut target) != 0 {
return false;
}
}
true
}
```
# 附用到的 Unicode 转换细节
这部分算 Misc仅供感兴趣的师傅阅读。解本题时不需知道。
有的师傅可能注意到了,本题中 emoji 有时候表现为 0x1F6?? 的形式,有时候表现为 0xF09F98?? 的形式。
实际上前者为该码点在 Unicode 全表中从 0 开始的序号(这个表不是完全连续的),后者为 UTF-8 变长编码。
![](../../wp/shctf-2024-rust/image52.webp)
单个码点的编号用 u32 可以存得下,但是它不符合前缀码规则,不能放进字节数组当成字符串。
以🦪U+1F9AA为例如果在数组中它可以被解释为单个码点也可以被解释为一个 0x1F9 和一个 0xAA或者一个 0x1一个 0xF9一个 0xAA等等。
1F9AA11111 100110 101010可以用以下变长编码UTF-8表示
**11110**000 **10**011111 **10**100110 **10**101010
最前面的 11110 表示接下来这个字符占 4 个字节如果是汉字3 个字节)则最前面是 1110。后面每个字节都以 10 开头。
![](../../wp/shctf-2024-rust/image53.webp)
UTF-8 每个字符长度为 8 位的倍数UTF-16 每个字符长度为 16 的倍数UTF-32 每个字符长度为 16 的倍数。
Python、Rust 字符串内部存储采用 UTF-8。
.NET、Java 字符串内部存储采用 UTF-16。