[보안/write-up] Offlinea

Shadis·2026년 3월 29일

보안/write-up

목록 보기
3/4

About This Wargame

문제 분석

  • 웹 서버: Werkzeug 내장 서버
  • WAS: Werkzeug 내장 서버
  • Framework: Flask(python) + PHP
  • DB: sqlite3

DB 구조

  • secrets
    • id (PK)
    • name
    • secret

name: oldest_user_of_bartender, secret: {flag} 구조로 flag를 저장하고 있음

  • history
    • id (PK)
    • url
    • timestamp

Application 기능

user가 url을 제공하면 서버 내부적으로 해당 url에 접근해 해당 웹페이지 스크린샷을 찍어서 pdf로 저장 및 제공.


(생성된 pdf)

Backend1 주요 함수

  • 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) 확인.

Backend2 주요 함수

  • 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과 현재 시간 저장.

Endpoint

이 application은 application 내부에 php로 이루어진 frontend와 flask로 이루어진 backend가 존재.

Backend1 (port 8000)

  • 모든 GET 요청
    no_way_trick_me($url) 함수를 통해 접속 url을 저장하고 있는 변수 $url이 올바른 url인지 확인.
    backend2(Flask)의 /generate endpoint로 frontend에 GET 요청을 보낼 때 사용한 GET parameter와 함께 GET 요청.
    client에게 backend(Flask)가 생성한 pdf로 redirect 시키는 HTTP 응답 제공.

Backend2 (port 5000)

  • /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로부터 데이터 조회.

Backend2 middleware

  • token_required
    user가 GET parameter로 제공하는 token을 서버가 가지고 있는 SECRET_KEY로 복호화해서 is_admin이 있는지, username == admin인지 검사.

flag 분석

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값을 확인해야 한다.

localhost 필터링 우회

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 필터링을 우회한 모습이다.

SECRET_KEY 획득

/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 필터링 우회 2

하지만 이 방식을 사용하면 기존의 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에 존재하므로

http://(host):8000/bartender.php?url=http://127.0.0.1:5000/logs?secret={logify.__globals__[app].config[SECRET_KEY]}&url=https%3A%2F%2Fexample.com&secret=test&name=test

위의 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를 확인하자.

token_required 우회

/bartender endpoint에 접근하기 위해 필요한 JWT을 생성하는 SECRET_KEY 값을 얻었다. /bartender endpoint에 접근하기 위해서는

{
  "is_admin":true,
  "username":"admin"
}

위의 데이터를 SECRET_KEY로 암호화한 JWT이 필요하므로 SECRET_KEY를 이용해서 JWT을 생성하자.

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

flag를 취득할 수 있다.

profile
HGU 20 김민석

0개의 댓글