Hello Navi

Tech, Security & Personal Notes

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。)

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=

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 能同时给它们赋值,直接让二者取同一个值就恒为真

Challenge

关卡给出一段 Windows 批处理(batch)写的认证脚本,要求输入一个能让脚本认证通过的密码。脚本用 SET /P 读取输入,然后逐字符与字母表比对,命中时把一个累乘变量乘以对应的素数:

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
@ECHO OFF
SETLOCAL ENABLEDELAYEDEXPANSION
SET PRIME=2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101
SET CHARS=a b c d e f g h i j k l m n o p q r s t u v w x y z
SET PASSWORDVALUE=1
SET INPUT=
SET /P INPUT=Insert password:
IF "%INPUT%"=="" "%~0"
ECHO Authenticating...
:OVERLOOP
SET CURRENTPOSITION=0
:SUBLOOP
IF /I "!INPUT:~%CHARACTERPOSITION%,1!"=="!CHARS:~%CURRENTPOSITION%,1!" SET /A PASSWORDVALUE*=!PRIME:~%CURRENTPOSITION%,3!
SET /A CURRENTPOSITION+=3
IF NOT %CURRENTPOSITION%==78 GOTO :SUBLOOP
SET /A CHARACTERPOSITION+=1
IF NOT "!INPUT:~%CHARACTERPOSITION%,1!"=="" GOTO :OVERLOOP
:END
ENDLOCAL&IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED
ECHO You have been authenticated. Welcome aboard!
GOTO :SILENTPAUSE
:ACCESSDENIED
ECHO Access denied!
:SILENTPAUSE
PAUSE > NUL

Solution

Step 1: 脚本语义

PRIMECHARS 都是等宽分隔的字符串:PRIME 里每个素数被右填充到 3 个字符宽,CHARS 里每个字母后面也有两个空格。内层循环的 CURRENTPOSITION0,3,6,…,75 共 26 个位置,!PRIME:~%CURRENTPOSITION%,3! 正好截出对应的一个素数,!CHARS:~%CURRENTPOSITION%,1! 截出 az

外层对输入里每个字符位置 CHARACTERPOSITION 走一遍这 26 个槽位。IF /I 是大小写不敏感的,所以一个大写字母也会命中。命中的后果只有一个:SET /A PASSWORDVALUE*=!PRIME:~…,3!,也就是把累乘值乘上该字母对应的那个素数。因此

  • 输入里每个字符最多贡献一次乘法,乘的是 {2,3,5,…,101} 中的某一个素数;
  • 非字母字符不贡献任何乘法;
  • 重复同一个字母就是重复乘同一个素数。

关卡的门槛是

1
IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED

把目标值分解:

1
1065435274 = 2 × 6827 × 78031

682778031 都是素数,且都大于 101,不在脚本能乘到的素数表里。换句话说,任何由列表内素数组成的乘积都不可能精确等于 1065435274。如果只盯着凑乘积这一个方向,该关无法通过。

Step 3: 32 位溢出

SET /A 的整数运算是 32 位有符号 的,累乘过程中超过 2**31 会回环绕。于是真正需要满足的条件是同余:

1
∏(选中的素数) ≡ 1065435274 (mod 2**32)

因为乘法在模 2**32 下可结合,最终值只取决于所选素数构成的多重集合(与输入顺序无关)。

再看目标值的 2-adic 赋值:1065435274 = 2 × 532717637532717637 是奇数。2**32 以内的偶数按 2 的幂次分层,目标只含一个因子 2,所以素数 2(字母 a)必须恰好出现一次,其余部分是一个奇数乘积,需要满足

1
X ≡ 532717637 (mod 2**32)

只剩 25 个奇素数(3101)可选,每个的多重度未知。用中间相遇(meet-in-the-middle):把奇素数分成两半分别枚举各自的多重集合乘积,在一半的哈希表里查另一半需要的补数(对奇数取模逆),取总乘法次数最少的组合。每个素数的重数上限取 2 就足够:25 个奇素数、每个重数取 0..2,组合数在 3**25 量级,远多于模数 2**32,解很多而长度很小。

完整求解器:

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
from itertools import product

MOD = 1 << 32
TARGET = 1065435274
PRIMES = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37,
41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101]
LETTERS = "abcdefghijklmnopqrstuvwxyz"

def search(limit=2):
"""Find prime multiplicities (0..limit per odd prime) with product == TARGET mod 2**32."""
odd = PRIMES[1:]
left, right = odd[:12], odd[12:]
table = {}
ranges = [range(limit + 1)] * len(right)
for counts in product(*ranges):
r = 1
for q, k in zip(right, counts):
r = r * pow(q, k, MOD) % MOD
table.setdefault(r, counts)
best = None
for counts in product(*([range(limit + 1)] * len(left))):
l = 1
for q, k in zip(left, counts):
l = l * pow(q, k, MOD) % MOD
need = (TARGET // 2) * pow(l, -1, MOD) % MOD
rc = table.get(need)
if rc is not None and (best is None or sum(counts) + sum(rc) < best[0]):
best = (sum(counts) + sum(rc), counts, rc)
return best

def build(best):
_, lc, rc = best
counts = {2: 1}
for q, k in dict(zip(PRIMES[1:13], lc)).items():
counts[q] = counts.get(q, 0) + k
for q, k in dict(zip(PRIMES[13:], rc)).items():
counts[q] = counts.get(q, 0) + k
pw = "".join(LETTERS[q_index] * k
for q_index, k in sorted((PRIMES.index(q), k) for q, k in counts.items()))
return counts, pw

def replay(pw):
"""Model the batch loop: each letter multiplies its prime; result is signed 32-bit."""
value = 1
for ch in pw.lower():
idx = LETTERS.find(ch)
if idx >= 0:
value = value * PRIMES[idx] % MOD
return value - MOD if value >= (1 << 31) else value

if __name__ == "__main__":
counts, pw = build(search())
print("counts:", counts)
print("password:", pw)
assert replay(pw) == TARGET, "replay mismatch"
assert pw == "aghilmort"

Step 5: Result

搜索得到 9 个字符的最短解:

1
2
密码:aghilmort
对应素数:a=2, g=17, h=19, i=23, l=37, m=41, o=47, p=61, t=71

乘积在 32 位下回绕:

1
2
2×17×19×23×37×41×47×61×71 = 4588090507402
4588090507402 mod 2**32 = 1065435274

Challenge

The following is a batch script authentication system. Your goal here is to get the batch script to authenticate you by inputting a password into the field. For this extbasic, your goal is to circumvent authentication altogether. Decrypting the password is for extbasic11.

关卡给出一个 Windows batch 认证脚本,要求输入内容使脚本认证通过。本关的目标是绕过认证。

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

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
@ECHO OFF
SETLOCAL ENABLEDELAYEDEXPANSION
SET PRIME=2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 101
SET CHARS=a b c d e f g h i j k l m n o p q r s t u v w x y z
SET PASSWORDVALUE=1
SET INPUT=
SET /P INPUT=Insert password:
IF "%INPUT%"=="" "%~0"
ECHO Authenticating...
:OVERLOOP
SET CURRENTPOSITION=0
:SUBLOOP
IF /I "!INPUT:~%CHARACTERPOSITION%,1!"=="!CHARS:~%CURRENTPOSITION%,1!" SET /A PASSWORDVALUE*=!PRIME:~%CURRENTPOSITION%,3!
SET /A CURRENTPOSITION+=3
IF NOT %CURRENTPOSITION%==78 GOTO :SUBLOOP
SET /A CHARACTERPOSITION+=1
IF NOT "!INPUT:~%CHARACTERPOSITION%,1!"=="" GOTO :OVERLOOP
:END
ENDLOCAL&IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED
ECHO You have been authenticated. Welcome aboard!
GOTO :SILENTPAUSE
:ACCESSDENIED
ECHO Access denied!
:SILENTPAUSE
PAUSE > NUL

Solution

PRIMECHARS 都按 3 个字符一项排列。内层循环依次取 036 直到 75,将输入字符映射到 2101 的质数;命中后执行:

1
SET /A PASSWORDVALUE*=对应质数

最终判定要求 PASSWORDVALUE 等于 1065435274。该值的分解为:

1
1065435274 = 2 × 6827 × 78031

682778031 不在脚本的质数表中,因此按原有乘法路径无法得到目标值。可利用点在输入处理,而非乘积本身。

认证前的判断直接把原始输入拼进双引号:

1
IF "%INPUT%"=="" "%~0"

输入未经过转义,可以闭合字符串并追加命令。使用以下 payload:

1
"=="" set passwordvalue=1065435274 && goto :end abc

代入后得到:

1
IF ""=="" set passwordvalue=1065435274 && goto :end abc"=="" "%~0"

""=="" 成立,set passwordvalue=1065435274 将累积值直接设为目标值,goto :end 跳过整个乘法循环。set 的变量名不区分大小写,所以这里的 passwordvalue 与脚本中的 PASSWORDVALUE 相同。

到达 :END 后,判定行中的 %PASSWORDVALUE% 会在整行执行前展开,早于 ENDLOCAL

1
ENDLOCAL&IF NOT %PASSWORDVALUE%==1065435274 GOTO :ACCESSDENIED

此时展开结果为 1065435274==1065435274IF NOT 条件为假,执行流进入认证成功分支。

Challenge

Captain Kirk has coded this Perl script for all his fellow-captains to automate their logging. This way they don't have to record their logs on tape, but they can type them in and archive them. But this log only seems to log one log?! It automatically deletes all previous logs! Fix the script for him, so they can keep their logs again! Captain Kirk 给同僚写了一个自动记日志的 Perl 脚本,但每次只留下一条日志, 之前的全被删掉;把它修好,让日志能留存下来。

关卡页给出完整脚本:

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
#!/usr/bin/perl
# Captain Kirk has coded this Perl script for all his fellow-captains
# to automate their logging.
# This way they don't have to record their logs on tape, but they can type them in
# and archive them. But this log only seems to log one log?!
# It automatically deletes all previous logs! Fix the script for him,
# so they can keep their logs again!
print '> Hello Captain ' . $ENV{'USER'} . '.' . "\n";
open(STARTREKLOG, '>/var/log/startrek');
print '> Please enter your log data here, end with a "." on a single line.' . "\n";
my $LogText;
print '> ';
while (<STDIN>) {
unless ($_ ne '.' . "\n") {
last;
}
$LogText .= $_;
print '> ';
}
print '> Log is being saved to /var/log/startrek' . "\n";
$DateTime = localtime();
print STARTREKLOG ' -- START OF LOG -- ' . "\n";
print STARTREKLOG 'Date/Time: ' . $DateTime . "\n";
print STARTREKLOG 'Log : ' . $LogText;
print STARTREKLOG ' -- END OF LOG -- ' . "\n";
die('> Log saved! Now exiting.' . "\n");

Solution

脚本把交互内容累加进 $LogText,最后用四条 print STARTREKLOG 落盘:

1
2
3
4
print STARTREKLOG ' -- START OF LOG -- ' . "\n";
print STARTREKLOG 'Date/Time: ' . $DateTime . "\n";
print STARTREKLOG 'Log : ' . $LogText;
print STARTREKLOG ' -- END OF LOG -- ' . "\n";

写入语句本身没有问题,本次要记的内容也完整。决定上一次的日志还在不在的是更早的那一行:

1
open(STARTREKLOG, '>/var/log/startrek');

Perl 的两参数 open 把模式写在文件名前面:< 读、> 写、>> 追加。>截断写:打开时先把文件长度清零,文件指针回到开头,于是每次运行都从空文件开始,上一次的内容在这次运行的第一条 print 之前就没了。题面说的 It automatically deletes all previous logs 正是这个行为。

open(STARTREKLOG, >>/var/log/startrek);

Challenge

Bill Gates wrote a Perl script that grants access to the company records; it has a security flaw that lets everyone in. Fix the flaw.

Bill Gates 写了个 Perl 脚本,用来校验访问者有没有权限读取公司记录;这段脚本存在安全缺陷,任何人都能拿到记录。要求把缺陷修掉。

1
2
3
4
5
6
7
8
#!/usr/bin/perl
chomp ( my $User = `/usr/bin/whoami` ) ;
print "Checking your access level...\n" ;
if ( $User == 'BillGates' ) {
print "Authorized! Here are the company records:\n" . `cat /home/BillGates/CompanyRecords.db` ;
die ( "Closing...\n" ) ;
}
die ( "You're not authorized!\n" ) ;

Solution

脚本的逻辑很短:用反引号执行 /usr/bin/whoami 拿到当前用户名存进 $User,然后只有一个 if 决定是否读取 /home/BillGates/CompanyRecords.db。要修好缺陷,改动点必然落在这个比较上;其它行(chompprintdie)都只是输出,不参与授权判断。

Perl 有两套比较运算符,不能混用:

  • ==!=<>数值比较,会先把两侧操作数转成数字;
  • eqneltgt字符串比较,逐字符对比。

脚本用的是 $User == 'BillGates'。两侧都是字符串,却在走数值比较。用 Perl 直接复现这个语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/perl
# Local reproduction of the ExtBasic 8 flaw (run against our own Perl, no
# network). A non-privileged user name stands in for the whoami output.
use strict;
use warnings;

my $User = "some_user"; # what `/usr/bin/whoami` would return

my $target = "BillGates";

print "user : $User\n";
print "target : $target\n";
print "num(user) : ", $User + 0, "\n";
print "num(target): ", $target + 0, "\n";
printf "flawed \$User == \$target -> %s\n", ($User == $target) ? "TRUE" : "FALSE";
printf "fixed \$User eq \$target -> %s\n", ($User eq $target) ? "TRUE" : "FALSE";
1
2
3
4
5
6
7
8
9
$ perl -w perl_demo.pl
Argument "some_user" isn't numeric in addition (+) at perl_demo.pl line 13.
Argument "BillGates" isn't numeric in addition (+) at perl_demo.pl line 14.
user : some_user
target : BillGates
num(user) : 0
num(target): 0
flawed $User == $target -> TRUE
fixed $User eq $target -> FALSE

Perl 把非数字开头的字符串转成数字时取前导数字部分,没有前导数字就是 0(同时抛 isn't numeric 警告)。whoami 返回的是用户名,BillGates 也是裸字符串,两者的数值转换结果都是 0

授权条件退化成 0 == 0,恒为真。任何用户名(rootnobody、普通用户都一样)都会走进 if 分支,打印记录再 die("Closing...")。真正的字符串判定应该用 eq'some_user' eq 'BillGates' 为假,只有用户名恰好是 BillGates 时才通过。

if ($User eq BillGates)

Challenge

修正一个 PHP 页面里同时带有 bug 和漏洞的那一行,提交修正后的整行。

关卡页给出的是一段数据录入代码,要求:There is only one line that has a vuln, correct it. The output does not have to be valid XHTML and assume that a mysql connection has been made already. There is a bug as well as a vuln. You MUST fix both.

1
2
3
4
5
6
7
8
9
10
11
<?php
if(!empty($_POST['data']))
{
$data = mysql_real_escape_string($_POST['data']);
mysql_query("INSERT INTO tbl_data (data) VALUES ('$data')");
}
?>
<form name="grezvahfvfnjuvavatovgpu" action="<?=$_SERVER['PHP_SELF']?>" method="get">
<input type="text" name="data" />
<input type="submit" />
</form>

Solution

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

The level prints a tiny PHP page and asks for the one line that carries both a
bug and a vulnerability:

<form name="grezvahfvfnjuvavatovgpu" action="<?=$_SERVER['PHP_SELF']?>" method="get">

* vuln: ``$_SERVER['PHP_SELF']`` is echoed raw into the ``action`` attribute, so
a path like ``/x.php/"><script>alert(1)</script>`` is reflected as markup and
becomes XSS. ``htmlspecialchars()`` fixes it.
* bug: the form submits with ``method="get"`` while the handler only reads
``$_POST['data']``, so the INSERT never runs. ``method="post"`` fixes it.

The corrected line is posted to ``/missions/extbasic/template.php`` together with
the per-load ``formkey`` and ``lvl``. A ``Referer`` pointing at the level page is
mandatory, otherwise the endpoint answers ``Invalid Referer`` and the attempt does
not count. The session cookie comes from ``HTS_COOKIE`` and is never persisted.
"""
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")
BASE = "https://www.hackthissite.org"
LEVEL = BASE + "/missions/playit/extbasic/7/"
TEMPLATE = BASE + "/missions/extbasic/template.php"
CK = os.environ["HTS_COOKIE"]

ANSWER = (
'<form name="grezvahfvfnjuvavatovgpu" '
'action="<?=htmlspecialchars($_SERVER[\'PHP_SELF\'])?>" method="post">'
)

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

def main():
page = get(LEVEL)
formkey = re.search(r'name="formkey" value="([^"]+)"', page).group(1)
data = urllib.parse.urlencode(
{"formkey": formkey, "lvl": "7", "pass": ANSWER}).encode()
req = urllib.request.Request(
TEMPLATE, data=data,
headers={"Cookie": CK, "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("go-on link to level 8:", "/missions/playit/extbasic/8" in resp)

if __name__ == "__main__":
main()

Challenge

This site is run by a new sysadmin who does not know much about web configuration. The script is located at http://moo.com/moo.php Attempt to make the script think you are authed by entering the correct URI. 进入正确的 URI,让脚本以为你已经通过认证。

关卡页给出脚本 me.php 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<?php
$user = $_GET['user'];
$pass = $_GET['pass'];
if (isAuthed($user,$pass))
{
$passed = TRUE;
}
if ($passed == TRUE)
{
echo 'you win';
}
?>
<form action="me.php" method="get">
<input type="text" name="user" />
<input type="password" name="pass" />
</form>
<?php
function isAuthed($a,$b)
{
return FALSE;
}
?>

页面底部还有一行全局提醒:All missions are case sensitive. I tried to keep them lowercase however.。答案大小写敏感。

Solution

isAuthed($a,$b) 的返回值写死为 FALSE,所以走正常表单提交 user/passif (isAuthed($user,$pass)) 永远不成立,$passed = TRUE 这一行根本不会执行。想从凭据这条路进去是没有出口的。

脚本只从 $_GET 里取了 userpass$passed 在整个文件里没有任何赋值默认值的语句:

1
2
3
4
$user = $_GET['user'];
$pass = $_GET['pass'];
if (isAuthed($user,$pass)) { $passed = TRUE; }
if ($passed == TRUE) { echo 'you win'; }

如果 $passed 只是一个普通的未定义局部变量,第二个 if 恒为假,这题无从下手。题面第一句正是钥匙:new sysadmin who does not know much about web configuration,一个不懂 Web 配置的管理员,对应的就是最典型的一项 PHP 配置错误:register_globals = On

register_globals 打开时,PHP 会在脚本运行前把查询字符串里的每个键自动导入成同名全局变量。于是 URL 里的 passed 会直接落到脚本的 $passed 上,完全绕过 isAuthed() 这条分支。

要让 $passed == TRUE 成立,只需在 URL 上挂一个 passed 参数:

1
http://moo.com/moo.php?passed=TRUE

PHP 的松散比较 == 在两侧类型不同时会做类型转换:字符串和布尔值比较时,字符串被转成布尔值,任何非空且非 "0" 的字符串都为真。因此 "TRUE" == TRUE 求值为 true,脚本进入 if 分支并输出 you win

值按源码里惯用的写法给 TRUE

formkey 每次加载关卡页都会变,取页面和提交必须在同一次运行里完成。

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
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")
BASE = "https://www.hackthissite.org"
LEVEL = BASE + "/missions/playit/extbasic/6/"
TEMPLATE = BASE + "/missions/extbasic/template.php"
COOKIE = os.environ["HTS_COOKIE"]
ANSWER = "http://moo.com/moo.php?passed=TRUE"

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

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

if __name__ == "__main__":
main()

Key points

  • register_globals 会把查询字符串的键自动提升为全局变量,攻击者可以覆盖脚本本应自己掌控的标志位(如 $passed
  • 判断是否登录/是否通过的标志必须由服务端在可信逻辑里赋值,不能依赖任何用户可控的名字
  • 认证分支不可达时,漏洞往往在缺失的初始化宽松比较上,而不是在比较内容上
  • == 的类型转换让 "TRUE" == TRUE 为真;权限判断应使用 === 并显式校验类型
  • register_globals 早已在 PHP 5.4 中被移除,现代 PHP 不再存在这条路径

Challenge

ExtBasic Mission 05 — fix the broken line in a shell script that is supposed to patch a PHP page. 修正 Sam 用来修补 PHP 页面的 shell 脚本里被写错的那一行。

关卡给出一个 PHP 页面:本该调用 safeeval() 包装函数的地方,写成了裸的 eval()

1
2
3
4
5
6
7
8
9
<?php
include ('safe.inc.php');
if ($access=="allowed") {
eval($_GET['cmd']);
if (!empty($_GET['cmd2'])) {
eval($_GET['cmd2']);
}
}
?>

Sam 写了个 shell 脚本,用 sedeval 批量改写成 safeeval

1
2
3
4
5
6
#!/bin/sh
rm OK
sed -E "s/eval/safeeval/" <exec.php >tmp && touch OK
if [ -f OK ]; then
rm exec.php && mv tmp exec.php
fi

页面说明 Sam 的系统是 freeBSD 6.9,顶部横幅另外提醒:不要写 sed -r,BSD 上要用 sed -E

任务是修正 shell 脚本里写错的那一行。

Solution

横幅专门提醒 -r-E 的区别,说明出题人预期玩家会先怀疑 sed 标志。脚本里已经是 sed -E,在 FreeBSD 上语法合法。这一行的问题不在方言,而在替换命令本身。

s/eval/safeeval/ 没有写全局标志 g

  • 不带 g:sed 对每一行只替换第一个匹配;
  • g:替换行内所有匹配。
sed -E s/eval/safeeval/g <exec.php >tmp && touch OK

Challenge

A program written in a made-up language called F.ake; work out its output when the user types 6,7.

第四关给出一段用自造语言 F.ake 写的程序,要求算出用户输入 6,7 时它的输出。

1
2
3
4
5
{user types 6,7}
BEGIN F.ake
var int as in
int var as in
out var int
67