前言

上个星期碰到个客户使用Swoole Compiler加密Drupal导致Drupal项目无法运行的问题,逐步排查后总结问题是Drupal中有部分代码直接通过file_get_contents获取PHP源码导致的,因为项目代码是加密过后的,所以直接获取PHP源码解析是获取不到想要的内容的。
注:

  • Swoole Compiler:https://www.swoole-cloud.com/compiler.html
  • Drupal是使用PHP语言编写的开源内容管理框架(CMF),它由内容管理系统(CMS)和PHP开发框架(Framework)共同构成。

加密后的影响Drupal运行的主要代码

代码路径

drupal/vendor/doctrine/common/lib/Doctrine/Common/Reflection/StaticReflectionParser.php:126

    //代码内容    protected function parse()    {        if ($this->parsed || !$fileName = $this->finder->findFile($this->className)) {            return;        }        $this->parsed = true;        $contents = file_get_contents($fileName);        if ($this->classAnnotationOptimize) {            if (preg_match("/\A.*^\s*((abstract|final)\s+)?class\s+{$this->shortClassName}\s+/sm", $contents, $matches)) {                $contents = $matches[0];            }        }        $tokenParser = new TokenParser($contents);        ......    }

其中部分代码如上,通过class名获取文件路径,然后通过file_get_contents获取PHP文件的内容,其中TokenParser类中构造函数如下

    public function __construct($contents)    {         $this->tokens = token_get_all($contents);         token_get_all("<?php\n/**\n *\n */");         $this->numTokens = count($this->tokens);    }

传入获取到的源码通过token_get_all进行解析,然后后续分析代码获取PHP文件的类、属性、方法的注释 ,父类的命名空间 和class名 ,本类的use信息等,因为文件已经加密,所以file_get_contents获取到的内容是加密后的内容,token_get_all就解析不到正确的信息,从而导致程序无法运行。

解决方案

本次使用的2.1.1版本的加密器,通过Swoole Compiler加密器加密的代码,在配置文件中save_doc配置选项必须设置为1,如果设置为0则不会保存注释,并且在2.1.3版本swoole_loader.so扩展中新增加的函数naloinwenraswwww也无法获取到类中use的相关信息,具体函数使用在后面会详细说明。
  1    $ref = new \ReflectionClass($this->className);  2  3    $parent_ref = $ref->getParentClass();  4  5    ......  6  7    if (is_file($fileName)) {  8        $php_file_info = unserialize(naloinwenraswwww(realpath($fileName)));  9        foreach ($php_file_info as $key => $info) {  10           if ($key == 'swoole_namespaces' || $key == 'swoole_class_name') {  11               continue;  12           }  13           $this->useStatements[$key] = $info;  14       }  15   }  16  17   $this->parentClassName = $parent_ref->getName();  18  19   if (strpos($this->parentClassName, '\\')!==0) {  20       $this->parentClassName = '\\'.$this->parentClassName;  21   }  22  23   $static_properties = [];  24  25   $properties = $ref->getProperties();  26  27   $parent_properties = $this->createNewArrKey($parent_ref->getProperties());  28  29   ......  30  31   $static_methods = [];  32  33   $methods = $ref->getMethods();  34  35   ......
  1. 第1行通过类名来获取反射类ReflectionClass类的对象。
  2. 因为此反射类包含了所有父类中的属性和方法,但源码中只要获取本类中的属性和方法,所以还要获取父类的反射类然后通过对比来剔除父类中的属性和方法,第3行使用ReflectionClass类提供的getParentClass方法获取父类的反射类,此方法返回父类的ReflectionClass对象。
  3. 第25行通过ReflectionClass类提供的getProperties方法分别获取本类和父类中的属性,然后进行对比剔除父类的属性,保留本类的属性,此方法返回的是一个ReflectionProperty类对象。
  4. 通过ReflectionProperty类提供的getDocComment方法就可以拿到属性的注释。
  5. 同上第33行通过ReflectionClass类提供的getMethods方法可以拿到本类和父类中的方法,然后进行对比剔除父类的方法,保留本类的方法,此方法返回的是一个ReflectionMethod类对象。
  6. 通过ReflectionMethod对象提供的getDocComment方法就可以拿到方法的注释。
  7. 通过第17行ReflectionClass提供的getName方法可以拿到类名。
因为反射无法获取use类的信息,所以在2.1.3版本中的swoole_loader.so扩展中添加函数naloinwenraswwww,此函数传入一个PHP文件的绝对路径,返回传入文件的相关信息的序列化数组,反序列化后数组如下
    [        "swoole_namespaces" => "Drupal\Core\Datetime\Element",        "swoole_class_name" => "Drupal\Core\Datetime\Element\DateElementBase",        "nestedarray" => "Drupal\Component\Utility\NestedArray",        "drupaldatetime" => "Drupal\Core\Datetime\DrupalDateTime",        "formelement"=> "Drupal\Core\Render\Element\FormElement"    ]

其中swoole_namespaces为文件的命名空间,swoole_class_name为文件的命名空间加类名,其他为use信息,键为use类的类名小写字母,如存在别名则为别名的小写字母,值为use类的命名空间加类名,通过该函数和反射函数可以兼容StaticReflectionParser中加密后出现的无法获取正确信息的问题

在加密后的未影响Drupal运行的潜在问题:

  • 代码路径:drupal/vendor/doctrine/annotations/lib/Doctrine/Common/Annotations/PhpParser.php:39
  • 代码路径:drupal/vendor/symfony/class-loader/ClassMapGenerator.php:91
  • 代码路径:drupal/vendor/symfony/routing/Loader/AnnotationFileLoader.php:90
Drupal中引入了Symfony框架,此框架中部分代码也是通过file_get_contentstoken_get_all来获取PHP文件的类名,但目前未对Druapl运行产生影响,可能并未用到其中方法

解决方案:

StaticReflectionParser类的解决方案一样通过2.1.3版本中的swoole_loader.so扩展中添加函数naloinwenraswwww来获取加密后文件的命名空间和类名

尚未有更好方案的问题:

  • 代码路径:drupal/core/includes/install.inc:220
    function drupal_rewrite_settings($settings = [], $settings_file = NULL)    {        if (!isset($settings_file)) {            $settings_file = \Drupal::service('site.path') . '/settings.php';        }        // Build list of setting names and insert the values into the global namespace.        $variable_names = [];        $settings_settings = [];        foreach ($settings as $setting => $data) {            if ($setting != 'settings') {                _drupal_rewrite_settings_global($GLOBALS[$setting], $data);            } else {                _drupal_rewrite_settings_global($settings_settings, $data);            }            $variable_names['$' . $setting] = $setting;        }        $contents = file_get_contents($settings_file);        if ($contents !== FALSE) {            // Initialize the contents for the settings.php file if it is empty.            if (trim($contents) === '') {                $contents = "<?php\n";            }            // Step through each token in settings.php and replace any variables that            // are in the passed-in array.            $buffer = '';            $state = 'default';            foreach (token_get_all($contents) as $token) {                if (is_array($token)) {                    list($type, $value) = $token;                } else {                    $type = -1;                    $value = $token;                }                // Do not operate on whitespace.                if (!in_array($type, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT])) {                    switch ($state) {                        case 'default':                            if ($type === T_VARIABLE && isset($variable_names[$value])) {                                // This will be necessary to unset the dumped variable.                                $parent = &$settings;                                // This is the current index in parent.                                $index = $variable_names[$value];                                // This will be necessary for descending into the array.                                $current = &$parent[$index];                                $state = 'candidate_left';                            }                            break;                        case 'candidate_left':                            if ($value == '[') {                                $state = 'array_index';                            }                            if ($value == '=') {                                $state = 'candidate_right';                            }                            break;                        case 'array_index':                            if (_drupal_rewrite_settings_is_array_index($type, $value)) {                                $index = trim($value, '\'"');                                $state = 'right_bracket';                            } else {                                // $a[foo()] or $a[$bar] or something like that.                                throw new Exception('invalid array index');                            }                            break;                        case 'right_bracket':                            if ($value == ']') {                                if (isset($current[$index])) {                                    // If the new settings has this index, descend into it.                                    $parent = &$current;                                    $current = &$parent[$index];                                    $state = 'candidate_left';                                } else {                                    // Otherwise, jump back to the default state.                                    $state = 'wait_for_semicolon';                                }                            } else {                                // $a[1 + 2].                                throw new Exception('] expected');                            }                            break;                        case 'candidate_right':                            if (_drupal_rewrite_settings_is_simple($type, $value)) {                                $value = _drupal_rewrite_settings_dump_one($current);                                // Unsetting $current would not affect $settings at all.                                unset($parent[$index]);                                // Skip the semicolon because _drupal_rewrite_settings_dump_one() added one.                                $state = 'semicolon_skip';                            } else {                                $state = 'wait_for_semicolon';                            }                            break;                        case 'wait_for_semicolon':                            if ($value == ';') {                                $state = 'default';                            }                            break;                        case 'semicolon_skip':                            if ($value == ';') {                                $value = '';                                $state = 'default';                            } else {                                // If the expression was $a = 1 + 2; then we replaced 1 and                                // the + is unexpected.                                throw new Exception('Unexpected token after replacing value.');                            }                            break;                    }                }                $buffer .= $value;            }            foreach ($settings as $name => $setting) {                $buffer .= _drupal_rewrite_settings_dump($setting, '$' . $name);            }            // Write the new settings file.            if (file_put_contents($settings_file, $buffer) === FALSE) {                throw new Exception(t('Failed to modify %settings. Verify the file permissions.', ['%settings' => $settings_file]));            } else {                // In case any $settings variables were written, import them into the                // Settings singleton.                if (!empty($settings_settings)) {                    $old_settings = Settings::getAll();                    new Settings($settings_settings + $old_settings);                }                // The existing settings.php file might have been included already. In                // case an opcode cache is enabled, the rewritten contents of the file                // will not be reflected in this process. Ensure to invalidate the file                // in case an opcode cache is enabled.                OpCodeCache::invalidate(DRUPAL_ROOT . '/' . $settings_file);            }        } else {            throw new Exception(t('Failed to open %settings. Verify the file permissions.', ['%settings' => $settings_file]));        }    }

Drupal安装过程中有个配置文件default.setting.php,里面存放了默认配置数组,在安装的过程中会让用户在安装界面输入一些配置比如Mysql的信息,输入过后此方法通过file_get_contentstoken_get_all来获取setting中的信息,然后合并用户在页面输入的信息,重新存回文件,因为整个过程涉及到读取文件,更改文件信息,在存入文件,所以Swoole Compiler在此处暂时没有更好的解决方案,需要在加密的时候选择不加密setting文件。

代码路径:drupal/vendor/symfony/class-loader/ClassCollectionLoader.php:126
此类中是Symfony读取PHP文件然后作相应处理后缓存到文件中,存在和上面代码同样的问题,暂未找到更好的解决方案