avatar

目录
浅析PHP混淆加密

通过一道题,来看一下php 混淆加密解密原理和分析
题目来自 [PWNHUB 公开赛 2018]傻 fufu 的工作日
这几天天天盯着这些鬼,眼睛都看瞎了
题目是个好题目,我看了Virink师傅和腹黑师傅的题解仍然发现自己很多不懂。还是太菜了
好好学习,好好学习


[PWNHUB 公开赛 2018]傻 fufu 的工作日

点开题目,是个上传,通过扫描后台,发现存在备份文件泄露,得到了 index.php.bak
文件打开,发现全是乱码,但是注意到了一些细节因此我们来对源码进行一下分析

decode1

首先是开头的加密源网站

php
1
<?php /* PHP Encode by  http://Www.PHPJiaMi.Com/ */

然后可以看到有颜色的代码最末尾有 return *** ?> 来结束脚本运行,这说明结束标签后面的数据都不会被正常输出,后面极可能是index.php 源码,而前面的 php 代码只是用来加密的。


解码准备

因为我的 IDEVSCode,所以安装了 PHP Debug 插件
以及需要进行代码缩进格式化
然后找到了这个工具PHP-Parser

使用工具前要先安装 composer
composer下载地址
阿里云镜像

安装好 composer 后,进入下好的 Parser 文件夹,用命令输入

bash
1
composer update

让其生成所需要的库,然后将下面的代码保存成一个新的文件(例如 format.php)

php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<?php
use PhpParser\Error;
use PhpParser\ParserFactory;
use PhpParser\PrettyPrinter;
require 'vendor/autoload.php';
$code = file_get_contents('index.php');
$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
try {
$ast = $parser->parse($code);
} catch (Error $error) {
echo "Parse error: {$error->getMessage()}\n";
return;
}
$prettyPrinter = new PrettyPrinter\Standard;
$prettyCode = $prettyPrinter->prettyPrintFile($ast);
file_put_contents('index2.php', $prettyCode);

然后执行命令 php format.php
然后将新生成的文件,以 Western (ISO 8859-1) 编码形式打开,这样可以显示更多非可显示字符而不是多字节的字符集

decode2


调试

开头两行需要先注释掉,防止调试过程出现一些问题

php
1
2
error_reporting(0);
ini_set("display_errors", 0);

使用 VSCode 的调试功能,我们可以方便的查看变量的具体内容。

decode3


33行

直到调试到这一行,发现直接跳出了。

decode4

php
1
php_sapi_name() == 'cli' ? die() : '';

因为是用命令行运行调试,所以执行完这一句,程序就结束了。
然后将这一行注释掉,在他下面下断点。重新运行程序。


34行是就是读取当前文件,这句话没有什么问题。

php
1
$f = file_get_contents(constant('jnggfmpt'));

然后就又是验证运行环境。

php
1
2
3
if (!isset($_SERVER['HTTP_HOST']) && !isset($_SERVER['SERVER_ADDR']) && !isset($_SERVER['REMOTE_ADDR'])) {
die();
}

decode5

然后看后面的 38-42行代码

php
1
2
3
4
5
argc = microtime(true) * 1000;
eval("");
if (microtime(true) * 1000 - argc > 100) {
die();
}

查了下,是用来防止下断点调试的,如果下断点调试,这里就超过 100 毫秒,然后就退出了。
因此决定直接在这条语句之后下断点,让代码一连串执行完,这样就不会超过 100 毫秒了,直接注释掉也行
然后再来看一下后面43行这个 eval
回显结果跑到了71行这里

decode6

看起来好像没什么帮助,注释掉之后

看44行

php
1
!strpos(decode_func(substr($f, -45, -1)), md5(substr($f, 0, -46))) ? $undefined1() : $undefined2;

用来校验数据完整性的代码,这里的 $undefined1$undefined2 都没有定义。如果验证失败,就会调用 $undefined1 会直接 Error 退出程序。而如果验证成功,虽然 $undefined2 变量不存在,但是只是一个 Warning,没有太大问题。

其中的 decode_func 就是加密代码中最后一个函数,专门负责字符串解码的。
验证方法就是把文件尾部分解密和前面的文件主体部分的 md5 对比,这次执行肯定又不能通过。
退出程序,注释掉,再重新运行。


47行找到关键性代码

php
1
$decrypted = str_rot13(@gzuncompress(decode_func(substr($f, -1522, -46))));

同时可以看到已经 return 加密前的代码了,上述的 -1522就是最先提到的加密代码末尾 ?> 后的第一个字符

decode7

然后跳到最后

php
1
2
3
4
5
6
7
8
$f_varname = '_f_';
$decrypted = check_and_decrypt(${$f_varname});
set_include_path(dirname(${$f_varname}));
$base64_encoded_decrypted = base64_encode($decrypted);
$eval_string = 'eval(base64_decode($base64_encoded_decrypted));';
$result = eval($eval_string);
set_include_path(dirname(${$f_varname}));
return $result;

怎么打印出来呢
直接在 $decrypted 后面加上一行 file_put_contents 就可以了。

decode7_2

然后便可以看到解密后的源码

decode8

如果将上述的 35行 server_host 改成 '127.0.0.1' 还可以看到服务器返回的请求验证数据代码

50行 decode_func

decode9

后话

感谢 Ganlv 师傅 的一路指导

大致的加密源码

php
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
<?php

// 先把这两行去掉,防止出现什么问题,我们还什么都不知道。
// error_reporting(0);
// ini_set("display_errors", 0);

if (!defined('jnggfmpt')) {
define('jnggfmpt', __FILE__);

if (function_exists('func2') == false) {
// 第一个函数返回 'base64_decode' ,这个函数不依赖其他任何函数,单纯地返回一个字符串 'base64_decode'。
function func1()
{
$v1 = '6f6e66723634';
$v2 = 'pa';
$v3 = '7374725f';
$v4 = 'H' . '*';
$v2 .= 'ck'; // $v2 = 'pack';
$v1 .= '5f717270627172'; // $v1 = '6f6e667236345f717270627172';
$v3 .= '726f743133'; // $v3 = '7374725f726f743133';
// $v5 = $v2($v4, $v3);
$v5 = pack('H*', '7374725f726f743133');
// $v5 = 'str_rot13';
// $v6 = $v5($v2($v4, $v1));
$v6 = str_rot13(pack('H*', '6f6e667236345f717270627172'));
// $v6 = 'base64_decode';
return $v6;
}

// 第二个函数接受两个参数,要注意第一个参数还是一个引用参数。
function func2(&$arg1, $arg2)
{
// 第一句是令一堆变量等于 func4
// $v1 - $v5 都使用 func4 解码一个字符串,结果如下
$v1 = 'str_rot13';
$v2 = 'strrev';
$v3 = 'gzuncompress';
$v4 = 'stripslashes';
$v5 = 'explode';
// $v6 = $v1($v2($v3($v4(func4('??????')))));
// $v6 = str_rot13(strrev(gzuncompress(stripslashes(func4('??????')))));
$v6 = ',chr,addslashes,rand,gzuncompress,assert_options,assert,file_get_contents,substr,unpack,constant,strpos,create_function,str_rot13,md5,set_include_path,dirname,preg_replace,base64_encode,base64_decode,';
// $v7 = $v5($v6);
// $v7 = explode($v6);
$v7 = array(
0 => "",
1 => "chr",
2 => "addslashes",
3 => "rand",
4 => "gzuncompress",
5 => "assert_options",
6 => "assert",
7 => "file_get_contents",
8 => "substr",
9 => "unpack",
10 => "constant",
11 => "strpos",
12 => "create_function",
13 => "str_rot13",
14 => "md5",
15 => "set_include_path",
16 => "dirname",
17 => "preg_replace",
18 => "base64_encode",
19 => "base64_decode",
20 => "",
);
$arg1 = $v7[$arg2];
// 看到这里知道了,这个函数就是用来需要用的提取函数名的
}

// 第三个函数被主程序调用了
// 不过分析之后发现这个 $arg1 参数并没有用到
// 这个函数的前半部分是防止调试
// 后半部分是提取后面加密的代码
function func3($arg1)
{
global $_v1, // $_v1 = 'file_get_contents';
$_v3, // $_v3 = 'substr';
$_v4, // $_v4 = 'assert';
$_v5, // $_v5 = 'assert_options';
$_v6, // $_v6 = 'unpack';
$_v7, // $_v7 = 'constant';
$_v8, // $_v8 = 'preg_replace';
$_v9, // $_v9 = 'base64_encode';
$_v10, // $_v10 = 'gzuncompress';
$_v11, // $_v11 = 'create_function';
$_v12, // $_v12 = 'strpos';
$_v13, // $_v13 = 'addslashes';
$_v14, // $_v14 = 'str_rot13';
$_v15, // $_v15 = 'md5';
$_v16, // $_v16 = 'set_include_path';
$_v17; // $_v17 = 'dirname';
// 这里有一堆变量等于 func4,然后用他们解码得到 $v1 - $v5
$v1 = 'php_sapi_name';
$v2 = 'die';
$v3 = 'cli';
$v4 = 'microtime';
$v5 = '1000';
// $v1() == $v3 ? $v2() : '';
// 这句话在调试的时候需要注释掉
php_sapi_name() == 'cli' ? die() : '';
// file_get_contents(constant(func4('??????')));
$v7 = file_get_contents(__FILE__);
// $v8 = $v4(true) * $v5;
$v8 = microtime(true) * 1000;
eval("");
// if ($v4(true) * $v5 - $v8 > 100) {
// 这里是防止下断点调试的,下断点调试,这里就超过 100 毫秒了,直接注释掉
if (microtime(true) * 1000 - $v8 > 100) {
// $v2();
die();
}
// eval(func4('??????'));
eval('if(strpos(__FILE__, msvigqgq) !== 0){$exitfunc();}');
// $_v12(func4($_v3($v7, func4('??????'), func4('??????'))), $_v15($_v3($v7, func4('??????'), func4('??????')))) ? $v9() : $v10;
// 这里的 $v9 和 $v10 都没有定义,如果验证失败,就会调用 $v9 会直接出错退出程序
// 而如果验证成功 $v10 变量不存在则没问题
// 验证方法就是把文件尾部分解密和前面的文件主体部分的md5对比,直接注释掉
!strpos(func4(substr($v7, -45, -1)), md5(substr($v7, 0, -46))) ? $v9() : $v10;
// 这两个数值是通过 func4 解码得到的
$v11 = '-2586';
$v12 = '-46';
// $v12 = $_v14(@$_v10(func4(substr($v7, $v11, $v12))));
$v12 = str_rot13(@gzuncompress(func4(substr($v7, $v11, $v12))));
return $v12;
}

// 第四个函数有点复杂,这是一个解码函数,用的是异或算法解密,所有调用 func4 的位置都没有提供 $arg2
function func4($arg1, $arg2 = '')
{
$v1 = 'base64_decode';
// $v2 - $v4 通过 base64_decode 解码得到
$v2 = 'ord';
$v3 = 'strlen';
$v4 = 'chr';
// $arg2 = !$arg2 ? $v2('?') : $arg2;
// $arg2 = !$arg2 ? 136 : $arg2;
$arg2 = 136;
// 这里 $v5 不存在,所以 $v6 = null;
$v6 = $v5;
// for (; $v6 < $v3($arg1); $v6++) {
for (; $v6 < strlen($arg1); $v6++) {
// $v7 .= $v2($arg1[$v6]) < $v2('?') ? $v2($arg1[$v6]) > $arg2 && $v2($arg1[$v6]) < 245 ? $v4($v2($arg1[$v6]) / 2) : $arg1[$v6] : '';
$v7 .= ord($arg1[$v6]) < 245 ? ord($arg1[$v6]) > $arg2 && ord($arg1[$v6]) < 245 ? chr(ord($arg1[$v6]) / 2) : $arg1[$v6] : '';
}
// $v8 = $v1($v7);
$v8 = base64_decode($v7);
$v9 = 'md5'; // $v9 通过 base64_decode 解码得到
$v6 = $v5;
// $arg2 = $v9('8_Q.L2');
// $arg2 = md5('8_Q.L2');
$arg2 = 'fac02565267d815643cecee75a16c7bd';
// $v10 = $ctrmax = $v3($arg2);
// $v10 = $ctrmax = strlen($arg2);
$v10 = $ctrmax = 32;
// for (; $v6 < $v3($v8); $v6++) {
for (; $v6 < strlen($v8); $v6++) {
$v10 = $v10 ? $v10 : $ctrmax;
$v10--;
$v11 .= $v8[$v6] ^ $arg2[$v10];
}
return $v11;
}
}
}

global $_v1, // $_v1 = 'file_get_contents';
$_v2, // $_v2 = 'chr';
$_v3, // $_v3 = 'substr';
$_v4, // $_v4 = 'assert';
$_v5, // $_v5 = 'assert_options';
$_v6, // $_v6 = 'unpack';
$_v7, // $_v7 = 'constant';
$_v8, // $_v8 = 'preg_replace';
$_v9, // $_v9 = 'base64_encode';
$_v10, // $_v10 = 'gzuncompress';
$_v11, // $_v11 = 'create_function';
$_v12, // $_v12 = 'strpos';
$_v13, // $_v13 = 'addslashes';
$_v14, // $_v14 = 'str_rot13';
$_v15, // $_v15 = 'md5';
$_v16, // $_v16 = 'set_include_path';
$_v17; // $_v17 = 'dirname';
// 然后一堆变量等于 func2
if (!$_v1) {
// 使用 func2 用传递引用变量的方法赋值,简化之后如下
$_v1 = 'file_get_contents';
$_v3 = 'substr';
$_v6 = 'unpack';
$_v10 = 'gzuncompress';
$_v11 = 'create_function';
$_v12 = 'strpos';
$_v13 = 'addslashes';
$_v14 = 'str_rot13';
$_v15 = 'md5';
$_v16 = 'set_include_path';
$_v17 = 'dirname';
$_v8 = 'preg_replace';
$_v9 = 'base64_encode';
$_v7 = 'constant';
$_v5 = 'assert_options';
$_v4 = 'assert';
$_v2 = 'chr';
$v1 = 'rand';
}
// 一堆变量等于 func4,然后用 func4 解码
$v2 = '_f_';
$v3 = func3(${$v2});
// $_v16($_v17(${$v2}));
set_include_path(dirname(${$v2}));
// $v4 = $_v9($v3);
$v4 = base64_encode($v3);
// $v5 = func4('??????');
// 解密之后的原文不是 $v4,这里是翻译之后的
$v5 = 'eval(base64_decode($v4));';
// $v5 = $_v8(func4('??????'), $v5, func4('??????'));
// mixed preg_replace ( mixed $pattern , mixed $replacement , mixed $subject [, int $limit = -1 [, int &$count ]] )
// PCRE 修饰符 e (PREG_REPLACE_EVAL)
// Warning: This feature was DEPRECATED in PHP 5.5.0, and REMOVED as of PHP 7.0.0.
// If this deprecated modifier is set, preg_replace() does normal substitution of backreferences in the replacement string, evaluates it as PHP code, and uses the result for replacing the search string. Single quotes, double quotes, backslashes (\) and NULL chars will be escaped by backslashes in substituted backreferences.
// 换句话说 preg_replace 如果带 e 的话,第一步,正常地进行正则表达式替换(反向引用也会被正常替换,就是完全正常的正则替换),第二步,把结果 eval 作为最终结果
// 简而言之 $v5 = eval($v5);
$v5 = preg_replace('/0dcaf9/e', $v5, '0dcaf9');
// 把上述几步统一一下 $v5 = eval(func3($_f_));
// $_v16($_v17(${$v2}));
set_include_path(dirname(${$v2}));
// 把解码之后的文件运行结果返回
return $v5;

解码

php
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php

function decrypt($data, $key)
{
$data_1 = '';
for ($i = 0; $i < strlen($data); $i++) {
$ch = ord($data[$i]);
if ($ch < 245) {
if ($ch > 136) {
$data_1 .= chr($ch / 2);
} else {
$data_1 .= $data[$i];
}
}
}
$data_1 = base64_decode($data_1);
$key = md5($key);
$j = $ctrmax = 32;
$data_2 = '';
for ($i = 0; $i < strlen($data_1); $i++) {
if ($j <= 0) {
$j = $ctrmax;
}
$j--;
$data_2 .= $data_1[$i] ^ $key[$j];
}
return $data_2;
}

function find_data($code)
{
$code_end = strrpos($code, '?>');
if (!$code_end) {
return "";
}
$data_start = $code_end + 2;
$data = substr($code, $data_start, -46);
return $data;
}

function find_key($code)
{
// $v1 = $v2('bWQ1');
// $key1 = $v1('??????');
$pos1 = strpos($code, "('" . preg_quote(base64_encode('md5')) . "');");
$pos2 = strrpos(substr($code, 0, $pos1), '$');
$pos3 = strrpos(substr($code, 0, $pos2), '$');
$var_name = substr($code, $pos3, $pos2 - $pos3 - 1);
$pos4 = strpos($code, $var_name, $pos1);
$pos5 = strpos($code, "('", $pos4);
$pos6 = strpos($code, "')", $pos4);
$key = substr($code, $pos5 + 2, $pos6 - $pos5 - 2);
return $key;
}

$input_file = $argv[1];
$output_file = $argv[1] . '.decrypted.php';

$code = file_get_contents($input_file);

$data = find_data($code);
if (!$code) {
echo '未找到加密数据', PHP_EOL;
exit;
}

$key = find_key($code);
if (!$key) {
echo '未找到秘钥', PHP_EOL;
exit;
}

$decrypted = decrypt($data, $key);
$uncompressed = gzuncompress($decrypted);
// 由于可以不勾选代码压缩的选项,所以这里判断一下是否解压成功,解压失败就是没压缩
if ($uncompressed) {
$decrypted = str_rot13($uncompressed);
} else {
$decrypted = str_rot13($decrypted);
}
file_put_contents($output_file, $decrypted);
echo '解密后文件已写入到 ', $output_file, PHP_EOL;

解题

没什么好说的,index.php 提示存在 UploadFile.class.php,下载下来之后,发现关键函数,然后数组绕过就vans了

php
1
2
3
4
5
6
7
<?php
...
// 用.分割文件名,只保留首尾两个字符串,防御Apache解析漏洞
$origin_name = current($filename);
$ext = end($filename);
$new_name = ($this->new_name ? $this->new_name : $origin_name) . '.' . $ext;
$target_fullpath = $this->dist_path . DIRECTORY_SEPARATOR . $new_name;

参考

Virink师傅的题解
p神的出题原理
腹黑师傅的wp
Ganlv 师傅的解码思路
某PHP加密文件解密过程初探

文章作者: 晓黑
文章链接: https://www.suk1.top/2020/03/26/php%E6%B7%B7%E6%B7%86%E5%8A%A0%E5%AF%86/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Manayakko - 微笑才是王道
打赏
  • 微信
    微信