某PHP加密文件调试解密过程
更新时间:2018-07-04 00:01:17 人气:
案例 1
实验样本
据说“加密效果同行最高”?
到 http://www.phpjiami.com/phpjiami.html 随意上传一个 php 文件,然后下载加密后的文件,这就是我们要解密的文件。
后来我发现这个“找源码加密”和“PHP加密”的加密算法是完全完全一样的 http://www.zhaoyuanma.com/phpencode.html,文中的解码程序对两个网站的免费加密通杀。
“保证运行状态下使程序不可读,难以被还原。找源码所加密的程序,找源码网站也不能破解,切记保留备份。”
这给你牛的...你自己破解不了,我可以帮你破解。
简单分析一下
先看看加密后的文件
可以看出这是一个正常的 php 文件,只不过所有的变量名都是乱码,还真亏了 php 引擎支持任意字符集的变量名,这个加密后的文件变量名的字节部都在 ASCII 范围以外,全是 0x80
以上的字符。
我们看到中间有一个 php 代码段结束标签 ?>
,而他的前面还有一个 return $xxx;
来结束脚本运行,这说明结束标签后面的数据都不会被正常输出,后面极可能是源文件加密后的数据,而前面的 php 代码只是用来解密的。
调试之前的准备
这里使用的 IDE 是 VSCode
(最开始我使用的是 PHPStorm
,后来我发现 VSCode
的效果更好)。
首先,安装 PHP Debug
插件。
然后,按照 https://xdebug.org/docs/install 的说明安装 XDebug
插件。
注意:运行未知的 php 代码还是很危险的,最好能在虚拟机上运行,真机上一定要保证你的 XDebug
和 PHP Debug
调试插件可以正常下断点。断开网络。最好同时打开任务管理器,一旦发生未知现象(比如 CPU 占用率或磁盘占用率),或者调试断点没断下来,或者出现某些问题,立刻结束 php 进程。
代码格式化
这个代码太乱了,我们需要格式化一下代码。
最开始我用的是 PHPStorm 自带的代码格式化,格式化之后数据变了,PHPStorm 对未知字符集的支持还是比较差的。
然后我就想对 php 文件的 AST
(Abstract Syntax Tree
抽象语法树)进行分析,看能不能顺便把变量名都改成可显示字符。后来想想似乎不行,因为这种代码肯定是带 eval
的,改了变量名之后,eval
的字符串中的变量名就对应不上了。
我找到了这个工具:https://github.com/nikic/PHP-Parser
首先 composer require nikic/php-parser
。
然后将下列代码保存到一个文件中(比如 format.php
),读取下载下来的 1.php
,把格式化之后的代码写入 2.php
。
<?php use PhpParser\Error; use PhpParser\ParserFactory; use PhpParser\PrettyPrinter; require 'vendor/autoload.php'; $code = file_get_contents('1.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('2.php', $prettyCode);
然后,执行 php format.php
。
使用这个方法格式化的 php 文件内容并没有被损坏,我们可以继续分析了。
如果,还不行,那就只能用十六进制编辑器查找
;
和}
手动替换了,添加\r\n
了。
调试
最前面这两行我们得先注释掉,不然出了什么错误的话会莫名其妙的。
error_reporting(0);
ini_set("display_errors", 0);
保存。然后完蛋了,代码又乱了。
我们需要一个支持非可显示字符的编辑器,或者...更改显示编码,选择一个不是多字节的字符集,比如 Western (ISO 8859-1)
现在,开始我们的调试。
在第一行下断点。执行 php 2.php
运行程序。然后单步调试,一边执行,一边注意变量的值,分析函数的执行流程。
使用 VSCode
的调试功能,我们可以方便的查看变量的具体内容。
单步调试到这一行,似乎有些不对劲。
php_sapi_name() == 'cli' ? die() : '';
我们用命令行运行的,所以执行完这一句,肯定程序就结束了。
那就让他结束吧,我们把这一行注释掉,在他下面下断点。重新运行程序。
下面这行是就是读取当前文件,这句话没有什么问题。
$f = file_get_contents(constant('rnfzwpch'));
然后就又是验证运行环境。
if (!isset($_SERVER['HTTP_HOST']) && !isset($_SERVER['SERVER_ADDR']) && !isset($_SERVER['REMOTE_ADDR'])) {
die();
}
注释掉,保存,重新运行。
当然,也可以通过调试控制台,执行类似 $_SERVER['HTTP_HOST'] = '127.0.0.1';
这类指令,来让验证通过。
再看下面的代码,我想到 exe 反调试了,不得不佩服想这个方法的人。防止下断点调试的,如果下断点调试,这里就超过 100 毫秒了。
$t = microtime(true) * 1000; eval(""); if (microtime(true) * 1000 - $t > 100) {
die();
}
我们直接在这条语句之后下断点,让他们一连串执行完,这样就不会超过 100 毫秒了。当然,直接注释掉是最粗暴的方法。
下面的 eval
我们需要通过“单步进入”来研究,不过结果是对我们的影响不大,当然注释掉也没问题。
接下来这个就是校验数据完整性的了
!strpos(decode_func(substr($f, -45, -1)), md5(substr($f, 0, -46))) ? $undefined1() : $undefined2;
这里的 $undefined1
和 $undefined2
都没有定义。如果验证失败,就会调用 $undefined1
会直接 Error
退出程序。而如果验证成功,虽然 $undefined2
变量不存在,但是只是一个 Warning
,并没有太大问题。
decode_func
就是文件中最后一个函数,专门负责字符串解码的。
这个验证方法就是把文件尾部分解密和前面的文件主体部分的 md5 对比,这次执行肯定又不能通过。
退出程序,注释掉,再重新运行。
$decrypted = str_rot13(@gzuncompress(decode_func(substr($f, -2358, -46))));
我们找到了这个解码的关键语句了,可以看到解密之后的代码已经出来了。
到了代码的最后,终于要执行脚本了。
$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;
折腾了半天,还是 eval
语句。
如何把内容输出呢。直接在 $decrypted
后面加上一行 file_put_contents
就可以了。
成果
通用解密程序
我们可以继续分析一下他的解密算法
算法是固定的,只是其中内联了一个秘钥,我们只要通过字符串函数截取出这个秘钥就可以了。
最后的解码程序如下。
<?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;
这个程序可以解密此网站全部免费加密的代码。
使用方法:php decrypt.php 1.php
案例 2
附件的 examples/2.php
,此附件由本帖 70# 层 提供,原楼层附件中文件名为 01.php
。
使用 php decrypt.php examples/2.php
直接解密
案例 3
附件的 examples/3.php
,此附件由本帖 70# 层 提供,原楼层附件中文件名为 02.php
。
使用 php decrypt.php examples/3.php
可以解密,不过解密之后得到的 3.php.decrypted.php
仍然是加密的内容。
好了,我们开始我们的调试。
和上面相同,去掉所有的环境监测、调试检测、文件完整性监测,最后我们来到了同样的位置,
$base64_encoded_decrypted = base64_encode($decrypted); $eval_string = 'eval(base64_decode($base64_encoded_decrypted));'; $result = eval($eval_string);
这里的 $decrypted
就是我们上面看到的一次解密结果。
我们还需要继续跟踪二次解密。
我们观察一下 3.php.decrypted.php
的内容,对比一下调试中的变量值,这里 eval
的内容是 assert(base64_decode('......'));
。
assert
和 eval
差不多,都是执行一个语句,这里我们不能让他执行,我们直接把解密的内容输出出来。
把 $GLOBALS['???']($GLOBALS['???']('......'));
改成
file_put_contents(__DIR__ . '/3.php.decrypted.2.php', "<?php\n". base64_decode('......'));
然后保存到 3.php.decrypted.1.php
,然后执行他,可以输出解密后的内容到 3.php.decrypted.2.php
。
我们打开 3.php.decrypted.2.php
,又看到了同样的场景,所以还是用和上面的同样的方法,分析得到代码为 eval(gzuncompress(base64_decode('......')));
,然后改成
file_put_contents(__DIR__ . '/3.php.decrypted.4.php', "<?php\n". gzuncompress(base64_decode('......')));