Link Search Menu Expand Document

Microservice as a Service 2

Part 2: Notes

https://maas.rars.win/

Challenge

TL;DR: Solved by unintended solution due to improper input sanitization.

Once again, we begin by analyzing the docker network layout. We will be able to access notes from the main application. We also see that there are 2 additional hosts, redis_users and redis_userdata, which we cannot reach from the main application directly as they reside in notes-net:

  notes:
    build: notes
    depends_on: ["redis_users", "redis_userdata"]
    networks:
      - level-1
      - notes-net
  redis_users:
    image: library/redis:latest
    networks:
      - notes-net
  redis_userdata:
    build: notes/redis_userdata
    networks:
      - notes-net

Looking at how the main application calls the notes network, we see that we first have to register with a username and afterwards we will get a uid assigned to our session.

Source: app/app.py

@app.route('/notes/profile', methods=["POST", "GET"])
def profile():
    username = session.get('notes-username')
    if not username:
        return redirect('/notes/register')
    uid = requests.get(f"http://notes:5000/getid/{username}").text
    // ...

@app.route('/notes/register', methods=["GET", "POST"])
def register():
    if request.method == "GET":
        return render_template('register.html')
    data = {"mode": "register", "username": request.form.get("username")}
    requests.post("http://notes:5000/useraction", data=data)
    session["notes-username"] = request.form.get("username")
    return redirect("/notes/profile")

Source: app/notes/app.py

@app.route('/useraction', methods=["POST"])
def useraction():
    mode = request.form.get("mode")
    username = request.form.get("username")
    if mode == "register":
        r = requests.get('http://redis_userdata:5000/adduser')
        port = int(r.text)
        red = redis.Redis(host="redis_users")
        red.set(username, port)
        return ""

@app.route('/getid/<username>')
def getid(username):
    red = redis.Redis(host="redis_users")
    return red.get(username).decode()

At this point, we observe that a Redis database is being deployed on the redis_users host.

Source: app/notes/redis_userdata/app.py

@app.route('/adduser')
def adduser():
    port = random.randint(50000, 60000)
    if os.system(f"redis-server --port {port} --daemonize yes --protected-mode no") == 0:
        return str(port), 200
    else:
        return "0", 500

To summarize, each time a username is registered, a random port number from 50000 to 60000 is generated and opened on the redis_userdata host. This port number is made known to notes host, which stores it to a Redis instance running on redis_users host in the format {username:port}.

Now that the application is clear to us, it’s time to find the path forward. It appears that each user can save a bio to their own profile. This bio is treated as a template and rendered when a GET request is sent to the /notes/profile route on the main application server. Perhaps we can perform SSTI?

Source: app/app.py

def render_bio(username, userdata):
    data = {"username": username,
            "mode": "bioget"}
    r = requests.post("http://notes:5000/useraction", data=data)
    try:
        r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata})
        return r.text
    except:
        return "Error in bio"

@app.route('/notes/profile', methods=["POST", "GET"])
def profile():
    // ...
    if request.method == "GET":
        return render_template("profile.html",
                               bio=render_bio(username, userdata(username)),
                               userid=uid
                               )
    // ...

It appears that whatever a user loads their own profile, the main application makes a request to http://notes/useraction with the mode set to bioget. This results in the following code to execute on notes server:

Source: app/notes/app.py

elif mode == "bioget":
    red = redis.Redis(host="redis_users")
    port = red.get(username).decode()
    r = requests.get(f"http://redis_userdata:5000/bio/{port}")
    return r.text

Which in turns retrieves bio from the redis_userdata host:

Source: app/notes/redis_userdata/app.py

@app.route("/bio/<port>", methods=["POST", "GET"])
def bio(port):
    if request.method == "GET":
        if os.path.exists(f"/tmp/{port}.txt"):
            with open(f"/tmp/{port}.txt") as f:
                return f.read()
        else:
            return ""
    elif request.method == "POST":
        with open(f"/tmp/{port}.txt", 'w') as f:
            f.write(request.json.get("bio"))
        return ""

After retrieving a bio, the main application will make a request to the notes host, which will in turn render the data sent as a template.

Source: app/app.py:render_bio()

r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata})
        return r.text

Source: app/notes/app.py

@app.route("/render", methods=["POST"])
def render_bio():
    data = request.json.get('data')
    if data is None:
        data = {}
    return render_template_string(request.json.get('bio'), data=data)

So, we just have to save SSTI payloads in our bio, right? But, remember that the bioadd code sanitizes our input?

Source: app/notes/app.py

elif mode == "bioadd":
        bio = request.form.get("bio")
        bio.replace(".", "").replace("_", "").\
            replace("{", "").replace("}", "").\
            replace("(", "").replace(")", "").\
            replace("|", "")

        bio = re.sub(r'\[\[([^\[\]]+)\]\]', r'', bio)
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        r = requests.post(f"http://redis_userdata:5000/bio/{port}", json={
            "bio": bio
        })
        return r.text

Notice anything off? The return value of the .replace() chain was never re-assigned into the bio variable. This breaks the sanitization process and we are able to directly inject SSTI payloads!

Performing the typical payload testing to leak the subprocess.Popen class, we found the offset to be at 278:


    -> (<class 'str'>, <class 'object'>)


    -> [<class 'type'>, <class 'weakref'>, ... ]
    -> Index of <class 'subprocess.Popen'> is [278]


Save the final payload as a bio and upon the page reload, we will see the flag displayed to us.

Flag: rarctf{wh4t_w4s_1_th1nk1ng..._60a4ee96}