SECCON 2017 オンライン CTF
セキュリティ技術コンテスト SECCON のオンライン予選CTF が、2017年12月9日(土)15:00JST~翌10日(日)15:00JSTの24時間かけて開催されました。
オンライン大会なので世界中から参加があり、今年は 102ヶ国から 1794チーム 4347人の参加登録がありました。
CTF の問題ジャンル
CTF(Capture The Flag)は、クイズ(Jeopardy)形式や、攻防戦(Attack&Defence)形式などがあります。
SECCONオンライン予選はクイズ形式で、主催者が出題した問題を解き、隠されているフラグ文字列を探し出すと、難易度に応じた得点を得られ、総合得点を競います。
以下のようなジャンルから出題されます。他の大会ではSteganoやRECONといったジャンルもあります。
- Binary, Reverse: 実行形式バイナリやバイナリデータを解析してフラグを取り出す
- Web: Webサーバの中からフラグを探し出す
- Crypto: 暗号を解いてフラグを取り出す
- Forensics: ディスクダンプ、メモリダンプ、パケットダンプなどからフラグを取り出す
- Pwn, Exploit: サーバの脆弱性を探し、サーバの中からフラグを取り出す
- Programming, PPC: プログラムを作成、実行してフラグを得る
セキュリティの仕事でも、大量のログデータから目当てのデータを抽出したり、バイナリデータ解析などでプログラミング技術が必要になります。
ですので、CTFにもPPC(Professional Programming and Coding)という競技プログラミング的ジャンルがあります。
プログラミング問題:Cubic Rube
今回、そのプログラミング問題として「Qubic Rube」(Rubic's Cube + QR code) という問題を作りました。しばらく問題サーバが動いていますのでアクセスしてみてください。
出題された問題はgithubで公開されています。
Qubic Rube は docker で動くように Docker ファイルも用意しています。依存ライブラリの確認にも使えます。
アイデア
何年か前のSECCONで、QRコードを使った問題がQRというジャンルで多数出題されました。
参加者の中には、CTF問題としてQRコードはふさわしくないと考える人もいるのですが、SECCONと言えばQRコードと楽しみにしてくれている人もいます。
プログラミングジャンルであれば、単純なゲームをクリアするような問題もありますので、QRコードを使ったゲームをいろいろ考えました。そのときに、ルービックキューブにQRコードを貼り付けたら面白いんじゃないか? というアイデアが浮かびました。
最初、google doodlesのルービックキューブ のように、動かして解けるようにしようかとも考えたのですが、手での操作に没頭する人が現れるのは間違いなく、画像データを加工するプログラミング問題にしたかったので、単純な立方体にQRコードを貼り付けることにしました。
CTFでは、100問ゲームをクリアするとフラグが表示されるプログラミング問題がいろいろあります。私も、ボーリングスコアを100問計算したり、あみだくじを100問クリアする問題を過去に出題しました。
今回も、ルービックキューブを100個解いたらフラグを表示する問題にしようと思ったのですが、画像データサイズが大きくてサーバ負荷や各国のインターネット回線事情を考慮し、半分の50問にしました。
問題数が少な過ぎると、プログラムを書かずに力技でクリアする人が現れるので、手で解く気になれないくらいの数にする必要があります。それでも、手で解こうという人は絶対いるんですけどね。
さて、50個のルービックキューブをどうやって出題するかですが、1度に50個のルービックキューブを表示することもできますが、1個表示して解けたら次のルービックキューブを表示することにしました。
次に、ルービックキューブの各面にどんなQRコードを表示するかですが、次問題のURLにしておけば、解いた人だけが先に進めるので好都合です。
URLを6分割の連結QRコードにして各面に張り付けることもできますが、QRコード連結機能を知らなくて繋ぐ順番がわからない人が多そうなのでやめました。
各面にはそれぞれ違うメッセージを書いておくことにし、6面のどこかに次の問題のURLを貼り付けることにします。また、どこかの面に「No. 1/50」と書いておけば50問あることも分かってもらえるだろうと考えました。
また、1問目からシャッフルした状態のルービックキューブを表示するのではなく、1問目は何も動かしてない状態、2問目は1か所動かした状態、3問目は2か所動かした状態にして、最初はスマホのカメラなどでも読み取れるようにしておいて、「そういうことか!」と何をすればいいか理解しやすいようにしておきました。
そして、50問目にはURLの代わりにフラグ文字列を貼り付けておきます。
参加者は50問解くプログラムを作ればいいのですが、フラグが常に同じ面にあると簡単ですし、色の配置が同じでも簡単です。
そこで、少し意地悪を加えて、11問目に色の配置をランダムに変え、21問目にURLを置く面を変えるようにしました。途中で色のRGB値を変えようかとも考えましたが、さすがにやめておきました。
Qubic Rube 問題作成プログラム
共通情報
共通情報は、一つのpythonスクリプトにして、それぞれのスクリプトから import して使うようにしました。
HOSTNAME = 'qubicrube.pwn.seccon.jp'
PORT = 33654 # 3x3x6=54
NUM = 50
FIRST_PATH = '01000000000000000000'
PATH_LENGTH = len(FIRST_PATH)
PATH_FILENAME = 'pathes.txt'
URL_FORMAT = 'http://%s:%s/%%s' % (HOSTNAME, PORT)
URL_LENGTH = len(URL_FORMAT % FIRST_PATH)
50個分のURLを作る
まず、50個の問題のURLを作ります。
短すぎるとブルートフォースアタックで特定されてしまうので、ブルートフォースしたくなくなる長さは必要です。
10桁だとブルートフォースする人がいそうなので20桁のランダム16進数にしました。
作ってみたあと、出来上がったイメージを確認するのに順序が分からなくて大変だったので、最初の2桁は問題番号にしました。
1問目は固定で'01000000000000000000'
にして、2問目以降を作ります。
# !/usr/bin/env python
# -*- coding:utf-8 -*-
"""
make web page pathes for Qubic Rube of SECCON 2017 Online CTF
"""
__author__ = "@shiracamus"
__version__ = "1.0"
__date__ = "09 December 2017"
import os
from vars import *
# The first byte of the path is a sequence number.
# The follow bytes are random numbers.
# But, the follows are all 0 for sequence number 1.
# So, this script makes pathes after sequence number 1.
FOLLOW_BYTES = (PATH_LENGTH - 2) // 2 # -2 for the sequence number
for number in range(2, NUM + 1):
print(('%02d' % number) + os.urandom(FOLLOW_BYTES).encode('hex'))
メッセージを書いた白黒QRコードイメージを作る
QRコードの生成は qrcode を使いました。
メッセージの長さが違うと出来上がるQRコードのイメージサイズが違ってしまい、ルービックキューブに張り付けて動かしたときに表示がデコボコになってしまうので、文字列の最後に空白を加えて長さを合わせています。
また、ライブラリのデフォルト設定ではオプティマイズ機能が有効で、空白などを削られてイメージサイズが変わってしまうので、オプティマイズしない指定も必要です。
# !/usr/bin/env python
# -*- coding:utf-8 -*-
"""
make QR code images for Qubic Rube of SECCON 2017 Online CTF
"""
__author__ = "@shiracamus"
__version__ = "1.0"
__date__ = "09 December 2017"
import qrcode # sudo pip install qrcode
from vars import *
FLAG = open("../flag.txt").read().strip()
MESSAGES = {
'welcome': 'SECCON 2017 Online CTF',
'title': 'Qubic Rube',
'url': 'Next URL is:',
'havefun': 'Have fun!',
'gogo': 'Go! Go!',
'grats': 'Congratulations!',
'flag': 'The flag is:',
}
LEN = max((
URL_LENGTH,
len(FLAG),
max(map(len, MESSAGES.values())),
))
def make_qrcode(text):
qr = qrcode.QRCode(box_size=6)
qr.add_data(text.ljust(LEN), optimize=0)
return qr.make_image()
def make_messages():
for title, message in MESSAGES.items():
make_qrcode(message).save('qrcode/%s.png' % title)
def make_numbers():
for i in range(1, NUM + 1):
make_qrcode('No. %d / %d' % (i, NUM)).save('qrcode/no%d.png' % i)
def make_pathes():
with open('pathes.txt') as pathes:
for path in pathes:
path = path.strip()
make_qrcode(URL_FORMAT % path).save('qrcode/%s.png' % path)
def make_flag():
make_qrcode(FLAG).save('qrcode/seccon.png')
if __name__ == '__main__':
make_messages()
make_numbers()
make_pathes()
make_flag()
シャッフルしたルービックキューブデータを作る
いきなり画像データを作るのは大変なので、テキスト形式でシャッフルした状態のルービックキューブデータを作ることにしました。
普通のルービックキューブは色だけなので、回転によって面の向きを意識することはないのですが、QRコードを貼り付けるとなると面の向きがどう変わるかも考慮しなければなりません。計算で求めることもできるとは思いますが、手っ取り早く、ダイソーで買ってきた玩具の各面に字を書いてどっち向きになるか確認しました。
手抜きをして、真ん中の軸は動かさず、外周の面だけを回転させています。
大会終了後に気付いたのですが、3問目が同じ面をシャッフルしていて2問目よりも簡単になっていたので、同じ面をシャッフルしない配慮も必要でした。残念。
シャッフルした結果は辞書データにして、単純に文字列化してファイルに出力します。使う側ではevalして辞書データに戻します。evalはセキュリティ的に危険な行為ですが、変換時に使うだけなのでそれほど気にしなくて大丈夫でしょう。
# !/usr/bin/env python3
# -*- coding:utf-8 -*-
"""
make shuffled QR code patterns for Qubic Rube of SECCON 2017 Online CTF
"""
__author__ = "@shiracamus"
__version__ = "1.0"
__date__ = "09 December 2017"
import random
from vars import *
# +-------------------------+
# | +-------------------+ |
# v v +-------------+ | |
# +---------+ | | |
# +------>|U1^U2^U3^|-------+ | | |
# | +--->|U4^U5^U6^|----+ | | | |
# | | +>|U7^U8^U9^|-+ v v | | |
# +---------+---------+---------+---------+
# |L1^L2^L3^|F1^F2^F3^|R1^R2^R3^|B1^B2^B3^|
# |L4^L5^L6^|F4^F5^F6^|R4^R5^R6^|B4^B5^B6^|
# |L7^L8^L9^|F7^F8^F9^|R7^R8^R9^|B7^B8^B9^|
# +---------+---------+---------+---------+
# ^ ^ +-|D1^D2^D3^|<+ | | ^ ^ ^
# | +----|D4^D5^D6^|<---+ | | | |
# +-------|D7^D8^D9^|<-----+ | | |
# +---------+ | | |
# | | +-------------+ | |
# | +-------------------+ |
# +-------------------------+
# rotation data: (move_from, move_to, rotate)
# the rotate means 0: 0 degree, 1: 90 degree, 2: 180 degree, 3: 270 degree
ROTATE_FRONT = (
('F1', 'F3', 1), ('F2', 'F6', 1), ('F3', 'F9', 1),
('F4', 'F2', 1), ('F5', 'F5', 1), ('F6', 'F8', 1),
('F7', 'F1', 1), ('F8', 'F4', 1), ('F9', 'F7', 1),
('U7', 'R1', 1), ('U8', 'R4', 1), ('U9', 'R7', 1),
('R1', 'D3', 1), ('R4', 'D2', 1), ('R7', 'D1', 1),
('D3', 'L9', 1), ('D2', 'L6', 1), ('D1', 'L3', 1),
('L9', 'U7', 1), ('L6', 'U8', 1), ('L3', 'U9', 1),
)
ROTATE_BACK = (
('B1', 'B3', 1), ('B2', 'B6', 1), ('B3', 'B9', 1),
('B4', 'B2', 1), ('B5', 'B5', 1), ('B6', 'B8', 1),
('B7', 'B1', 1), ('B8', 'B4', 1), ('B9', 'B7', 1),
('U3', 'L1', 3), ('U2', 'L4', 3), ('U1', 'L7', 3),
('L1', 'D7', 3), ('L4', 'D8', 3), ('L7', 'D9', 3),
('D7', 'R9', 3), ('D8', 'R6', 3), ('D9', 'R3', 3),
('R9', 'U3', 3), ('R6', 'U2', 3), ('R3', 'U1', 3),
)
ROTATE_UP = (
('U1', 'U3', 1), ('U2', 'U6', 1), ('U3', 'U9', 1),
('U4', 'U2', 1), ('U5', 'U5', 1), ('U6', 'U8', 1),
('U7', 'U1', 1), ('U8', 'U4', 1), ('U9', 'U7', 1),
('B3', 'R3', 0), ('B2', 'R2', 0), ('B1', 'R1', 0),
('R3', 'F3', 0), ('R2', 'F2', 0), ('R1', 'F1', 0),
('F3', 'L3', 0), ('F2', 'L2', 0), ('F1', 'L1', 0),
('L3', 'B3', 0), ('L2', 'B2', 0), ('L1', 'B1', 0),
)
ROTATE_RIGHT = (
('R1', 'R3', 1), ('R2', 'R6', 1), ('R3', 'R9', 1),
('R4', 'R2', 1), ('R5', 'R5', 1), ('R6', 'R8', 1),
('R7', 'R1', 1), ('R8', 'R4', 1), ('R9', 'R7', 1),
('U9', 'B1', 2), ('U6', 'B4', 2), ('U3', 'B7', 2),
('B1', 'D9', 2), ('B4', 'D6', 2), ('B7', 'D3', 2),
('D9', 'F9', 0), ('D6', 'F6', 0), ('D3', 'F3', 0),
('F9', 'U9', 0), ('F6', 'U6', 0), ('F3', 'U3', 0),
)
ROTATE_DOWN = (
('D1', 'D3', 1), ('D2', 'D6', 1), ('D3', 'D9', 1),
('D4', 'D2', 1), ('D5', 'D5', 1), ('D6', 'D8', 1),
('D7', 'D1', 1), ('D8', 'D4', 1), ('D9', 'D7', 1),
('F7', 'R7', 0), ('F8', 'R8', 0), ('F9', 'R9', 0),
('R7', 'B7', 0), ('R8', 'B8', 0), ('R9', 'B9', 0),
('B7', 'L7', 0), ('B8', 'L8', 0), ('B9', 'L9', 0),
('L7', 'F7', 0), ('L8', 'F8', 0), ('L9', 'F9', 0),
)
ROTATE_LEFT = (
('L1', 'L3', 1), ('L2', 'L6', 1), ('L3', 'L9', 1),
('L4', 'L2', 1), ('L5', 'L5', 1), ('L6', 'L8', 1),
('L7', 'L1', 1), ('L8', 'L4', 1), ('L9', 'L7', 1),
('U1', 'F1', 0), ('U4', 'F4', 0), ('U7', 'F7', 0),
('F1', 'D1', 0), ('F4', 'D4', 0), ('F7', 'D7', 0),
('D1', 'B9', 2), ('D4', 'B6', 2), ('D7', 'B3', 2),
('B9', 'U1', 2), ('B6', 'U4', 2), ('B3', 'U7', 2),
)
ROTATE_FACES = (
ROTATE_FRONT,
ROTATE_BACK,
ROTATE_UP,
ROTATE_DOWN,
ROTATE_LEFT,
ROTATE_RIGHT,
)
FACES = 'FBUDLR' # Front, Back, Up, Down, Left, Right
NUMBERS = '123456789' # 3x3
DIRECTIONS = '^>v<' # up(0 degree), right(+90), down(+180), left(+270)
class RubicCube:
def __init__(self):
self.faces = {face + number: (face + number, 0)
for face in FACES
for number in NUMBERS}
def __str__(self):
return str({face + number: self.faces[face + number]
for face in FACES
for number in NUMBERS})
def rotate(self, moves):
faces = self.faces
new_faces = faces.copy()
for move_from, move_to, rotate in moves:
face, direction = faces[move_from]
new_faces[move_to] = face, (direction + rotate) % 4
self.faces = new_faces
def shuffle(self, times=1):
for t in range(times):
face = random.choice(ROTATE_FACES)
for angle in range(random.choice((1, 2, 3))): # times makes angle
self.rotate(face)
def main():
for i in range(NUM):
cube = RubicCube()
cube.shuffle(i)
open('rubic/%d.txt' % (i + 1), 'w').write(str(cube))
if __name__ == '__main__':
main()
QRコードとシャッフルデータから6面カラーイメージ作成
6面分のQRコードイメージを読み込んで背景をルービックキューブ色それぞれ9分轄し、シャッフルされたルービックキューブデータの移動位置に向きを合わせて配置し、6枚のイメージを作成して server/images 配下に格納します。
色のRGBはこちらを参照しました。
# !/usr/bin/env python
# -*- coding:utf-8 -*-
"""
make images for Qubic Rube web server of SECCON 2017 Online CTF
"""
__author__ = "@shiracamus"
__version__ = "1.0"
__date__ = "09 December 2017"
import random
from PIL import Image # sudo apt install python-imaging
from vars import *
def RGB(rgb):
return ((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff)
COLORS = {
'U': RGB(0xFFFFFF), # White
'L': RGB(0xFF5800), # Orange
'F': RGB(0x009E60), # Green
'R': RGB(0xC41E3A), # Red
'B': RGB(0xFFD500), # Yellow
'D': RGB(0x0051BA), # Blue
}
BLACK = RGB(0)
FACES = 'ULFRBD'
NUMBERS = '123456789'
def shuffle_colors():
colors = list(COLORS.values())
random.shuffle(colors)
for face, color in zip(FACES, colors):
COLORS[face] = color
def paste_qr(image, qr):
width, height = image.size
for y in range(height):
for x in range(width):
if qr.getpixel((x, y)) == 0:
image.putpixel((x, y), BLACK)
def make_rubic_faces(qrnames, rubic):
qrs = [Image.open(qr) for qr in qrnames]
width, height = size = qrs[0].size
w = h = width // 3
faces = [Image.new('RGB', (w * 3, h * 3), COLORS[face]) for face in FACES]
crops = {}
for FACE, face, qr in zip(FACES, faces, qrs):
paste_qr(face, qr)
for n, NUMBER in enumerate(NUMBERS):
x, y = (n % 3) * w, (n // 3) * h
crops[FACE + NUMBER] = face.crop((x, y, x + w, y + h))
moved = {move_to: crops[FACE + NUMBER].rotate(rotate * 90)
for FACE in FACES
for NUMBER in NUMBERS
for move_to, rotate in [rubic[FACE + NUMBER]]}
for face, FACE in enumerate(FACES):
for n, NUMBER in enumerate(NUMBERS):
faces[face].paste(
moved[FACE + NUMBER], ((n % 3) * w, (n // 3) * h))
return faces
def make_image(number, faces, path):
with open('rubic/%s.txt' % number) as rubic:
faces = make_rubic_faces(faces, eval(rubic.read()))
for FACE, face in zip(FACES, faces):
face.save('server/images/%s_%s.png' % (path, FACE))
def make_images():
with open(PATH_FILENAME) as f:
pathes = f.readlines()
number = 1
path = FIRST_PATH
for next_path in pathes:
next_path = next_path.strip()
images = [
'qrcode/welcome.png', # SECCON 2017 Online CTF
'qrcode/title.png', # Qubic Rube
'qrcode/no%d.png' % number, # No. number / NUM
'qrcode/url.png', # Next URL is:
'qrcode/%s.png' % next_path, # http://x.x.x.x/path
'qrcode/havefun.png', # Have fun!
]
if number > 10:
shuffle_colors()
if number > 20:
random.shuffle(images)
if number > 40:
images[images.index('qrcode/havefun.png')] = 'qrcode/gogo.png'
make_image(number, images, path)
number += 1
path = next_path
# LAST: the flag
images = [
'qrcode/welcome.png', # SECCON 2017 Online CTF
'qrcode/title.png', # Cubic Rube
'qrcode/no%d.png' % number, # No. number / NUM
'qrcode/grats.png', # Congraturations!
'qrcode/flag.png', # The flag is:
'qrcode/seccon.png', # SECCON{...}
]
make_image(number, images, path)
if __name__ == '__main__':
make_images()
Webサーバを立てる
Webサーバ
Webサーバはbottleを使いました。
沢山のリクエストを裁くために、bjoernも導入しました。
ディレクトリ構成や必要なファイルはリポジトリを参照してください。
# !/usr/bin/env python
# -*- coding:utf-8 -*-
"""
Qubic Rube web server of SECCON 2017 Online CTF
"""
__author__ = "@shiracamus"
__version__ = "1.0"
__date__ = "09 December 2017"
# sudo apt install libev-dev
# sudo pip install bottle
# sudo pip install bjoern
from bottle import default_app, route, template, static_file, request
from datetime import datetime
from sys import stdout
HOST = '0.0.0.0'
PORT = 33654 # 3x3x6=54
TOP = '01000000000000000000'
def log(message):
stdout.write( datetime.now().strftime('%Y/%m/%d %H:%M:%S')
+ ' ' + str(request.remote_addr)
+ ' ' + message
+ '\n'
)
stdout.flush()
@route('/')
def index():
return template('index', path=TOP)
@route('/<path:re:[0-9a-f]{20}>')
def next(path):
return template('index', path=path)
@route('/js/three.min.js')
def threejs():
return static_file('three.min.js', root="./js")
@route('/images/<file_path:path>')
def image(file_path):
if file_path.endswith('_B.png'):
log(file_path)
return static_file(file_path, root='./images')
import bjoern
bjoern.run(default_app(), host=HOST, port=PORT)
index.htmlのテンプレート
three.jsを使って立方体を表示するindex.htmlを用意しなければなりません。
手っ取り早く、three.js のデモにあったgeometry / cubeをお手本にしました。
キューブに張り付ける順序はここなどを参照しました。
<!DOCTYPE html>
<html lang="en">
<head>
<title>Qubic Rube - SECCON 2017 Online CTF</title> -->
<!-- <title>three.js webgl - geometry - cube</title> -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0">
<style>
body {
margin: 0px;
background-color: #000000;
overflow: hidden;
}
</style>
</head>
<body>
<script src="/js/three.min.js"></script>
<script>
var camera, scene, renderer;
var cube;
init();
animate();
function init() {
camera = new THREE.PerspectiveCamera( 70, window.innerWidth / window.innerHeight, 1, 1000 );
camera.position.z = 400;
scene = new THREE.Scene();
var loader = new THREE.TextureLoader();
var texture1 = new loader.load( "/images/{{path}}_R.png" );
var texture2 = new loader.load( "/images/{{path}}_L.png" );
var texture3 = new loader.load( "/images/{{path}}_U.png" );
var texture4 = new loader.load( "/images/{{path}}_D.png" );
var texture5 = new loader.load( "/images/{{path}}_F.png" );
var texture6 = new loader.load( "/images/{{path}}_B.png" );
var materials = [
new THREE.MeshBasicMaterial( { map: texture1 } ),
new THREE.MeshBasicMaterial( { map: texture2 } ),
new THREE.MeshBasicMaterial( { map: texture3 } ),
new THREE.MeshBasicMaterial( { map: texture4 } ),
new THREE.MeshBasicMaterial( { map: texture5 } ),
new THREE.MeshBasicMaterial( { map: texture6 } ),
];
var faceMaterial = new THREE.MeshFaceMaterial( materials );
var geometry = new THREE.BoxGeometry( 128, 128, 128 );
cube = new THREE.Mesh( geometry, faceMaterial );
scene.add( cube );
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( window.innerWidth, window.innerHeight );
document.body.appendChild( renderer.domElement );
window.addEventListener( 'resize', onWindowResize, false );
}
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize( window.innerWidth, window.innerHeight );
}
function animate() {
requestAnimationFrame( animate );
cube.rotation.x += 0.005;
cube.rotation.y += 0.01;
renderer.render( scene, camera );
}
</script>
</body>
</html>
起動
以下のコマンドで Qubic Rube Webサーバを起動できます。
$ python QubicRube.py
大会中は大量のアクセスでファイルオープン数上限に達してサーバが何度か落ちましたので、以下のシェルスクリプトで再起動するようにしました。
# !/bin/bash
while [ ! -e stop ]
do
python QubicRube.py >> QubicRube.log 2>&1
echo `date "+%Y/%m/%d %H:%M:%S"` aborted
sleep 3
done
rm -f stop
問題を作り変える
buildディレクトリで以下のコマンドでできます。
$ make rebuild
解き方
他の方の Write-Up や github にある answer.txt および solver.py を参照してください。