PHP源码保护与还原方法详解:无扩展加密的破解技巧
[id_[id_3[id_[id_133698369]8009255]3952382]06[id_1285419502]268655]

前 言
近期在工作中遇到了一些用 php 开发的应用,这些应用都采用了不同的源码保护方案来对自身代码进行加密处理,有的应用加密强度比较高,以至于工作还没开始就可能要宣告结束。
为了解决所遇到的这个问题,对市面上一些常见的保护方案展开了研究。同时,成功地将这些较为常见的保护进行了代码还原。接下来,将会对这几种常见的保护以及还原方法进行介绍。
[id_67[id_1643561586]246508]
无扩展加密
这种加密程度,只是对代码本身进行了压缩与编码。处理完毕后,它本身就是一个正常可运行的文件。依托于 php 的变量与函数,能够使用绝大部分字符(除了一些特殊字符)。这就使得这种方式处理之后的文件内容,几乎全是不可见字符,只有零星一些明显被编码后的字符串以及极个别函数符号是可见的。
因为加密强度不高,所以我们能够通过强行阅读处理后的文件,还原出解码方式,进而获取原始代码。接下来,用两种市面上常见的加密来举例,演示如何进行逆向还原。
01
[id_1468899370]
工具地址:
[id_322706599]
可以看到基本上全是由不可见字符和编码后的字符串组成。其中能注意到有一个明文可见的符号为 base64_decode。由此可以确定那一堆可见字符就是一堆 base64 编码。因为低版本 php 在遇到非码表内的字符时会进行忽略从而完成正常解析,而高版本 php>=7 会抛出异常,所以这种处理后的代码只能在低版本 php 上运行。那么直接对代码进行格式化,然后尝试对其还原,格式化后的代码如下所示:
/*PHP 消息相关内容,在 PHP 文件中进行处理。开发人员需确保 PHP 文件能够接收并处理相关消息,同时要遵循相关的消息处理规范。警告:请勿修改此文件,否则可能导致程序运行出现问题。QQ: 1833596该网站的地址是 http://www.phpjm.net/ 。2012 年到 2024 年期间,phpjm.net 的版权归其所有,且所有权利均已保留。*/if (!defined([id_802657830])) {define("EBCCDDCDEF", __FILE__);global$�,$��,$���,$����,$����,$������,$�������,$��������,$���������,$����������,$�����������,$�����������,$�������������,$��������������,$���������������,$��������������� 分别是不同的元素或内容,它们各自具有独特的性质和特点。function ��($��,$���="") {global $�,$��,$���,$����,$����,$������,$�������,$��������,$���������,$����������,$�����������,$�����������,$�������������,$��������������,$���������������,$���������������;if(empty($���)) {return base64_decode($��);} else {return以上就是改写后的内容。}}$���=��("c3RycmV2�");$�����������=��("c3RydHI=�");$����=��("LXLhbA==�","ZpomPWJL");$����=��("A3p1bmNvbXByAXNz�","ZTQA");$������=��("nmFzZTn0X2R�ln29kZQ==�","YhJpAn");$����������=��("SHJlZ19yZXBsYWNl�","chtS");$���������������=��(“ZzdlNWM5Yjd 等在特定情境下与 iMDRhNjLlNT 等一起形成了某种结果。”,"LomFgvZ");function ����(&$����) {global $�,$��,$���,$����,$����,$������,$�������,$��������,$���������,$����������,$�����������,$�����������,$�������������,$��������������,$���������������,$���������������;$����������������=��("Okll�","ZGfMkO");请提供具体的句子呀,没有看到横线下的内容呢。它包含一些特定的字符组合,如“eNptku1P2lAYxf8V�0vDhNuuUUosj5GZb�M0GJI4AghW0hLbQM�bGcFgsNpEFaohDfp�ECvC1j91vZeXGLaP�957fc87Nea57Aic2�IDRVqWixSEZSk9Ag�KCKT42MHGkEG3Hof�AwktUhGZiDJBoipr�CaT9enq+n08tDJyc�h5V0KgZNBJyHOOnb�CUIW5tB8fO4YmEny�Xy/FkP8sGYPwDnHS�RbWcRNidOWqbzU53�iDk5FaPnji5GPx4p�5YKMiGZjaLZvHwa9�n7MWhgqq/7KQ2oMt�ZHR4XOWUAuL6t/ez�32Zz2m63zWUqx5wW�OSZeXzhgrva9UApp�CQ6hxmDe0i1dN6xe�d/CE2eBhVMnQ/mp6�XouXhPcQjtFQuMZH�g2jioTtrdRZGo9U3�5ta4a+KZcIqV07xQ�T/NncOTgwoeL0yu1�FEYDExtms8Gj44Ns�NlCUwVYkIHbBzqu3�5GewS1AOSzlt2yT5�w90zFn9siE6f6C+B�G0mpSJvLiR24cfdm�1lSfOqeXtYAlQrmI�sojCx43Hnu4wqzWB�9ZQTtUJJvOCNsF0f�WFlQrxmahHBb3CwN�bCv/d/E4Pl5yh/Dv�e/dzPokVPX5vjmXl�vEDnmbyPFd74WNqz�JxAUQ5I403V97fqn�dBugrzfq2/Dd0nn9�foBvyZffyYbVoiqB�dRl4XwoTp0U1UhN5�rt5X+MgV2nEmnghp�itPaX0ZAL+E=”。,"���请明确具体的句子内容呀,你只给了一串字符,没有实际的句子让我改写呢。);return "�";}} else {global $�,$��,$���,$����,$����,$������,$�������,$��������,$���������,$����������,$�����������,$�����������,$�������������,$��������������,$���������������,$���������������;$���=��("c3RycmV2�");$�����������=��("c3RydHI=�");$����=��("LXLhbA==�","ZpomPWJL");$����=��("A3p1bmNvbXByAXNz�","ZTQA");$������=��("nmFzZTn0X2R�ln29kZQ==�","YhJpAn");$����������=��("SHJlZ19yZXBsYWNl�","chtS");$���������������=��("ZzdlNWM5Yjd�iMDRhNjLlNT�JhMDm0LGVkM�zlhMjM5YjYz�Z2U=�","LomFgvZ");}$���������=��("JU5vAjBNNE5CJ0F�DY4JxRWOZ�","ZxqeLAgnJ");$��������=����($���������);请提供具体的句子呀,没有看到横线下的句子呢。此内容包含无法识别的乱码或特殊字符,无法准确理解其含义并进行改写。请提供清晰、有意义的文本内容。.$���������.$��������.lTK4RuHv9hfB0F7 等在其中有着相应的地位。,"��抱歉,你提供的内容似乎不太完整或不太清晰,我不太理解其具体含义和要求,无法准确进行改写。请提供更明确的信息或上下文,以便我更好地为你服务。);return true;请提供具体需要改写的句子呀,只给出了一个编码呢,没有实际的句子内容呢。
代码量不算多,逻辑也较为清晰。如果觉得这类不可见字符对阅读有影响,甚至可以直接使用 winhex 等工具进行批量替换。首先可以发现这里面仅有两个函数,把它们重新命名为 func0 和 func1。先来查看其中含有符号 base64_decode 的 func0,暂且忽略那一堆会扰乱视线的全局变量,将编码后的明文字符串代入,就能还原出 func0 函数的处理流程。
function func0($var1, $var2="") {// global $varxxx...if (empty($var2)) {return base64_decode($var1);} else {//func0调用 varx 函数,传入 var1、var2 和调用 vary 函数并传入 var2 的结果;func0使用函数 strtr 对变量 $var 进行操作,将其中的 $var2 替换为 $var2 的反转字符串 strrev($var2) 。}}
注释中的 varx 实际上就是 func0('c3RycmV2�') 等于 strrev,vary 实际上就是 func0('c3RydHI=�') 等于 strtr。到这里,最核心的解码函数已经恢复完毕。下面这一堆看起来很复杂的调用,实际上就是解码需要用到的函数符号,解码后的结果如下:
$���=��("c3RycmV2�");$�����������=��("c3RydHI=�");$����=��("LXLhbA==�","ZpomPWJL");$����=��("A3p1bmNvbXByAXNz�","ZTQA");$������=��("nmFzZTn0X2R�ln29kZQ==�","YhJpAn");$����������=��("SHJlZ19yZXBsYWNl�","chtS");$���������������=��("ZzdlNWM5Yjd�iMDRhNjLlNT�JhMDm0LGVkM�zlhMjM5YjYz�Z2U=�","LomFgvZ");/*string(6) "strrev"string(5) "strtr"string(4) "eval"string(12) "gzuncompress"string(13) "base64_decode"string(12) "preg_replace"string(35) "/7e5c9b7b04a66e52a084ded39a239b63 后面接着一个字母 e"*/
将所有不可见字符全都替换回来之后,以此类推,整个文件的代码就会变成如下所示,这样阅读起来就会非常简单。
function func0($var1,$var2="") {if(empty($var2)) {return base64_decode($var1);} else {return使用函数 func0 ,该函数的参数是将字符串 $var1 中的 $var2 替换为字符串 $var2 的反转结果 strrev($var2) 。}}function func1(&$var1) {eval(gzuncompress(base64_decode('xxxx')));return pack('H*', '80');}$tmp_var = func0(pack('H*', '4A553576416A424E4E4535434A3046' 以及 '9F4459344A7852574F5A83'), pack('H*', '5A7871654C41676E4A'));$tmp_var2 = func1($tmp_var);@preg_replace("/7e5c9b7b04a66e52a084ded39a239b63/e" 这个内容本身就是特定的标识或路径等信息,没有具体的上下文来明确其确切含义和用途,仅从这几个字符本身难以确切理解其具体所指。, eval 函数作用于经过一系列解码操作后的内容。首先对 str 进行处理,接着与 $tmp_var 进行组合,再与 $tmp_var2 组合,然后将组合后的结果与 str 再次组合,最后对组合后的整体内容进行 base64 解码,再对解码后的内容进行 gzuncompress 解压操作。, 我无法理解你提供的这段内容,它看起来像是乱码或无意义的字符组合,无法进行改写。请提供有意义的文本内容。);
在 func1 中存在一段调用 eval(gzuncompress(base64_decode('xxx')))。将其恢复后,发现是一段较长的代码。这段代码除了有一句对 $var1 进行赋值的代码外,对还原文件的帮助不大,主要是对当前文件进行一些 md5 的完整性校验等。赋值代码的原型如下:
$var1 = gzuncompress(base64_decode($var1));
原样调用后得到了 $tmp_var 的值,也得到了 $tmp_var2 的值。将它们进行拼接,接着做 gzuncompress(base64_decode)操作,就能够还原出 php 本来的代码了。
02
phpjiami.com
工具使用地址:
这种方式处理后的代码与 phpjm 本质上没什么差别,只是逻辑变得更复杂了些,处理前后的代码对比情况如下:
这个代码量与 phpjm 相比大了很多,若手工进行还原会比较费力。可以借助 nikic/php-parser 来对文件进行解析,把其中所有的变量从不可见字符替换为可见字符串,然后再手动对代码进行一些修复和替换,以提高其可读性,便于还原解密逻辑。以下是对函数和符号重命名的 Visitor:
class VariableVisitor extends NodeVisitorAbstract{public function leaveNode(Node $node) {global $varCount, $funcCount, $maps;if ($node instanceof Expr\Variable) {如果 $node->name 是字符串类型,那么 $varName 就等于 $node->name;否则,$varName 等于 $node->name->name。$varName = md5($varName);if如果 $varName 存在并且 $varName 不在 $maps 数组的键中,那么就会执行以下代码块。$maps[$varName] = 'var' . $varCount++;}变量 $varName 对应的映射值被赋值给变量 $node 的 name 属性,即 $node 的 name 属性等于 $maps 中 $varName 对应的那个值。}if ($node instanceof Node\Stmt\Function_) {$funcName 等于 $node 的 name 的 name 。$funcName = md5($funcName);if如果 $funcName 存在且 $funcName 不在 $maps 数组的键中,那么(条件成立)。$maps[$funcName] = 'func' . $funcCount++;}变量 $node 的 name 属性被赋值为 $maps 数组中 $funcName 对应的元素。}}}
在大致恢复符号之后,可以发现存在三个函数,它们被重新命名为 func0、func1 和 func2。因为只有 func2 中包含一个可见字符的函数,即 base64_decode,并且之后有一系列明显的调用。所以从恢复这个函数开始,手动将其进行解码并重新赋值。在恢复完成之后会发现,实际上这是一个核心的解密函数,func2 的恢复对比情况如下:
通过如法炮制的方式恢复出了 func1,进而可以发现它其实就是一个较为简单的解码操作。
function func1(&$var1, $var2) {$enc = func2(pack('H*', 此字符串内容为“A6CAE0B236F23833DEA29A2B9C30E2DCD89CEA42AE32EE2BE635C4989AD2D8C2E8EE34C4CA47C29A33A0AAC8C8DAE0B037D8364333E2C8A2C4EC3932F244E04730F4A6CEA2D2E2B238C439F4EE9EB234DCCE9C30493338E8AA474396A6969847D898CC309CE44A379AC6C4A64235D6AEAEDC3749DCF4A246C630383944DEACF2A04AB22B2F2BA2A4F0333098C6B0364632E8E0A6E62FEAC637ECD849AC4546CC30A647C2F0A039C239C6A8DEC4DC35D0CCC4E24748A241AEE02B9C46E8DADADAF444A6CC”,未进行具体含义解读或其他处理。));使用 strrev 函数反转字符串,再使用 gzuncompress 函数解压缩字符串,接着使用 stripslashes 函数去除字符串中的反斜杠,最后使用 str_rot13 函数对字符串进行 ROT13 编码操作,将结果赋值给变量 $res 。$s = explode(',', $res);$var1 = $s[$var2];}
解码之后将其dump出来,发现实际上它就是函数符号表,是供var1这一堆很长的全局变量使用的。
最后解出的是 func0,会发现它就是最终能解出原始代码的那个函数,并且里面有一堆校验,其作用是防止用户破解。
function func0($var1) {php_sapi_name() == 'cli' ? die() : ''; // 防止在cli下运行$content = file_get_contents(__FILE__);同样要避免在 cli 环境下运行,这里或许是担心会直接对 php_sapi_name 进行 hook 操作。if(!isset($_SERVER['HTTP_HOST']) && !isset($_SERVER['SEREVER_ADDR']) && !isset($_SERVER['REMOTE_ADDR'])) {die();}$time = microtime(true) * 1000;// 防止eval hook,断点时间超1秒就退出eval("");if (microtime(true) * 1000 - $time > 100) {die();}eval(如果在__FILE__中查找'nirpnqsz'的位置,结果不等于 0,那么就执行$exitfunc()。);查找 func2(substr($content, func2(getHexStr( 字符串在另一个字符串中的位置。 先对 $content 进行 substr 操作,再对结果进行 getHexStr 操作,最后对 getHexStr 的结果进行 func2 操作,然后查找这个操作结果在某个字符串中的位置。'4841A4A2')), func2(pack('H*', '4841453D'如果 md5(substr($content, func2(), func2())))) 成立,就返回 undefined();否则返回 undefined()。$start = func2(pack('H*', '484146A6ACA23D3D'));$end = func2(pack('H*', '4841A4A8'));// 还原的核心代码,其实就是取了后那一堆乱码使用 substr 函数从 $content 中截取从 $start 到 $end 的部分,然后将其作为参数传递给 func2 函数进行处理,处理后的结果再通过 gzuncompress 函数进行解压,最后使用 str_rot13 函数对解压后的结果进行处理。return $content;}
从加密后的文件中取出被还原的核心代码,即 str_rot13(@gzuncompress(func2(substr($content, $start, $end)))); 然后对其进行调用,这样就能还原出加密前的代码。
三
扩展加密(源码混淆)
当加密进行到这一步时,方式开始出现变化。例如,需要用户手动进行编译并启用一个 php 扩展,然后使用该扩展所提供的工具对文件进行一次相应的加密。这样一来,在 web 目录中的文件几乎全是乱码,看不到任何可见符号。但从本质上来说,这种加密与上述的两种加密并无任何差别,只是增加了一些扩展逆向的难度而已。下面同样以两种市面上常见的扩展为例。
01
php-beast
项目地址:
使用这个扩展处理文件之后,整个文件会处于完全不可读的状态。在非默认情况下,脱离扩展本身很难将其还原。加密前后的对比情况如下:
直接看他如何hook的 compile_file
zend_op_array *__fastcall cgi_compile_file(zend_file_handle *h, int type){const char *filename; // rdiFILE *v4; // raxFILE *v5; // rbpint v6; // eaxint v7; // eaxzend_stream_type v8; // eaxfile_handler *v9; // raxint v10; // edxint v11; // r13dbeast_free_buf_t *free; // raxbeast_ops *ops; // [rsp+8h] [rbp-40h] BYREFchar *buf; // [rsp+10h] [rbp-38h] BYREFint free_buffer; // [rsp+18h] [rbp-30h] BYREFint size[3]; // [rsp+1Ch] [rbp-2Ch] BYREFfilename = h->filename;free_buffer = 0;ops = 0LL;v4 = fopen(filename, "rb");v5 = v4;if ( !v4 )goto final;v6 = fileno(v4);v7 等于对 h 的文件名进行解密操作,使用 v6 作为参数,同时将解密后的结果存储在 buf 中,大小为 size,还会涉及到对 free_buffer 和 ops 的相关处理。// ...return old_compile_file(h, type);}
打开文件后调用了自己实现的 decrypt_file 。接着做了一系列操作,把解密后的内容重新交给 old_compile_file ,让其继续进行正常的编译执行操作。主要关注这个 decrypt_file ,看看它是怎样进行解密的。
文件经由 beast 混淆后会有一个文件头特征,通常其为:0xe8, 0x16, 0xa4, 0x0c, 0xf2, 0xb2, 0x60, 0xee 。代码会检查这个文件头,以此来判断是否为加密文件。用户可在编译前修改 header.c 以替换该值,所以此特征并非固定不变。
.data:000000000000E668 public encrypt_file_header_sign.data:000000000000E668char 用于存储加密文件头签名的数组为 encrypt_file_header_sign ;该数组用于特定的加密文件头相关操作 ;其具体的用途是在特定的加密文件处理过程中起到标识和验证等作用 。8].data:000000000000E668 encrypt_file_header_sign db 0E8h, 16h, 0A4h, 0Ch, 0F2h, 0B2h, 60h, 0EEh.data:000000000000E668 ; DATA XREF: LOAD:0000000000000EF0↑o.data:000000000000E668语言表达较为模糊和难以理解,不太清楚具体的含义和上下文,仅从这几个字符和符号难以准确改写出有意义的句子。如果这是特定程序或系统中的代码片段,需要结合相关的背景知识和代码逻辑才能进行恰当的改写。.data:000000000000E660 public encrypt_file_header_length.data:000000000000E660定义了一个整型变量 encrypt_file_header_length.data:000000000000E660 encrypt_file_header_length dd 8 ; DATA XREF: LOAD:00000000000010A0↑o.data:000000000000E660获取:加密文件头长度指针向上移动 o 。if使用 memcmp 函数比较 header 与 encrypt_file_header_sign,比较的长度为 encrypt_file_header_length{if ( log_normal_file ){beast_write_log 进行了 beast_log_error 的相关操作。 它将 beast_log_error 记录下来。 这是一种错误记录的行为。 用于记录错误相关的信息。 以 beast_log_error 为依据进行记录。 记录的内容与 beast_log_error 相关。 主要是针对 beast_log_error 的记录动作。 为了记录 beast_log_error 而进行的操作。 围绕 beast_log_error 展开的记录行为。 以记录 beast_log_error 为目的的动作。 基于 beast_log_error 进行的记录活动。 专门针对 beast_log_error 进行的记录事宜。 主要是对 beast_log_error 进行记录的过程。 以 beast_log_error 为核心的记录行为。 为记录 beast_log_error 而开展的行动。 围绕 beast_log_error 进行的记录工作。 依据 beast_log_error 进行的记录行为。 以记录 beast_log_error 为导向的动作。 主要围绕 beast_log_error 展开的记录活动。 以 beast_log_error 为基础的记录行为。 为记录 beast_log_error 而进行的各项操作。 围绕 beast_log_error 所进行的记录事宜。 以记录 beast_log_error 为主要目的的动作。 基于 beast_log_error 开展的记录工作。 依据 beast_log_error 进行的记录行动。 以记录 beast_log_error 为导向的记录行为。 主要是以 beast_log_error 为依据的记录活动。 为记录 beast_log_error 而进行的相关操作。 围绕 beast_log_error 展开的相关记录行为。 以记录 beast_log_error 为主要导向的动作。 基于 beast_log_error 开展的相关记录工作。 依据 beast_log_error 进行的相关记录行动。"文件 `%s` 不是一个加密文件", filename);return -1;}return -1;}
判断文件头符合之后,会接着从文件头里取出三个信息,分别是文件大小、有效日期以及加密方式。这三个值都需要转为大端序,其中最为重要的是加密方式这个值。
v16 等于将 &header[encrypt_file_header_length] 转换为 _DWORD 类型的指针所指向的值。v17 等于将 header 数组中从 encrypt_file_header_length 位置开始的地址转换为 _DWORD 类型的指针所指向的值。4];v18 等于将指针类型转换为 DWORD 类型后与 header 数组中 encrypt_file_header_length 之后的地址进行相加操作所得的结果。8];v42 = 4386;v19 = (v16 << 24) | ((int)(v16 & 0xFF0000) >> 8) | ((v16 & 0xFF00) << 8) | HIBYTE(v16);v20 = (v17 << 24) | ((int)(v17 & 0xFF0000) >> 8) | ((v17 & 0xFF00) << 8) | HIBYTE(v17);v21 = (v18 << 24) | ((int)(v18 & 0xFF0000) >> 8) | ((v18 & 0xFF00) << 8) | HIBYTE(v18);if ( beast_max_filesize > 0 && v19 > beast_max_filesize ){beast_write_log(beast_log_error,文件大小为“%d”,而最大大小为“%d”,(unsigned int)v19,(unsigned int)beast_max_filesize);return -1;}if ( v20 > 0 && v20 < beast_now_time ){野兽写入日志(野兽日志错误, )"File `%s' was expired", filename);return -2;}v31 = chk;beast_get_encrypt_algo(v21)得到了 encrypt_algo 。
在文件中具体查看代码所示的内容,就能看到文件头中这四个信息的情况,这四个信息分别是文件头标志、文件大小、有效日期以及加密方式。
现在知晓加密方式为 1 了,接着跟进去,看看这个数字指代的是何种方式的混淆。
beast_ops 进行快速调用 beast_get_encrypt_algo 函数并传入整数类型参数 type{return beast_get_encrypt_algo(type);}beast_ops *__fastcall beast_get_encrypt_algo(int type){unsigned int v1; // ediv1 = type - 1;if ( v1 > 3 )return ops_handler_list[0];elsereturn ops_handler_list[v1];}.data:000000000020C620 public ops_handler_list.data:000000000020C620 ;野兽操作(beast_ops)的操作处理程序列表(ops_handler_list)中,包含了各种与 C620 相关的操作处理程序。这些操作处理程序可以处理不同类型的 C620 相关任务,例如数据传输、指令执行等。每个操作处理程序都有其特定的功能和用途,可以根据具体的需求选择合适的操作处理程序来处理 C620 相关的任务。4].data:000000000020C620 的 ops_handler_list dq 有偏移量 des_handler_ops,有偏移量 aes_handler_ops,有偏移量 base64_handler_ops.data:000000000020C620;数据引用:加载:0000000000001AE8↑o.data:000000000020C620 得到了 ops_handler_list_ptr 并指向 o.data:000000000020C638 有 dq offset 和 dword_0 。
这里可以看到 beast 所提供的三种代码混淆方式,分别是 DES、AES 和 BASE64,并且默认使用的是 DES。这里我们的取值为 1,能得知当前文件所使用的混淆方式是 DES。接下来的事宜较为简单,在对应的 decrypt_handler 里找到相应的 key,接着用 Python 简单编写一个解密脚本就行(这里的 key 同样是能够被修改掉的)。
.data:000000000000E5A8 ; char key_0[8].data:000000000000E5A8 key_0 db 1, 1Fh, 1, 1Fh, 1, 0Eh, 1, 0Eh.data:000000000000E5A8,数据交叉引用:des_encrypt_handler+89↑o.data:000000000000E5A8 ;用于解密处理的 des_decrypt_handler 加上(一些相关操作或特征等)50↑o对加密后文件进行解密的操作情况如下:(AES 与 BASE64 情况相同)。from Crypto.Cipher import DESfrom Crypto.Util.Padding import padECB_KEY = 我无法理解你提供的这个字节序列“b'\x01\x1F\x01\x1F\x01\x0E\x01\x0E'”的具体含义和相关情境呀,无法进行改写呢。请提供更明确的内容或背景信息哦。HEAD_LENGTH = 8ENCRYPT_TYPES = [ 'DES', "AES" , 'BASE64']def des_decrypt(ciphertext):cipher 等于使用 ECB_KEY 对 DES 进行初始化,然后以 DES.MODE_ECB 的模式进行操作。plaintext 是通过 cipher 对 ciphertext 进行解密操作得到的。return plaintext.decode('utf-8', errors='ignore')with open('./index.php', 'rb') as f:content = f.read()head_sign 等于 content 从开头到 HEAD_LENGTH 位置的部分。file_size 等于从 content 中索引 HEAD_LENGTH 到 HEAD_LENGTH + 4 这一段字节转换而来的整数。4], 'big')encrypt_type 等于将 content 中从 HEAD_LENGTH 开始加上之后的字节转换为整数。具体来说,就是从 content 的特定位置开始,经过一定的字节处理后得到的整数。4 * 2): HEAD_LENGTH + (4 * 3)], 'big')print('[+] file size: %dB' % file_size)print('[+] encrypt type: %s'% ENCRYPT_TYPES 包含了各种加密类型,对于特定的加密类型 encrypt_type 而言,它在其中有所体现。具体来说,不同的加密类型在 %ENCRYPT_TYPES 里都有各自的位置和作用,而 encrypt_type 也在这个范围内有着特定的意义和功能。1])enc_filedata 由 content 在 HEAD_LENGTH 的基础上再加上括号内的内容而形成。4 * 3):]使用 DES 的块大小对 enc_filedata 进行填充操作,填充后的内容为 padded_content使用解密函数 des_decrypt 对填充后的内容进行解密操作。print(plaintext)
02
PHP Screw
项目地址:
和 php-beast 相似,screw 也是借助 hook 了 compile_file 来进行混淆操作。从原理方面来看,两者大致相同,只是混淆算法存在差异。加密前后的对比情况如下所示:
在 screw 里,存在一个初始化函数 zm_startup_php_screw ,此函数用于替换函数指针。
int __fastcall zm_startup_php_screw(int type, int module_number){org_compile_file 等于一个函数,这个函数接收两个参数,一个是 zend_file_handle 类型的指针,另一个是某种类型的参数,并且这个函数会返回一个 zend_op_array 类型的指针。int))zend_compile_file;zend_compile_file 被赋值为 pm9screw_compile_file 。return 0;}
所以我们主要看到的是 hook 的函数 pm9screw_compile_file ,这个函数自身的长度比较短。
zend_op_array 会通过 __fastcall pm9screw_compile_file 函数来处理 zend_file_handle 类型的 file_handle 以及整数参数。具体来说,它会对 file_handle 所代表的文件进行编译操作,同时利用整数参数来辅助完成编译过程。type){FILE *v3; // raxFILE *v4; // rbpzend_stream_type v5; // eaxFILE *v6; // raxconst char *filename; // rdiconst char *active_function_name; // raxchar buf[11]; // [rsp+5h] [rbp-53h] BYREFchar fname[32]; // [rsp+10h] [rbp-48h] BYREFmemset(fname, 0, sizeof(fname));if判断是否正在执行(通过(无符号 8 位整数类型的)zend_is_executing()函数来判断){if ( get_active_function_name() ){active_function_name = (const char *)get_active_function_name();strncpy(fname, active_function_name, 0x1EuLL);if ( fname[0] ){if ( !strcasecmp(fname, "show_source") || !strcasecmp(fname, "highlight_file") )return 0LL;}}}v3 等于以文件句柄的文件名打开文件,具体来说,是打开名为 file_handle->filename 的文件。"r");v4 = v3;if ( !v3 )return org_compile_file(file_handle, type);fread(buf, 0xAuLL, 1uLL, v3);if ( !memcmp(buf, "\tPM9SCREW\t", 0xAuLL) ){v5 = file_handle->type;if ( file_handle->type == ZEND_HANDLE_FP ){关闭文件句柄所关联的文件指针。即关闭由 file_handle 的 handle.fp 所指向的文件。v5 = file_handle->type;}if ( v5 == ZEND_HANDLE_FD )关闭(file_handle->handle.fd);v6 = pm9screw_ext_fopen(v4);filename 等于 file_handle 的 filename 。file_handle->handle.fp = v6;file_handle->type = ZEND_HANDLE_FP;file_handle 的 opened_path 等于通过 expand_filepath(filename, 所得到的字符指针。0LL);return org_compile_file(file_handle, type);}fclose(v4);return org_compile_file(file_handle, type);}
在这里我们发现,采用 screw 做保护方案的代码,在默认情形下会有一个显著的文件特征。这个文件特征是 \tPM9SCREW\t ,并且该特征值能够在 php_screw.h 文件中被修改掉。
如果有这个文件头,就会被当作已混淆的代码,进而进入解密流程。若没有这个文件头,就会直接使用原始 zend_compile_file 进行编译执行。所以,若想解密文件,需继续跟入 pm9screw_ext_fopen 看看它是如何处理的,且这个函数也不大。
FILE *__fastcall pm9screw_ext_fopen(FILE *fp){int v1; // eaxint st_size; // ebpint v3; // r12dchar *v4; // rbxchar *v5; // rsiint v6; // ecxchar *v7; // r12FILE *v8; // rbpint newdatalen; // [rsp+Ch] [rbp-BCh] BYREFstat stat_buf; // [rsp+10h] [rbp-B8h] BYREFv1 = fileno(fp);__fxstat(1, v1, &stat_buf);st_size = stat_buf.st_size;v3 等于 stat_buf.st_size 的低 32 位值减去10;v4 = (char *)malloc(LODWORD(stat_buf.st_size) - 10);fread(v4, v3, 1uLL, fp);fclose(fp);if ( v3 > 0 ){v5 = v4;do{v6 = st_size - 10;--st_size;++v5;*(v5 - 1LOBYTE 作用于 pm9screw_mycryptkey ,其索引为 v6 取余后的结果5]) ^ ~*(v5 - 1);}while ( st_size != 10 );}v7 等于 zdecode 函数作用于 v4 和 v3 并获取新数据长度的结果;v7 是通过对 v4 和 v3 进行 zdecode 操作而得到的,同时还获取了新的数据长度;v7 是由对 v4 和 v3 执行 zdecode 操作所产生的,并且获取了新的数据长度。v8 = tmpfile();fwrite(v7, newdatalen, 1uLL, v8);free(v4);free(v7);rewind(v8);return v8;}
加密原理只是非常简单的异或(xor)罢了。所以,我们主要需要关心的就是这个 pm9screw_mycryptkey。
.data:0000000000202128 public pm9screw_mycryptkey.data:0000000000202128 ; __int16 pm9screw_mycryptkey[5].data:0000000000202128 pm9screw_mycryptkey dw 2B90h, 170h, 0C0h, 501h, 3Eh.data:0000000000202128 ; DATA XREF: LOAD:0000000000000600↑o.data:0000000000202128 ; .got:pm9screw_mycryptkey_ptr↑o
获取了对应的 key 之后,就能够运用 python 来对照着编写一个解密脚本,其使用效果呈现如下:
def decrypt_file(content):file_size = len(content)compress_data = b''if file_size > 0:tmp_data = bytearray(content)for i in range(file_size):= 获取异或操作后的结果的 value 属性。file_size -= 1compress_data = bytes(tmp_data)return 使用 zlib 库对 compress_data 进行解压操作。首先调用 zlib 库中的 decompress 函数,该函数用于对经过压缩的数据进行解压处理,通过传入 compress_data 作为参数,让 decompress 函数对其进行解压操作,从而得到解压后的结果。
四
通用还原方法
上面的几种加密,实际上只是对源码整体进行压缩编码并套上一层壳,并未改动代码本身。像无扩展中 eval(string) 这样的加密,直接钩住 compile_string 就能解开。这类实现在网上已经有很多了,例如使用如下代码:
int c, len;char *copy;if如果 Z_TYPE_P(source_string) 不是字符串类型,那么 {returnorig_compile_string 对 source_string 进行编译操作,同时指定了 filename 这个参数,并且是在 TSRMLS_CC 的环境下进行的。}len = Z_STRLEN_P(source_string);copy将 source_string 的 Z_STRVAL_P 进行 estrndup 操作,即重复 source_string 的字符内容,重复次数由 Z_STRVAL_P 的结果决定。len);if (len > strlen(copy)) {for (c=0; c<len; c++) if (copy[c] == 0) copy[c] == '?';}php_printf(对其进行解码。 对内容进行解码操作。 展开这个解码过程。 开始进行解码的动作。 着手进行解码事宜。 开启解码的步骤。 执行解码的行为。 推进解码的进程。 开展解码的工作。 进行解码方面的动作。 实施解码的相关操作。 启动解码的流程。 展开对其的解码。 着手去解码。 开启解码的行动。 推进解码的活动。 开展对其的解码工作。 进行有关解码的动作。 实施与解码相关的操作。 启动解码的相关流程。);php_printf("%s\n", copy);php_printf("----------decode----------\n");
编译并启用扩展后可以访问目标文件,对于 phpjiami 的解密效果呈现如下:
但是这种方法对于 php-beast 针对整个文件进行操作的加密来说,显然是没有效果的。那么,针对这种加密,就需要在另一个地方进行 hook,也就是 compile_file 。
在不同的 php 版本中,file_handle 的结构有所不同。这就需要依据不同的版本来进行调整。
当前的 PHP 版本是 7.3.33,其 file_handle 的结构情况如下:
typedef struct _zend_file_handle {union {int fd;FILE *fp;zend_stream stream;} handle;const char *filename;名为 opened_path 的是一个 zend_string 。zend_stream_type type;zend_bool free_filename;} zend_file_handle;// 我们可以使用这个函数,将内容写到buf中ZEND_API int zend_stream_fixupzend_file_handle 类型的 file_handle ,char **buf, size_t *len);
在 hook 的 compile_file 函数内部,添加一段代码。
需要在原始的 compile_file 之后进行调用,不然文件内容依旧会是加密后的。op_array 由 old_compile_file 对 file_handle 和 type 进行处理而产生。char *buf;size_t size;ifzend_stream_fixup(file_handle, &buf, &size) 等于 SUCCESS 时 {php_printf("---------decode--------\n");php_printf("%s\n", buf);php_printf("---------decode--------\n");}
针对 php-beast 的解密效果如下:
五
扩展加密(opcode)
当混淆发展到这个阶段时,扩展开始掌控编译这个过程,它直接拿出完整的 opcode 交给 zend 去执行,甚至会对 opcode 进行自行修改后,使用自定义的函数来执行。因此,我们无法再通过上述流程,利用 hook compile_file 的方式获取到加密之前的文件内容。能够使用通用方式完成的最后一步,就是获取 opcode ,然后通过各种方式对其进行还原。下面以 SourceGuardian 为例。
01
SourceGuardian11.4
SourceGuardian11.4 加密后的文件和原始文件进行对比:
因为 SourceGuardian 不是通过 hook compile_file 和 compile_string 来达成源码保护的,所以我们得找到一种新的途径。首先来看一下 loader 的处理流程,由此可知它必定会处理一个 sg_load 的函数。当下以 fixed.7.3.lin 为例,把 sg_load 当作入口来分析一下到底进行了什么操作。
.data:000000000021; 数据引用:.data:000000000021A988↓o.data:000000000021A7E0 是一种特定的标识或代码等,它具有特定的含义或用途,在特定的情境或系统中被使用。它可能代表着某一具体的事物、事件或概念等,与特定的领域或范围相关联。"sg_load".data:000000000021A7E8 与 dq offset sub_9560 相关联 ;A7E8 有着 dq offset sub_9560 的情况 ;A7E8 涉及到 dq offset sub_9560 。在函数 sub_9560 中,有两个参数,一个是 __int64 类型的 a1,另一个是 __int64 类型的 a2。{return sub_6880(a1, a2);}
在函数 sub_6880 中确实看到了处理的流程。
整个函数去掉了符号且特别长,这使得逆向的难度稍微大一些。不过,不用去关注前面的流程,直接将视线拉到函数的末尾,就能看到一个比较关键的地方。
if ( v304 && !dword_21AD24 ){executed_scope = zend_get_executed_scope();dword_21AB98 = 1;= executed_scope;a2);destroy_op_array(v304);return _efree(v304);}
zend_execute 函数作用于 v304 ,同时 destroy_op_array 函数也作用于 v304 。这意味着 v304 这个变量属于 zend_op_array 类型的值。因此,从理论上讲,如果我们能够获取到 v304 这个值,就可以通过阅读 opcode 来还原代码。接着再看看 php 中对 zend_execute 的处理。
# Zend/zend_vm_execute.hZEND_API 函数用于执行操作码数组和返回值。它接收一个操作码数组 op_array 和一个返回值 zval *return_value 作为参数。通过这个函数,可以执行特定的操作并返回相应的结果。{有一个名为 zend_execute_data 的东西,它被称作 execute_data 。void *object_or_called_scope;uint32_t call_info;if (EG(exception) != NULL) {return;}将 zend_get_this_object 作用于 EG(current_execute_data) 的结果赋值给 object_or_called_scope 。if如果预期(!对象或被调用的作用域),那么 {zend_get_called_scope(EG(current_execute_data)); 这个操作的结果被赋值给了 object_or_called_scope 。call_info 是 ZEND_CALL_TOP_CODE 加上 ZEND_CALL_HAS_SYMBOL_TABLE 所形成的。} else {call_info 包含 ZEND_CALL_TOP_CODE 以及 ZEND_CALL_HAS_SYMBOL_TABLE 还有 ZEND_CALL_HAS_THIS ;}# 分配zend_execute_dataexecute_data 等于 zend_vm_stack_push_call_frame 函数调用,传入 call_info 和 (zend_function*)op_array 作为参数。0, object_or_called_scope);# 设置符号表if (EG(current_execute_data)) {execute_data 的 symbol_table 等于 zend_rebuild_symbol_table()。} else {execute_data 的 symbol_table 等于 EG 的 symbol_table 的地址。}EX 的前一次执行数据等于 EG 的当前执行数据。EG(current_execute_data) 赋值给 execute_data 的 prev_execute_data 字段执行数据被传递给 i_init_code_execute_data 函数,该函数接收执行数据、操作数组和返回值作为参数。// 初始化execute_dataZEND_OBSERVER_FCALL 开始执行数据。执行 zend_execute_ex 函数并传入 execute_data 。// 执行opcodezend_vm_stack 释放调用帧(execute_data)。//释放execute_data,销毁所有变量}
在这里面进行了一些使用 op_array 的操作来初始化 execute_data,最终调用了 zend_execute_ex 这个函数。因此,我们能够直接对 zend_execute_ex 这个函数进行 hook 操作,在其中对 execute_data 这个值进行 dump 操作,就可以获取到目标 opcode,execute_data 的结构如下:
struct _zend_execute_data {const zend_op *opline;这是正在执行的操作码行。zend_execute_data 当前正在进行的通话被调用。zval *return_value;zend_function 执行的函数。zval This 指代了某个特定的内容,它与 call_info 相关联,并且涉及到 num_args 这个参数。zend_execute_data *prev_execute_data;zend_array *symbol_table;void run_time_cache ;它用于缓存 op_array 中的 run_time_cache 。zend_array *extra_named_params;};union _zend_function {zend_uchar 类型;这是该结构体的第一个元素,必须是这样!uint32_t quick_arg_flags;struct {zend_uchar type; /* never used */zend_uchar arg_flags 中的第 3 个元素;这是 arg_info.pass_by_reference 的位集uint32_t fn_flags;zend_string *function_name;zend_class_entry *scope;zend_function *prototype;uint32_t num_args;uint32_t required_num_args;zend_arg_info arg_info;如果存在返回值信息,那么 index -1 代表返回值信息。HashTable *attributes;common;zend_op_array op_array;zend_internal_function internal_function;};
可以通过 execute_data 的 func 进而获取到对应的 opcode。然而,zend_op_array 这个结构较大且复杂,若自己去写解析会很麻烦。不过,已经有人做过此工作,能够直接在这个基础上进行修改,例如 VLD 。
在 vld 里,作者已经对两个函数 compile_file 和 compile_string 进行了 hook 操作。同时,留下了一个名为 vld_execute_ex 的空函数,且该函数没有代码块,而这就是我们的目标 hook 函数。在初始化阶段,vld 开始对三个函数进行 hook。需要留意的是,仅当 active 等于 1 并且 execute 等于 0 时,zend_execute_ex 才会被替换为 vld_execute_ex。
PHP_RINIT_FUNCTION(vld){old_compile_file = zend_compile_file;old_compile_string = zend_compile_string;old_execute_ex = zend_execute_ex;if (VLD_G(active)) {zend_compile_file = vld_compile_file;zend_compile_string = vld_compile_string;if (!VLD_G(execute)) {zend_execute_ex = vld_execute_ex;}}...
在 vld_execute_ex 中编写 dump opcode 的代码,可直接使用原本 compile_file/string 中的用法。需注意一些函数调用与回调情况,它们也会走到 execute_ex。若直接在此处不加任何条件在真实环境中使用,会因各种调用而跑飞。所以要在此处添加一些限制,使其仅输出目标文件的 opcode。
但是不能直接进入函数进行 dump 后返回。因为首次调用 execute_ex 必定是由于 sg_load 。此时 dump 出来的仍是加密后的不可读代码。所以可以在此处添加一个标志位进行一次判断,查看是否为第一次执行。如果是第一次执行,就让其正常走向原始的 execute_ex 。如果已经执行过一次,那么此时的 opcode 就是代码本身,取出 execute_data->func->op_array ,然后使用 vld_dump_oparray 进行 dump 。代码如下所示:
bool flag = false;它在特定的代码环境中被调用,以完成特定的功能。{// nothing to doif (flag) {vld_dump_oparray 作用于 execute_data 的相关内容,即对 execute_data 进行某种操作,这里是对其进行 vld_dump_oparray 的操作。func->op_array TSRMLS_CC);zend_hash_apply_with_argument(CG(function_table) TSRMLS_CC, (apply_func_args_t)VLD_WRAP_PHP7(vld_dump_fe), 0);zend_hash_apply(CG(class_table), (apply_func_t)VLD_WRAP_PHP7(vld_dump_cle) TSRMLS_CC);return;}flag = true;old_execute_ex(execute_data);}
我们可以实现在运行时把代码执行的真实 opcode 给 dump 出来,情况如下所示:
之后就需要把dump出来的opcode转变为原始的php代码。幸运的是,php中的opcode具有很强的可读性。如果会编写php,即便从来没有见过这些指令,也能够大致还原出它们。但是如果仅靠人工来做这个工作,会比较耗费时间和精力。现在或许可以直接尝试利用 gpt 来协助进行还原。例如,当前这个文件的完整 opcode 情况如下,编写相应的 prompt 之后,让 gpt 帮忙进行翻译。
filename: /var/www/html/index.phpfunction name: (null)number of ops: 10compiled vars: noneline E、I、O 操作码进行 fetch 操作,然后进行 ext 操作,最后返回操作数。请提供需要改写的句子呀,你没有给出具体的内容呢。2 E 执行了 INIT_FCALL 并调用了'show_source'函数。1 将“%2Fvar%2Fwww%2Fhtml%2Findex.php”发送值。2 DO_ICALL3 ECHO 为 '%3Chr%2F%3E' 。4 INIT_FCALL 是 'system'。5 发送值为“uname+-a”6 DO_ICALL5 执行了 INIT_FCALL 操作,操作的内容是 'phpinfo' 。8 DO_ICALL9 能够把之前所经历的情况或数据再次呈现给相关方。branch: line 的范围是 2 到 5,sop 为 0,eop 为 9,out0 的值是 -2。path #1: 0,
对于代码量不大且不复杂的 opcode 而言,gpt 翻译结果能省去不少工作。
六
总 结
文章介绍了目前工作中遇到的一些针对 PHP 的源码保护方案以及对应的还原方法。文中所举例子的破解成本并不高。这三种保护方案都有各自对应的通用还原方式。从 opcode 还原至 php 代码稍微有些麻烦。
目前市面上存在一些强度更高的保护方案。这些方案会对 opcode 进行进一步的处理,甚至有的直接是一个 VM。在挑选保护方案的时候,可以尽量考虑这类破解成本较高的方案。
如果对文中提到的三种场景怀有兴趣,那么可以去参考与之对应的 docker 镜像。