name: oldest_user_of_bartender, secret: {flag} 구조로 flag를 저장하고 있음
user가 url을 제공하면 서버 내부적으로 해당 url에 접근해 해당 웹페이지 스크린샷을 찍어서 pdf로 저장 및 제공.


(생성된 pdf)
url_check
curl을 이용해 웹 요청을 보낸 이후 200 HTTP 코드를 받아오는지 확인.
ip_in_range($ip, $range)
$ip가 $range 네트워크의 subnet에 포함되는지 확인.
no_way_trick_me($url)
host가 빈칸인지, 해당 url로 보낸 요청이 HTTP 코드 200을 리턴하는지(url_check), host가 IP 주소형식인지 확인, IP 주소형식이면 사설 IP인지, scheme가 http, https인지, {, } (중괄호)가 들어있는지, IP 주소가 $private_ranges의 네트워크에 속하는지(ip_in_range) 확인.
validate_url(url)
scheme가 http, https인지, url이 존재하는지 확인
is_request_safe(url, min_ttl=40)
DNS rebinding 공격을 방지하기 위해 ttl이 40초 미만일 경우 요청 수행 X
peek_website(url,timestamp)
is_request_safe 함수를 통해 url이 안전한지 확인.
check_equiv 함수를 통해 redirection이 이루어졌는지 확인.
안전하다고 판단될 시 pdf를 생성하여 로컬 드라이브의 service/pdfs/results-{timestamp}.pdf에 저장.
DB의 history table에 url과 현재 시간 저장.
이 application은 application 내부에 php로 이루어진 frontend와 flask로 이루어진 backend가 존재.
/bartender (GET) <- token_required
DB의 secret table에 존재하는 모든 name, secret 데이터를 제공.
/generate (GET)
validate_url(url) 함수를 통해 scheme 확인.
peek_website(url,timestamp) 함수를 통해 pdf 생성. 이후 생성에 성공할 경우 DB의 secrets table에 name, secret 저장.
/logs (GET)
DB의 history table로부터 데이터 조회.
DB의 secret table에 flag가 저장되어 있다는 점, 5000번 port flask의 /bartender endpoint에 접근하면 secret table을 조회할 수 있다는 점, 5000번 포트가 외부로 노출되어 있지 않다는 점, 이 application의 기능이 주어진 URL에 접근한 이후 스크린샷을 찍어서 pdf로 제공한다는 점을 미루어 보아 localhost URL 필터링을 우회하여 SSRF를 통해 application이 http://localhost:5000/bartender에 접근 및 스크린샷을 찍어서 secrets table에 저장된 flag값을 확인해야 한다.
url GET parameter의 host가 localhost인지 확인하는 로직은
1. backend1의 no_way_trick 함수의 url이 올바른 IP 주소형식일 경우 사설 IP인지 확인하는 로직
2. host가 $private_ranges의 네트워크에 속하는지 확인인지 로직
// bartender.php
$private_ranges = [
'127.0.0.0/8',
'10.0.0.0/8',
'172.17.0.0/12',
'192.168.0.0/16',
'0.0.0.0/8',
'169.254.0.0/16',
'::1/128',
'fe80::/10'
];
두 번째 필터링의 경우 언듯 보면 IPv6까지 포함하여 localhost를 나타낼 수 있는 모든 경우의 수에 대처한 것 같지만 IPv4-mapped IPv6에 대한 필터링은 이루어지지 않는다. IPv4-mapped IPv6는 IPv4를 IPv6로 나타내기 위한 IP 표기법이다.

또한, 첫 번째 필터링에서 올바른 IP 주소형식인지 체크하는데 사용하는
filter_var($host, FILTER_VALIDATE_IP)
함수는 대괄호가 들어간 IPv6에 대해 false를 리턴한다. 그래서 IP가 사실 IP인지 확인하는 로직이 동작하지 않으면서 자연스럽게 첫 번째 필터링도 우회할 수 있다.
http://[::ffff:7f00:1:5000]/logs로 테스트해본 결과 성공적으로 localhost 필터링을 우회한 모습이다.
/bartender endpoint에 접근하기 위해서는 token_required middleware를 우회하여야 한다. token_required를 통과하기 위해서는 유저가 제공한 JWT을 서버의 SECRET_KEY로 복호화했을 때 아래의 값이 들어있어야 한다. SECRET_KEY는 Flask app이 처음 서비스를 시작할 때 만들어지는 난수이다.
{
"is_admin":true,
"username":"admin"
}
# app.py
def logify(rec):
row_separator = '\n'
history = [f"ID: {row[0]} | URL: {row[1]} | Timestamp: {row[2]}" for row in rec]
history_1 = row_separator.join(history)
log = history_1.format(logify=logify)
return log
/logs endpoint에서 전체 log를 한 줄씩 정리하기 위해 사용하는 logify() 함수에서 format() method를 사용한다. format() method는 해당 string에서 {}으로 감싸여있는 변수를 파이썬의 객체로 치환해주는 method이다. logify()에서는 {logify}를 logify 함수 객체로 치환해준다.
파이썬의 모든 객체는 전역 객체를 담고 있는 __globals__ special variable을 가지고 있다. logify 함수 객체의 __globals__를 이용하면 SECRET_KEY 값에 접근할 수 있다. 이렇게 format() method를 이용해서 개발자가 의도하지 않은 파이썬 객체에 접근하는 방식을 PFSI(Python Format String Injection)이라고 부른다.
이제 SECRET_KEY 값에 접근하는 방법을 알았으니 SECRET_KEY를 노출시킬 방법을 찾으면 된다.
backend2의 /logs endpoint에서는 지금까지 유저가 제공한 URL을 저장하고 있는 history table에서 조회한 데이터를 PFSI가 동작하는 logify()를 거친 다음 보여준다. 따라서 먼저 app이 /logs endpoint에 대한 URL인데 PFSI를 통해 SECRET_KEY를 가지고 있는 URL에 접근하도록 해서 history table에 SECRET_KEY를 가지고 있는 URL을 저장한 다음, 다시 한번 /logs endpoint에 접근해서 history table에 저장된 SECRET_KEY 값을 확인하면 된다.
하지만 이 방식을 사용하면 기존의 localhost 필터링 우회가 불가능해진다. PFSI를 이용하기 위해서는 URL에 {}(중괄호) 문자를 사용해야 하는데 backend1의 no_way_trick_me() 함수에서 제공한 URL에 중괄호가 있는지 확인하는 로직이 존재하기 때문이다. 그래서 localhost 필터링을 우회하기 위한 다른 방법을 찾아야 한다.
이 app에서 GET query string으로 제공되는 url을 체크하는 로직은 backend1(php)에서, backend2(flask)에서 각각 진행된다. GET query string에 같은 이름의 key가 두 개 이상 존재하면 php와 flask에서 해당 이름의 key의 GET query string을 불러올 때 서로 다른 query string을 가져온다는 것을 이용하여 url 체크 로직을 우회할 수 있다.
같은 key를 가진 query string이 두 개 이상 존재할 때 Flask에서는 첫 번째 GET query string을 가져오고 php에서는 마지막 GET query string을 가져온다. 이렇게 GET query string을 해석하는 방식의 차이를 이용하는 기법을 HPP(HTTP Parameter Pollution)라고 부른다.
// http://127.0.0.1?url=first&url=second 제공
// bartender.php
if ($_SERVER["REQUEST_METHOD"] == "GET") {
$urlunsanitized = $_GET['url'];
// $urlunsanitized == first
#http://127.0.0.1?url=first&url=second 제공
# app.py
@app.route('/generate', methods=['GET'])
def scrape():
name = escape(request.args.get('name'))
timestamp=request.args.get('time')
url = request.args.get('url')
# url == second
user가 제공한 URL이 사설 네트워크인지, 중괄호가 들어가는지 등을 확인하는 로직은 php에 존재하고 실제로 user가 제공한 URL에 접속하여 스크린샷을 찍고 pdf로 제공하는 로직은 Flask에 존재하므로
위의 URL을 제공하면 php는 https://example.com을 확인할 것이고 Flask는 http://127.0.0.1:5000/logs?secret={logify.__globals__[app].config[SECRET_KEY]}에 접속하여서 attacker의 의도대로 SECRET_KEY 값을 가지고 있는 URL을 history table에 저장할 것이다.
이 기법을 사용하여 SECRET_KEY를 확인하자.

/bartender endpoint에 접근하기 위해 필요한 JWT을 생성하는 SECRET_KEY 값을 얻었다. /bartender endpoint에 접근하기 위해서는
{
"is_admin":true,
"username":"admin"
}
위의 데이터를 SECRET_KEY로 암호화한 JWT이 필요하므로 SECRET_KEY를 이용해서 JWT을 생성하자.

생성된 JWT를 GET query string값으로 이용해서 이 app을 이용하면

flag를 취득할 수 있다.