WEEK1-RE
PVZ
打开IDA之后发现这个程序是用java写的,于是直接使用压缩包把应用解包,随后使用Recaf打开

找到了其中的关键代码。
发现获取Flag的核心方法是 getFlag(),它调用了 decryptFlag()。整个解密流程如下:
- killCount 派生密钥解密:
decryptWithKillCount使用僵尸击杀数生成的随机数种子作为密钥,对密文进行异或解密。 - 分割与异或: 将解密后的数组分为两半,分别与
0x66 (102) 和0x77(119) 进行异或。 - AES (伪) 解密: 将合并后的数组与
aesEncryptedKey进行循环异或。 - 字符串转换: 转为 UTF-8 字符串。
- 凯撒移位 (Rotation) : 根据 "PLANTS_VS_ZOMBIES_2025" 的字符和计算偏移量进行移位。
- 替换密码 (Substitution) : 使用硬编码的映射表进行字符替换。
- 最终修正: 将字符串开头的 "flag" 替换为 "hgame"。
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public class PvZFlagSolver {
// 密文数据
static byte[] killCountEncryptedFlag = {0, -8, -6, 6, 31, -39, -104, 114, 86, -23, -35, 28, -122, 56, 29, -126, -29, 94, 23, -29, 46, -126, -4, 45, 20, -57};
static byte[] aesEncryptedKey = {74, -111, -61, 127, 46, -75, 104, -44, 28, -119, 58, -14, 93, -90, 113, -66};
// 替换表
static Map<Character, Character> substitutionMap = new HashMap<>();
static Map<Character, Character> reverseSubstitutionMap = new HashMap<>();
public static void main(String[] args) {
initMaps();
int rotationOffset = getRotationOffset();
System.out.println("开始爆破 n7 (0-65535)...");
// 爆破 n7 (中间状态变量)
for (int n7 = 0; n7 < 65536; n7++) {
try {
// 1. 生成密钥
byte[] key = generateKeyFromN7(n7);
// 2. 第一层解密 (KillCount XOR)
byte[] step1 = decryptWithKey(killCountEncryptedFlag, key);
// 3. 分割并异或
int mid = step1.length / 2;
byte[] part1 = new byte[mid];
byte[] part2 = new byte[step1.length - mid];
System.arraycopy(step1, 0, part1, 0, mid);
System.arraycopy(step1, mid, part2, 0, part2.length);
part1 = xorArray(part1, (byte) 102); // 0x66
part2 = xorArray(part2, (byte) 119); // 0x77
// 合并
byte[] step2 = new byte[step1.length];
System.arraycopy(part1, 0, step2, 0, mid);
System.arraycopy(part2, 0, step2, mid, part2.length);
// 4. 第二层解密 (Simple AES - 其实就是循环异或)
byte[] step3 = simpleAesDecrypt(step2, aesEncryptedKey);
// 转字符串
String rawString = new String(step3, StandardCharsets.UTF_8);
// 5. 凯撒解密
String rotated = rotateDecrypt(rawString, rotationOffset);
// 6. 替换解密
String flagCandidate = substitutionDecrypt(rotated);
// 验证格式
if (flagCandidate.startsWith("flag{") && flagCandidate.endsWith("}")) {
System.out.println("-----------------------------------");
System.out.println("找到正确的 n7: " + n7);
System.out.println("原始 Flag: " + flagCandidate);
// 题目最后一步逻辑:替换 flag 为 hgame
String finalFlag = flagCandidate.replace("flag", "hgame");
System.out.println("最终 Flag: " + finalFlag);
System.out.println("-----------------------------------");
break;
}
} catch (Exception e) {
// 忽略解码错误
}
}
}
// 根据 n7 生成 16字节密钥 (对应源码 deriveKeyFromKillCount 的后半部分)
private static byte[] generateKeyFromN7(int n7) {
byte[] byArray = new byte[16];
int n8 = n7;
for (int i = 0; i < 16; ++i) {
n8 = n8 * 1103515245 + 12345 & Integer.MAX_VALUE;
byArray[i] = (byte)((n8 >> 16) % 256);
}
return byArray;
}
// 对应源码 decryptWithKillCount 中的异或逻辑
private static byte[] decryptWithKey(byte[] input, byte[] key) {
byte[] output = new byte[input.length];
for (int i = 0; i < input.length; i++) {
byte k = key[i % key.length];
byte modifier = (byte)((i * 13 + 7) % 256);
output[i] = (byte)(input[i] ^ k ^ modifier);
}
return output;
}
private static byte[] xorArray(byte[] arr, byte val) {
byte[] res = new byte[arr.length];
for (int i = 0; i < arr.length; i++) {
res[i] = (byte)(arr[i] ^ val);
}
return res;
}
private static byte[] simpleAesDecrypt(byte[] input, byte[] key) {
byte[] res = new byte[input.length];
for (int i = 0; i < input.length; i++) {
res[i] = (byte)(input[i] ^ key[i % key.length]);
}
return res;
}
private static int getRotationOffset() {
String s = "PLANTS_VS_ZOMBIES_2025";
int sum = 0;
for (char c : s.toCharArray()) {
sum += c;
}
return sum % 26; // 结果应为 20
}
private static String rotateDecrypt(String s, int n) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
if (Character.isUpperCase(c)) {
int val = (c - 'A' - n + 26) % 26;
sb.append((char)('A' + val));
} else if (Character.isLowerCase(c)) {
int val = (c - 'a' - n + 26) % 26;
sb.append((char)('a' + val));
} else {
sb.append(c);
}
}
return sb.toString();
}
private static String substitutionDecrypt(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray()) {
Character res = reverseSubstitutionMap.get(c);
sb.append(res != null ? res : c);
}
return sb.toString();
}
private static void initMaps() {
// 从源码复制的映射关系
char[][] pairs = {
{'A', 'Q'}, {'B', 'W'}, {'C', 'E'}, {'D', 'R'}, {'E', 'T'}, {'F', 'Y'}, {'G', 'U'},
{'H', 'I'}, {'I', 'O'}, {'J', 'P'}, {'K', 'A'}, {'L', 'S'}, {'M', 'D'}, {'N', 'F'},
{'O', 'G'}, {'P', 'H'}, {'Q', 'J'}, {'R', 'K'}, {'S', 'L'}, {'T', 'Z'}, {'U', 'X'},
{'V', 'C'}, {'W', 'V'}, {'X', 'B'}, {'Y', 'N'}, {'Z', 'M'}, {'_', '!'}, {'{', '['}, {'}', ']'}
};
for (char[] p : pairs) {
substitutionMap.put(p[0], p[1]);
// 生成逆向映射用于解密
reverseSubstitutionMap.put(p[1], p[0]);
}
}
}

Signal Storm
看似是只要动态调试就行,但是实际上是异常处理。

找到主程序入口。这里很明显能发现三个信号处理函数:
-
SIGSEGV (11):sub_1640 -
SIGFPE (8):sub_16E0 -
SIGTRAP (5):sub_1740
在读取到长度为32的flag之后,进入一个循环
-
dword_0 = 0:尝试向空地址写入,触发 SIGSEGV。这会跳转到sub_1640执行,处理完后跳回。 -
raise(5):主动抛出 SIGTRAP。这会跳转到sub_1740执行。
那三个信号处理函数其实是在对flag进行变换。
-
sub_1640 (核心加密步) :
这是一个典型的 RC4 KSA/PRGA 的变种步骤。-
v0 (即i) 递增。 -
dword_4064 (即j) 更新公式:j = (j + S[i] + Key[i % 21]) % 256。 - 交换
S[i] 和S[j]。 - 注意:这里引用的
aC0lmBe4ore7heS 是一个长度为 21 的动态 Key 数组。
-
-
sub_1740 (密钥变换) :-
memmove 和赋值操作实现了将aC0lmBe4ore7heS 数组循环左移 1 位。 - 这意味着每一轮加密使用的 Key 数组都在变化,且每一轮取 Key 的索引也在变化 (
i % 21),大大增加了复杂度。
-
而1780,经过确认则是标准的RC4 KSA过程
其中 key找到发现是:
至此,整个程序的逻辑链条已完全打通:
-
准备:
- 使用 Key
"C0lm_be4ore_7he_st0rm" 对 S-Box 进行标准的 RC4 初始化 (sub_1780)。
- 使用 Key
-
解密循环 (32次) :
-
生成密钥流 (
sub_1640):-
i = (i + 1) % 256 - 魔改点:
j = (j + S[i] + Key[i % 21]) % 256(注意:这里再次用到了 Key 数组,且是当前轮次旋转前的 Key)。 -
Swap(S[i], S[j]) -
K = S[(S[i] + S[j]) % 256]
-
-
异或:
Plain[n] = Cipher[n] ^ K。 -
Key 旋转 (
sub_1740):将 Key 数组循环左移 1 位。
-
import struct
def solve():
# ---------------------------------------------------------
# 1. 提取密文 (Little Endian)
# 来源于 main 函数中的 qword_4088, qword_4090 等
# ---------------------------------------------------------
cipher_qwords = [
0x8260C1C9C8D936E3,
0x1C4BB2D52511D975,
0xF11CAF1C716DE64D,
0x1A5AF67F261CA506
]
cipher = b''
for q in cipher_qwords:
cipher += struct.pack('<Q', q)
print(f"[*] Cipher Hex: {cipher.hex()}")
# ---------------------------------------------------------
# 2. 准备初始数据
# ---------------------------------------------------------
# Key 来源于 .data 段
key_str = "C0lm_be4ore_7he_st0rm" # len = 21
key = [ord(c) for c in key_str]
# S-Box 初始化 (sub_1780)
# 步骤 A: 填充 0-255 (SIMD 部分的等价逻辑)
S = list(range(256))
# 步骤 B: 标准 KSA 打乱
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) % 256
S[i], S[j] = S[j], S[i]
# ---------------------------------------------------------
# 3. 模拟主循环 (sub_1640 和 sub_1740)
# ---------------------------------------------------------
# 全局变量初始化
i_idx = 0
j_idx = 0
decrypted_flag = []
print("[*] Starting decryption loop...")
for n in range(32):
# === SIGSEGV: sub_1640 (生成密钥流) ===
i_idx = (i_idx + 1) % 256
# 魔改点:计算 j 时引入了 Key 数组
# 使用当前状态的 Key 数组
key_val = key[i_idx % 21]
j_idx = (j_idx + S[i_idx] + key_val) % 256
# Swap
S[i_idx], S[j_idx] = S[j_idx], S[i_idx]
# 生成密钥流字节 K
K = S[(S[i_idx] + S[j_idx]) % 256]
# === 解密当前字节 ===
# 异或解密
p = cipher[n] ^ K
decrypted_flag.append(p)
# === SIGTRAP: sub_1740 (Key 旋转) ===
# memmove(base, base+1, 20); base[20] = v0;
# 逻辑:循环左移 1 位
first_byte = key.pop(0)
key.append(first_byte)
# ---------------------------------------------------------
# 4. 输出结果
# ---------------------------------------------------------
flag_bytes = bytes(decrypted_flag)
print(f"[*] Decrypted Bytes: {flag_bytes}")
try:
print(f"[*] FLAG: {flag_bytes.decode()}")
except:
print("[!] Decode error, check key or algorithm details.")
if __name__ == "__main__":
solve()

看不懂的华容道
题目给了一个huarongdao.exe 还有一个game.bin,那么大致猜测game.bin 就是用来存放游戏数据的。

用IDA打开之后发现了程序的主入口,其中有一个地方引起了我的注意:

这里出现了一个 “HuarongDao2026_Salt” 这看起来是某种加密用的盐值
case 0x18u:
sub_140011212(a1);
sub_1400110A0(v49, 32LL); // 初始化 MD5 上下文
sub_140011717(v49);
// [关键步骤 1]:先处理棋盘数据 (BoardBytes)
// 循环 20 次,对应 4x5 = 20 个格子的棋子 ID
for ( j = 0; j < 20; ++j )
sub_1400116A9(v49, a1 + j + 80 + 160); // update(board[j])
// [关键步骤 2]:后处理 Salt
sub_14001148D(v51, "HuarongDao2026_Salt"); // 加载 Salt 字符串
v52 = v51;
v53 = (char *)sub_14001103C(v51);
v54 = sub_14001107D(v52);
// 遍历 Salt 字符串的每个字符并 update
while ( v53 != (char *)v54 )
{
v55 = *v53;
v60[0] = v55;
sub_140011AB4(v49, v60); // update(salt_char)
++v53;
}
v62 = sub_14001144C(v49); // Finalize / HeXDigest
也就说明了,当程序跑起来之后,这一串默认输出的“683200ad824e73245bbe16997099688a” 是什么东西的问题。
那么。接下来只需要找到棋盘最初的状态,用BFS把棋盘的胜利状态算出来就行了。(后来发现也可以直接把这个虚拟机的操作的字节码读出来,也就是game.bin来获取)




那么接下来,解决这个华容道就直接BFS算法
import collections
import hashlib
import sys
import time
# --- Configurations ---
W, H = 4, 5
TARGET_P0_IDX = 13
SHAPES = {
0: (2, 2),
1: (2, 1), 2: (2, 1), 3: (2, 1), 4: (2, 1),
5: (1, 2),
6: (1, 1), 7: (1, 1), 8: (1, 1), 9: (1, 1)
}
INITIAL_POS = (1, 0, 3, 11, 10, 8, 12, 16, 13, 19)
MOVES = {
'w': -4,
'a': -1,
's': 4,
'd': 1
}
MOVE_ORDER = ['w', 'a', 's', 'd']
MASKS = {}
def get_mask(idx, shape):
h, w = shape
r, c = divmod(idx, W)
if r + h > H or c + w > W:
return None
mask = 0
for dr in range(h):
for dc in range(w):
pos = (r + dr) * W + (c + dc)
mask |= (1 << pos)
return mask
def init_masks():
for pid, shape in SHAPES.items():
MASKS[pid] = {}
for idx in range(W * H):
m = get_mask(idx, shape)
if m is not None:
MASKS[pid][idx] = m
init_masks()
def solve_valid():
start_node = tuple(INITIAL_POS)
# Store path: state -> (parent, move)
# To save memory, maybe just store parent? Or rely on reconstructing later?
# To be fast and get the path, I'll store (parent, move).
# Visited: state -> (parent_state, move_from_parent)
visited = {start_node: (None, None)}
queue = collections.deque([start_node])
steps = 0
while queue:
current = queue.popleft()
if current[0] == TARGET_P0_IDX:
# Found solution!
print(f"Solution found!")
path = []
curr = current
while curr != start_node:
parent, move = visited[curr]
path.append(move)
curr = parent
return path[::-1], current
# Calculate full mask
full_mask = 0
current_masks = []
for i in range(10):
m = MASKS[i][current[i]]
current_masks.append(m)
full_mask |= m
# Expand
for pid in range(10):
p_mask = current_masks[pid]
current_pos = current[pid]
board_without_p = full_mask ^ p_mask
for move in MOVE_ORDER:
delta = MOVES[move]
new_pos = current_pos + delta
# --- VALIDITY CHECKS ---
# Check 1: Boundary check handled by get_mask (returns None if OOB)
# Check 2: Row Wrap check for Left/Right
if move == 'a' or move == 'd':
if (current_pos // W) != (new_pos // W):
continue # Invalid wrap-around
new_p_mask = MASKS[pid].get(new_pos)
if new_p_mask is not None:
if (new_p_mask & board_without_p) == 0:
new_state = current[:pid] + (new_pos,) + current[pid+1:]
if new_state not in visited:
visited[new_state] = (current, f"{pid}{move}")
queue.append(new_state)
steps += 1
if steps % 50000 == 0:
print(f"Steps: {steps}, Queue: {len(queue)}")
sys.stdout.flush()
if __name__ == "__main__":
path, final_state = solve_valid()
print(f"Path Length: {len(path)}")
print("Sequence:")
print(" ".join(path))
# Also calc hash of final state
salt = "HuarongDao2026_Salt"
board_bytes = [255] * 20
for pid, pos in enumerate(final_state):
h, w = SHAPES[pid]
r, c = divmod(pos, W)
for dr in range(h):
for dc in range(w):
idx = (r + dr) * W + (c + dc)
board_bytes[idx] = pid
m = hashlib.md5()
m.update(bytes(board_bytes))
m.update(salt.encode('utf-8'))
digest = m.hexdigest()
digest_bytes = bytes.fromhex(digest)
reversed_digest = digest_bytes[::-1].hex()
print(f"Final Hash: {reversed_digest}")

NonceSense
题目给了三个文件:
- Client.exe:用户态的客户端程序。
- GateDriver.sys:内核驱动程序。
- Drv_blob.bin:被加密后的数据文件。
任务很明确:分析加密流程,解密 Drv_blob.bin 获取 Flag。
将 Client.exe 拖入 IDA。定位到 main 函数,逻辑看起来比较清晰:
- 输入处理:程序读取用户输入。
- 预处理 (VM混淆):
输入数据并没有直接发送给驱动,而是先经过了一个 do-while 循环。
// IDA 伪代码片段
do {
switch ( v12 ) {
case 0: ...
case 1: ... // XOR
case 6: ... // ROL (循环左移)
}
// ...读取字节码...
} while ( v12 \<\= 6 );
这里看起来像是一个微型的 VM (虚拟机) 混淆。它读取一段硬编码的字节码 (unk_1400043F3),根据操作码对输入数据的每一个字节进行变换。
通过仔细分析这几个 case 和字节码,我还原了加密(混淆)逻辑:
- v24 寄存器实际上就是当前处理的字节。
- 它对每个字节 input[i] 进行了以下操作:
- x ^= (i * 13 + 195) & 0xFF (基于索引的线性变换)
- x = ROL(x, (i * 3 + 1) & 7) (循环左移)
- x ^= 0x5A (固定异或)
- 驱动交互:
处理完数据后,Client 调用了 DeviceIoControl 与驱动通信。
- IOCTL 0x222000:获取 Nonce (虽然获取了,但在 VM 逻辑里似乎没用到)。
- IOCTL 0x222004:将处理后的数据发送给驱动进行加密,结果写出到 Drv_blob.bin。
接着分析内核驱动 GateDriver.sys。在 DriverEntry 中找到分发例程,定位到处理 DeviceIoControl 的函数 sub_1400010C0。
1. 算法识别
在驱动中漫游时,注意到了一个加密函数 sub_1400016B0。
查看其引用的数据,发现了一个非常眼熟的数组:
0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B…
这正是 AES 的 S-Box!这说明驱动内部使用了 AES 加密。看循环结构,是对每个 16 字节块独立加密,应该是 ECB 模式。
2. 密钥生成 (HKDF)
AES 的密钥从哪来?跟踪调用链,发现密钥是在 sub_1400019B8 中生成的。
这个函数引用了一系列 SHA-256 的常数 (0x6A09E667, 0xBB67AE85…)。
逻辑大概是:
Key = HMAC_SHA256(Key=PRK, Data=KeyMaterial + "\x01")
这非常像 HKDF (HMAC-based Extract-and-Expand Key Derivation Function) 的扩展阶段。
3. 关键材料
继续追踪,发现参与密钥生成的有两个关键部分:
- Key Material: 一串解密后的硬编码字符串,我在内存中找到了它:"VIDAR_HGAME_D3C_A3S_K2_build2026"。
- Nonce: 一个 16 字节的随机数。
Nonce 在哪?
最初我试图分析驱动中生成 Nonce 的函数 sub_140001668,发现它使用了 KeQueryPerformanceCounter 作为种子。
我想过爆破种子(Seed 是 32 位的),写了个 OpenMP 的 C 脚本跑了一遍,结果一无所获。这让我意识到方向可能错了。
突破:重新审视密文
既然爆破行不通,我回头看了一眼 Drv_blob.bin。
文件大小是 80 字节。
AES 的块大小是 16 字节,80 字节正好是 5 个块。
通常 flag 长度都在 30-60 字节左右,加上 Padding 可能是 64 字节(4 块)。
多出来的 16 字节是什么?
在密码学协议设计中,为了解密方能解密,通常会将 IV (或 Nonce) 明文放在密文的最前面!
虽然驱动代码看起来有些绕,但我猜测:
Drv_blob.bin = [Nonce (16 bytes)] + [AES_Ciphertext (64 bytes)]
验证猜想的时刻到了。编写了如下 Python 脚本:
- 从 Drv_blob.bin 读入数据。
- 切分:前 16 字节视为 Nonce,后面视为密文。
- 计算密钥:模拟驱动的 HKDF 逻辑,用提取的 Nonce 和硬编码字符串生成 AES Key。
- AES 解密:用生成的 Key 解密后半部分数据。
- 去混淆 (Client VM 逆向):将 AES 解密后的数据,按照 Client 中 VM 的逻辑逆向操作(ROR, XOR)。
import struct
import hashlib
import hmac
from Crypto.Cipher import AES
# 1. 载入密文
# 来自 Drv_blob.bin
CIPHERTEXT_FULL = bytes.fromhex("8a8fa4ab7254811ef7a28a47394b66bf24c7cf462bf5f7e04e07abdf170e1ea6ec8df713af3a574b381d396a9ea1eb0260b8aa8a646d9d26d2454c79459510dc532317b2f1488960220a9104ee31772d")
# 2. 准备 Key Material (从驱动中提取并解密后的字符串)
# 原始内存数据经过简单的 ROR 解密后得到这个字符串
KEY_MAT_STR = b"VIDAR_HGAME_D3C_A3S_K2_build2026"
def ror8(x, n):
n = n % 8
return ((x >> n) | (x << (8 - n))) & 0xFF
# 3. 逆向 Client 的 VM 混淆逻辑
def deobfuscate_byte(y, i):
# 正向逻辑:
# x ^= (i * 13 + 195)
# x = ROL(x, shift)
# x ^= 0x5A
# 逆向逻辑:
y = y ^ 0x5A
shift = (i * 3 + 1) & 7
# ROL 的逆操作是 ROR
y = ror8(y, shift)
y = y ^ ((i * 13 + 195) & 0xFF)
return y
# 4. 模拟驱动的 HKDF 密钥派生
def hkdf_derive(nonce):
# Step 1: Extract (虽然 salt 是 0,但这里 nonce 充当了 IKM)
prk = hmac.new(b'\x00'*32, nonce, hashlib.sha256).digest()
# Step 2: Expand
okm = hmac.new(prk, KEY_MAT_STR + b'\x01', hashlib.sha256).digest()
return okm[:16] # 取前16字节做 AES-128 Key
if __name__ == '__main__':
# 猜想:前 16 字节是 Nonce
nonce = CIPHERTEXT_FULL[:16]
cipher_data = CIPHERTEXT_FULL[16:]
print(f"Nonce: {nonce.hex()}")
# 计算 AES 密钥
key = hkdf_derive(nonce)
print(f"Derived Key: {key.hex()}")
# AES 解密
cipher = AES.new(key, AES.MODE_ECB)
decrypted = cipher.decrypt(cipher_data)
# VM 去混淆
plain = bytearray(len(decrypted))
for i in range(len(decrypted)):
plain[i] = deobfuscate_byte(decrypted[i], i)
print(f"Flag: {plain.decode('utf-8', errors='ignore')}")
运行脚本,成功得到 Flag:
Nonce: 8a8fa4ab7254811ef7a28a47394b66bf
Derived Key: …
Flag: hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}