SSRF#
Server-Side Request Forgery는 사용자가 전달한 주소로 서버가 대신 요청을 보내도록 유도하는 취약점이에요. 브라우저에서는 직접 접근할 수 없는 내부 서비스도 서버의 관점에서는 로컬 요청이 될 수 있다는 점이 핵심이에요.
SSRF를 분석할 때는 요청 주체가 브라우저인지 서버인지 먼저 구분해야 해요. 사용자가 URL을 입력하고 서버가 requests.get() 같은 함수로 해당 URL에 접근한다면, 실제 내부 요청을 보내는 주체는 브라우저가 아니라 서버예요.
요청 흐름#
이미지 URL을 입력받아 서버가 이미지를 가져오는 기능을 예로 들 수 있어요.
사용자 입력 URL
↓
웹 서버의 요청 함수
↓
외부 사이트 또는 내부 서비스
↓
서버가 받은 응답을 사용자에게 전달textfrom flask import Response, request
import requests
@app.get("/image")
def image():
image_url = request.args.get("image_url", "")
response = requests.get(image_url, timeout=3)
content_type = response.headers.get(
"Content-Type",
"application/octet-stream",
)
return Response(response.content, content_type=content_type)
@app.get("/flag")
def flag():
if request.remote_addr != "127.0.0.1":
return "403 Forbidden", 403
return "FLAG{example_flag_value}"python예를 들면 이 코드에선 image_url에 대한 허용 범위를 검사하지 않으므로 서버가 접근할 수 있는 모든 주소가 요청 대상이 될 수 있어요.
내부 서비스가 127.0.0.1:8000에 열려 있고 외부 요청에는 /flag가 403을 반환한다면, 서버가 내부에서 다음 주소를 요청하도록 만들 수 있어요.
GET /image?image_url=http://127.0.0.1:8000/flag HTTP/1.1
Host: <문제 호스트>http이 요청의 흐름은 사용자 → 웹 서버 → 127.0.0.1:8000이에요. 외부 사용자가 직접 /flag에 접근한 게 아니라 서버가 로컬 요청을 보냈으므로, 로컬 사용자만 접근할 수 있는 기능이 응답을 반환할 수 있어요.
Path Traversal#
서버가 URL의 경로 뒤에 사용자 입력을 그대로 붙이는 경우에는 Path Traversal을 사용할 수 있어요. 예를 들어 서버가 다음과 같은 주소를 만든다고 가정해요.
base_url = "http://127.0.0.1/db/image/"
image = request.args.get("image", "")
response = requests.get(base_url + image, timeout=3)pythonimage=../../flag를 입력하면 서버가 요청하는 문자열은 다음과 같아져요.
http://127.0.0.1/db/image/../../flagtextURL 경로가 정상화되면 ..은 상위 디렉터리를 뜻하므로 경로가 http://127.0.0.1/flag처럼 바뀔 수 있어요.
다만 URL 클라이언트나 웹 서버가 요청 전후에 경로를 어떻게 정상화하는지에 따라 결과가 달라질 수도 있어요.
호스트 필터 우회#
127.0.0.1이나 localhost라는 문자열만 검사하는 필터는 다른 호스트 표기를 놓칠 수 있어요.
| 표기 | 의미와 주의점 |
|---|---|
http://127.1 | 일부 파서에서 루프백 주소의 축약형으로 해석될 수 있어요 |
http://127.0.1 | 파서에 따라 IPv4 축약 표기로 해석될 수 있어요 |
http://0 | 라이브러리에 따라 로컬 주소로 해석될 수 있어요 |
http://0.0.0.0 | 모든 인터페이스를 뜻하는 주소라 환경별 동작을 확인해야 해요 |
http://[::] | IPv6의 와일드카드 주소라 네트워크 설정에 따라 결과가 달라져요 |
이 값들이 언제나 같은 주소로 해석되는 것은 아니에요. 핵심은 문자열을 차단하는 데 그치지 않고 URL을 파싱한 뒤 호스트를 실제 IP로 해석하고, 루프백·사설·링크 로컬 대역을 차단하는 데 있어요. 리다이렉션이 발생하면 최종 주소도 다시 검사해야 해요.
Command Injection#
Command Injection은 사용자의 입력이 프로그램의 인자가 아니라 셸 명령어의 일부로 해석되는 취약점이에요. 서버가 시스템 명령어를 실행하는 기능을 제공하면서 입력을 검증하지 않거나 명령어 문자열에 직접 이어 붙이면 발생할 수 있어요.
웹 애플리케이션에서는 다음과 같은 시스템 함수를 볼 수 있어요.
| 언어 | 예시 API | 주의할 점 |
|---|---|---|
| PHP | system() | 문자열을 시스템 명령어로 전달해요 |
| Node.js | child_process.exec() | 기본적으로 셸을 거쳐 명령어를 실행해요 |
| Python | os.system() | 문자열 형태의 셸 명령어를 실행해요 |
이 함수 자체가 항상 취약한 것은 아니에요. 사용자 입력을 셸 명령어 문자열에 결합하는 방식이 문제를 만들어요. 예를 들어 입력한 호스트에 ping을 보내는 기능을 다음처럼 구현하면 위험해요.
import os
from flask import request
@app.get("/ping")
def ping():
host = request.args.get("host", "")
os.system(f"ping -c 1 {host}")
return "done"python이 코드는 host가 IP 주소라고 기대하지만, 셸은 공백과 메타 문자를 기준으로 문자열을 다시 해석해요.
입력: 127.0.0.1; cat /etc/passwd
실행 문자열: ping -c 1 127.0.0.1; cat /etc/passwdtext; 앞의 ping이 끝난 뒤 cat /etc/passwd가 별도의 명령어로 실행돼요. 이처럼 사용자가 입력한 값이 데이터에서 명령어로 바뀌는 지점을 찾아야 해요.
Shell Metacharacter#
Shell Metacharacter는 셸이 명령어의 구조를 해석할 때 특별한 의미를 부여하는 문자예요. 명령어 삽입은 이 문자를 통해 원래 명령어의 인자 영역을 벗어나는 방식으로 발생하는 경우가 많아요.
| 표기 | 동작 |
|---|---|
`cmd` | 안쪽 명령어의 출력으로 치환해요 |
$(cmd) | 명령어를 실행하고 그 출력을 치환해요 |
&& | 앞 명령어가 성공했을 때 뒤 명령어를 실행해요 |
| ` | |
; | 앞 명령어의 성공 여부와 관계없이 다음 명령어를 실행해요 |
| ` | ` |
명령어 치환#
백틱과 $()는 괄호나 백틱 안의 명령어를 먼저 실행하고 그 결과를 바깥 명령어에 넣어요.
echo `echo mizuki`
echo $(echo mizuki)bash위 명령어의 출력은 다음과 같아요.
mizuki
mizukitext$()는 중첩해서 사용할 수 있고, 백틱보다 읽기 쉬워서 여러 단계의 명령어 치환에서 자주 사용해요.
명령어 연속 실행#
echo hello && echo mizuki
false || echo mizuki
echo hello; echo mizuki
echo 'echo PIPE_TEST' | /bin/shbash로컬 Linux 셸에서 확인할 수 있는 출력 예시는 다음과 같아요.
hello
mizuki
mizuki
hello
mizuki
PIPE_TESTtext첫 번째 줄은 echo hello가 성공했기 때문에 뒤의 echo mizuki도 실행된 결과예요. false는 실패하는 명령어라서 || 뒤의 echo mizuki가 실행돼요. 세미콜론은 앞 명령어의 성공 여부와 관계없이 다음 명령어를 실행하고, 파이프는 앞 명령어가 만든 문자열을 뒤의 /bin/sh가 명령어로 읽게 만들어요.
-----BEGIN SSH SIGNATURE-----
U1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg4c/dn4BitGH1/xNjKoKEp97I2b
eU57QXvkDBEdNNrEMAAAATYmxvZy5pbW55YS5uZy9wb3N0cwAAAAAAAAAGc2hhNTEyAAAA
UwAAAAtzc2gtZWQyNTUxOQAAAEACJEU6QvNSV8Pj5pbupJMd+XTSwrkzW7X41BrLbXSsoR
IHhhl+CBlPW1hbudENTm8mQYGLTM2T9cXV4v+2PskP
-----END SSH SIGNATURE-----