PHP是动态解析型语言即代码不需要编译运行,因不利于代码保密所以在一些商业会将代码进行加密处理,尽可能的保护知识产权,是一种合理合法的做法。

特别说明:以下解密思路及方法仅用于学习与研究,所有解密只针对能正常运行的加密PHP代码解密


PHP代码加密

PHP加密代码分三种:PHP代码模式、PHP内核编译扩展模式、扩展编译模式。

扩展加密模式性能和保密性好,保密性最好的是非开源扩展编译模式(即解密后的代码不经过PHP内核编译在扩展中完成编译解释),源代码被编译为特定字节码无法通过内核解密,这种基本上只能反编译扩展逆向找出对应字节码处理结构才方便解密(除非有解密扩展源码或算法)。PHP允许扩展改变编译和执行处理器,从内核上方便了扩展进行更复杂自由的加解密处理。

在加密的过程中还有一类将源代码命名或结构进行混淆优化处理,这种方式会破坏源码并且能压缩源码量,类似js和java常量的压缩混淆功能,实际混淆后的代码也能通过阅读进行理解,混淆严格来说不算加密。这里仅以上面三种模式为主进行说明。

解密方式这里只提供两种模式,一种是修改PHP内核,一种是通过阅读PHP解密。

逆向反编译解密需要一定的汇编和反编译阅读能力,一般使用IDA pro或ghidra工具进行反编译,后者是java开发的开源免费工具。反编译后的C代码与源码样貌相差很大,变化有:函数名是前缀+地址、函数参数名是前缀+序号、变量及常量名是前缀+序号或地址、结构体基本无法还原、部分代码无法反编译、代码结构存在部分区别等;无变化有:函数指针名、外部函数名、外部变量名、对外函数或变量名、逻辑结构等。可以理解为反编译出来的C代码只作为分析参考难以直接用来再编译生成对应的二进制执行文件进行替换。


PHP代码模式

将php源码进行复杂转换生成一个编码串连同解密PHP代码一并发布使用。解密代码一般处理层次很深多数将变量或函数等命名改为特殊字符(非字母数据汉字之类字符),增加阅读和解密难度。

这种加载模式一般有两种加密结构:依赖eval运行源码结构、解密同步执行源码结构。两种结构对外发布的代码结构极其相似,一般多以独立文件出现。

代码示例截图:

PHP加密代码及部分解密处理_php

优点:不需要专用解密PHP扩展,只要在支持的PHP版本下运行均可,通用兼容性好。

缺点:代码实际是明文传播,容易解密且性能较差。

解密:通过开发工具+脚本替换也能解密(需要一定阅读程序功底),修改PHP内核还能自动解密(需要能正常运行加密代码且必需是eval结构)。


依赖eval运行源码结构

源码解密出需要通过eval执行,源码逻辑不参与到解密结构中,解密后源码的命名及结构不受变化。这种结构的加密可通过修改PHP内核或阅读php代码解密,其中通过修改内核解密是最快最可靠。

这种加密文件一般是独立文件运行并发布多见。


解密同步执行源码结构

源码与解密代码混合在一起运行,解密代码很少运行eval,源码基本无法解密出原来的样式(比如:注释格式之类会丢失)。这种结构的加密只能通过阅读php代码截取源码运行部分,解密后可手动或编写程序自动生成源码文件。

这种加密文件一般是一个系统中部分文件加密多见,通过加密部分核心文件来控制敏感代码不被修改,多数不能独立运行。


PHP内核编译扩展模式

多数是开源扩展,源码对外公开(配置文件多使用 extension 的PHP扩展),在扩展中处理代码的加解密,解密后再将PHP源码送入PHP内核编译处理器,随即送到执行处理器运行。运行加密的PHP代码前必需安装解密扩展,扩展内可提供缓存等措施性能要比eval方式快。

优点:性能好,非明文传播代码,解密难度大,通用兼容性好。

缺点:依赖专用扩展,解密难度适中。

解密:通过修改PHP内核(编译解释器部分)或扩展库解密部分(需要能正常运行加密代码)


扩展编译模式

一般是商业PHP加密扩展(配置文件多使用 zend_extension 的Zend扩展),扩展内通过替换掉php内核编译解释器和执行处理器,以动态库的方式发行(即不开源扩展代码)。因解析器在各PHP版本中语法结构略有差异,所以不同版本的PHP需要不同版本的解密扩展。

代码示例截图:

PHP加密代码及部分解密处理_反编译_02

优点:性能好,非明文传播代码,解密难度最大。

缺点:依赖专用版本扩展,通用兼容性差,多数收费(加密部分)。

解密:可通过逆向反编译so或dll动态库分析解密逻辑(暂未完成解密,初步解密方向:逆向提取解密算法、逆向通过执行数据合成源代码)。


修改PHP内核解密加密代码

PHP内核编译解释器(zend_compile_file和zend_compile_string)和执行处理器处理器(zend_execute_ex)使用的是函数指针,即允许通过扩展方式修改指针对应的处理函数,opcache扩展也是通过修改编译解释器处理函数指针到达劫持并缓存编译效果。

通过截取编译解释器能直接提取PHP源码,如果扩展中除了解密还需要专用编译解释器则截取无效(即扩展编译模式无效)。通过截取执行处理器也能拼接出PHP源代码,如果扩展中使用了专用执行处理器则无法拼接。

截取编译解释器能快速直接获取PHP源代码,仅针对PHP内核编译扩展模式。


php代码编译解释器

php代码编译解释器处理函数使用的是定义为函数指针在 Zend/zend_compile.c 文件( Zend/zend_compile.h 头文件也有定义方便扩展引用 )

// 文件编译函数指针定义
ZEND_API zend_op_array *(*zend_compile_file)(zend_file_handle *file_handle, int type);

// 字符串语句编译函数指针定义,给PHP的eval之类语句使用
ZEND_API zend_op_array *(*zend_compile_string)(zval *source_string, char *filename);

源码截图:

PHP加密代码及部分解密处理_php_03

在Zend/zend.c文件中进行默认函数指定,

// 指定文件编译函数
zend_compile_file = compile_file;

// 指定字符串编译函数
zend_compile_string = compile_string;

源代码截图:(如果开启了调试跟踪功能则使用HAVE_DTRACE区块)

PHP加密代码及部分解密处理_反编译_04

compile_file 和 compile_string 函数定义在Zend/zend_language_scanner.c文件中(各版本PHP内部处理代码不尽相同)但最终 都均调用yy_scan_buffer函数,只要在yy_scan_buffer上增加提取编译代码即可。


PHP源码增加自动解密功能

打开源码文件:Zend/zend_language_scanner.c 增加以下示例代码。

示例代码:(在yy_scan_buffer函数位置处修改)

// 当有编译操作时将调用PHP函数save_php_code,有二个参数:要编译的代码字符串,要编译的文件名(eval时为null)
static void save_php_code(char* str, size_t len)
{
  zval function_name, argv[2], return_value;
  zend_string* func_name = zend_string_init(ZEND_STRL("save_php_code"), 0);
  zend_string* code_str = zend_string_init(str, len, 0);
  zend_file_handle* file_handle = SCNG(yy_in);
  zend_string* compiled_filename = NULL;
  if (file_handle)
  {
    compiled_filename = zend_string_init(file_handle->filename, strlen(file_handle->filename), 0);
    ZVAL_STR(&argv[1], compiled_filename);
  }
  else
  {
    ZVAL_NULL(&argv[1]);
  }
  ZVAL_STR(&function_name, func_name);
  ZVAL_STR(&argv[0], code_str);
  call_user_function(EG(function_table), NULL, &function_name, &return_value, 2, argv);
  zend_string_release(func_name);
  zend_string_release(code_str);
  if (compiled_filename)
  {
    zend_string_release(compiled_filename);
  }
}

// 源码代码,需要增加调用,修改源码时搜索此函数名即可找到定义位置
// save_php_code函数必需放在此函数上面或上面有声明
static void yy_scan_buffer(char *str, unsigned int len)
{
  save_php_code(str, (size_t)len);

  YYCURSOR       = (YYCTYPE*)str;
  YYLIMIT        = YYCURSOR + len;
  if (!SCNG(yy_start)) {
    SCNG(yy_start) = YYCURSOR;
  }
}

源代码截图:

PHP加密代码及部分解密处理_php_05

修改源码后,正常编译安装并配置好相关运行环境即可。

使用PHP代码前准备:(创建test.php文件,写下以下代码)

<?php
// 如果需要解密其它文件,此文件应该为入口文件

// 此函数为必需项,编译PHP代码前内核自动调用
function save_php_code($code, $filename) {
    static $num = 1;
    if($filename) {
        file_put_contents($filename . ".php", $code);
    } else {
        file_put_contents(__DIR__.'/'.$num.'-'.date('Y-m-d').'.php', $code);
        $num++;
    }
}

eval('echo PHP_VERSION;');

// include "引入要解码的PHP文件";

调用准备前代码:(会生成一个 1-Y-m-d.php 的文件,因test.php文件需要编译所有无法提取test.php文件代码,但可以提取include指定后的文件)

php test.php

阅读PHP代码解密

阅读PHP代码解密加密代码需要一定PHP开发功底,通过阅读代码找出需要截取的代码。当加密使用eval时只需要找到eval位置提取参数即可;非eval同步执行模式需要找到源码运行的位置再截取代码(多数是嵌套到在解密函数中调用)。多数加密的代码使用了非正常字符命名严重影响代码阅读,甚至部分开发工具无法正常展示或编译,建议在解密前将这些特殊命名给替换掉再进行阅读编译代码。

这种加密的的核心思想就是尽可能混乱思路,多数使用大量函数进行交叉调用或者使用递归交叉调用;有些调用函数名使用字符串拼装,增加替换难度;有些会将函数名或变量名参与到解密过程中,替换后会导致解密运行失败;还有部分解密程序会校验文件是否被修改,需要找到对应的位置进行截取校验。

直接通过阅读加密代码提取源码不直观,借助代码的正常运行通过加断点分析或截取打印等方式会事半功倍。


替换掉加密代码中一些特殊命名

一般建议使用程序进行替换(比如使用PHP脚本),也可以使用开发工具替换(部分开发工具可能容易替换出问题)。脚本替换快速,但容易替换掉加密串而非变量或函数名(概率很低)。

代码替换非必需项,但替换后代码更容易阅读。当替换后代码不可正常运行时需要逐个排查,比如:函数不存在报错时,可将以下make_name函数复制到替换的文件中,将函数名生成为替换后的函数名;或者解密校验失败终止程序可通过断点进行排查。

示例PHP替换缩进脚本:(保存为replace.php)

#!/usr/bin/env php
<?php
// 指定一个以上要替换的PHP文件,替换后生成一个原文件名.php的文件,注意不要有重名文件。

if ($argc <= 1) {
    die("最少指定一个要替换的PHP文件");
}

array_shift($argv);

ini_set('display_errors', true);
error_reporting(E_ALL);

// 文件替换处理
foreach($argv as $file){
    if (!(file_exists($file) && is_file($file))){
        echo "$file 文件不存在\n";
        continue;
    }
    if (strcasecmp(pathinfo($file, PATHINFO_EXTENSION), 'php') != 0){
        echo "$file 不是有效的PHP文件\n";
        continue;
    }
    // 只处理PHP标签内的代码
    $code = preg_replace_callback('/<\?(php)?((\'([^\'\\\\]+|\\\\.)*\')|("([^"\\\\]+|\\\\.)*")|[^\'"\?]+|\?(?!>))*(\?>)?/i', function(array $matches) {
        // 缩进处理
        $level =[
            'min' => 0,
            'max' => 0,
        ];
        $code = preg_replace_callback('/(\'([^\'\\\\]+|\\\\.)*\')|("([^"\\\\]+|\\\\.)*")|([^\'"]+)/', function(array $matches)use(&$level) {
            if(isset($matches[5])){
                return preg_replace('/\s{4}\}/', '}', preg_replace_callback('/\{|\}|\(|\)|;/', function(array $matches)use(&$level) {
                    $join = false;
                    switch($matches[0]){
                        case '(':
                            $level['min']++;
                            break;
                        case ')':
                            $level['min']--;
                            break;
                        case '{':
                            $level['max']++;
                            $join = true;
                            break;
                        case '}':
                            $level['max']--;
                            $join = true;
                            break;
                        case ';':
                            if($level['min'] <= 0){
                                $join = true;
                            }
                            break;
                    }
                    return $matches[0] . ( $join ? "\n" . str_repeat(" ", max($level['max'], 0) * 4) : '');
                }, $matches[5]));
            }
            return $matches[0];
        }, $matches[0]);
        
        // 变量或函数替换
        $functions = [];
        $code = preg_replace_callback('/(\'([^\'\\\\]+|\\\\.)*\')|("([^"\\\\]+|\\\\.)*")|([^\'"]+)/', function(array $matches)use(&$functions) {
            if(strlen($matches[1])){ // 单引号字符串
                return $matches[0];
            }
            $code = isset($matches[5]) ? $matches[5] : $matches[3];
            // 变量名替换
            $code = preg_replace_callback('/\$([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)/', function(array $matches) {
                if(preg_match('/^\$[a-z_][\w+_]*$/i', $matches[0])){ // 标准参数
                    return $matches[0];
                }
                return '$v_' . make_name($matches[1], 'v');
            }, $code);
            // 函数定义名替换
            $code = preg_replace_callback('/(function(\s+|\s*&\s*))([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)/', function(array $matches)use(&$functions) {
                if(preg_match('/^[a-z_][\w+_]*$/i', $matches[3])){ // 标准参数
                    return $matches[0];
                }
                if (empty($functions[$matches[3]])) {
                    $functions[$matches[3]] = 'f_' . make_name($matches[3], 'f');
                }
                return $matches[1] . $functions[$matches[3]];
            }, $code);
            return $code;
        }, $code);
        
        // 函数调用名替换
        return preg_replace_callback('/(\'([^\'\\\\]+|\\\\.)*\')|("([^"\\\\]+|\\\\.)*")|([^\'"]+)/', function(array $matches)use($functions) {
            // 非字符串或字符串函数名才替换
            if(isset($matches[5]) || (isset($matches[1]) && isset($functions[substr($matches[1], 1, -1)])) || (isset($matches[3]) && isset($functions[substr($matches[3], 1, -1)]))){
                return str_replace(array_keys($functions), array_values($functions), $matches[0]);
            }
            return $matches[0];
        }, $code);
    }, file_get_contents($file));
    // 保存替换后的文件
    file_put_contents($file.'.php', $code);
}

// 转换命名为字母+数字,保证替换前后的命名是一一对应,容易后续再拼装函数名
function make_name($str) {
    static $names = [];
    if (!isset($names[$str])) {
        $name = '';
        for ($key = 0; $key < strlen($str); $key++) {
            $name .= substr('0' . dechex(ord($str[$key])), -2);
        }
        $names[$str] = $name;
    }
    return $names[$str];
}

解密示例

因代码太长仅以部分代码截图为准,主要说明解密过程(加密代码以某开源项目的部分加密代码为例)。

一般解密位置在文件最后几行代码上(函数调用语句),非递归式的解密直接通过断点即可逐个提取,递归式可通过运行代码进行断点截取。

编辑工具:Notepad++ 

特别说明:部分编辑工具在修改带特殊字符的文件后会导致部分特殊字符变动,即破坏代码本意无法进行解密,Notepad++在未编辑非特殊字符区块时能保证代码本意不变(即在特殊字符前后均不建议作换行复制等操作,否则代码运行结果未知且无法正常解密)


依赖eval运行源码结构

加密源文件截图:(文件名 1234.php)

PHP加密代码及部分解密处理_反编译_06

替换缩进(使用上面的替换脚本)

php replace.php 1234.php

替换缩进后文件截图:

PHP加密代码及部分解密处理_php_07

运行下替换缩进后的文件是否正常(最好与原文件运行结果进行对比)

php 1234.php.php

运行结构截图:(无输出,说明替换与缩进暂未异常)

PHP加密代码及部分解密处理_PHP_08

排查是否有校验或是否能正常解密,在解密程序最后一后(结束前一行)增加下输出代码,验证是否能进入,如果不能进入则输出代码向上移动,直到能输出为止,再找到终止的逻辑,如果是条件性die掉则可以做截取处理,保证解密程序最后一行能正常输出增加的指定代码。

修改后的代码:(排查出4个条件die代码,还有一个提取文件代码)

PHP加密代码及部分解密处理_反编译_09

在解密最后几行找到eval函数,初步判断是解密代码

PHP加密代码及部分解密处理_php_10

将eval修改为die运行结果:(使用了原解密变量名)

PHP加密代码及部分解密处理_反编译_11

将变量替换代码嵌入

PHP加密代码及部分解密处理_反编译_12

运行输出:(即获取到要处理的变量名)

PHP加密代码及部分解密处理_PHP_13

将变量名修改到代码中

PHP加密代码及部分解密处理_php_14

运行即获取解密的源码(编码格式差异会异常展示中文异常,可以将die修改为file_put_contents函数写到文件即可获取解密代码)

PHP加密代码及部分解密处理_反编译_15


解密同步执行源码结构

加密源文件截图:(文件名 config.php)

PHP加密代码及部分解密处理_php_16

替换缩进(使用上面的替换脚本)

php replace.php config.php

替换缩进后文件截图:

PHP加密代码及部分解密处理_PHP_17

运行下替换缩进后的文件是否正常(最好与原文件运行结果进行对比)

php config.php.php

运行结构截图:(运行失败,证明有拼装函数名)

PHP加密代码及部分解密处理_php_18

找到函数拼装的位置

PHP加密代码及部分解密处理_反编译_19

将替换脚本的make_name函数复制进来并修改拼装结果

PHP加密代码及部分解密处理_PHP_20

再运行还是报错,但这个错误已经跳出了解密代码范围(解密的变量或函数应该是特殊字符)

PHP加密代码及部分解密处理_PHP_21

这种报错说明加密是:解密同步执行源码结构,并且不可独立运行,需要放在原来的位置运行再逐个解密出源码。解密可在报错的位置进行打印调用的函数及参数,然后放在原来的位置运行,将打印出的结果拼装成一个新文件替换加密文件逐步验证直到完全解密。