
1. 项目概述从一次偶然的代码审计说起那天下午我正像往常一样在本地环境里复现一个CMS系统的后台功能。我手里拿着的是YzmCMS 5.3.0的源码包。这个版本在圈子里流传挺广不少中小型内容站都在用。我的本意是想看看它的“采集节点”功能设计得怎么样毕竟内容采集是很多站长的刚需。然而当我顺着代码逻辑一步步跟下去的时候后背开始有点发凉。一个原本应该用于从互联网上抓取文章、图片的“采集”功能其内部实现逻辑却像一把没有锁的万能钥匙能够从服务器内部向任意内部网络地址发起请求。这不就是典型的SSRFServer-Side Request Forgery服务器端请求伪造漏洞吗而且这个功能点设计得如此“开放”几乎没有任何有效的安全校验让我不得不怀疑这究竟是开发者的无心之失还是一个被精心伪装过的后门SSRF这个漏洞简单来说就是攻击者能够诱使服务器应用程序向攻击者指定的内部或外部地址发起HTTP请求。利用这个漏洞攻击者可以绕过防火墙探测或攻击内网中那些原本无法从外网直接访问的服务比如数据库、Redis、或者管理后台。而“采集节点”功能其核心逻辑正是“根据用户提供的URL去获取内容”。如果这个“用户提供的URL”可以被任意控制那么它就从一把采集数据的“铲子”变成了一根捅向内网的“长矛”。这次审计我们就来彻底拆解YzmCMS 5.3.0中这个危险的“采集节点”看看问题到底出在哪以及如何从根本上防御这类功能滥用。2. 核心漏洞原理与“采集节点”功能拆解要理解这个漏洞我们得先搞清楚YzmCMS的“采集节点”是怎么工作的。在内容管理系统中采集功能通常用于自动化地从指定的新闻源、博客或其他网站上抓取标题、正文、发布时间等信息并自动或半自动地发布到自己的站上。这个过程技术上讲就是由服务器端的脚本PHP模拟一个浏览器去访问目标URL下载网页内容然后通过正则表达式或DOM解析的方式从中提取出需要的数据。2.1 “采集节点”的标准工作流程在YzmCMS的后台添加一个采集节点时你需要填写几个关键信息节点名称 只是一个标识。网站地址 你要采集的目标网站的首页或列表页地址比如http://www.example.com/news/。列表区域规则 告诉系统如何在列表页中找到文章链接的HTML范围。内容页采集规则 告诉系统如何在文章详情页中提取标题、正文等内容。当管理员启动采集任务时系统会执行以下步骤步骤一获取列表。 系统会使用PHP的file_get_contents()、curl或fsockopen等函数去请求“网站地址”对应的列表页。步骤二解析链接。 根据“列表区域规则”从列表页HTML中解析出所有文章详情页的URL。步骤三循环抓取内容。 对于每一个文章URL系统再次发起HTTP请求获取文章页的HTML。步骤四内容提取与入库。 根据“内容页采集规则”从HTML中提取出字段存入数据库。问题就潜藏在步骤一和步骤三。这两个步骤的核心操作是根据一个外部输入的URL字符串发起一个网络请求。2.2 SSRF漏洞是如何产生的SSRF漏洞产生的根本原因在于服务器对用户提供的URL没有进行严格的校验和限制。在YzmCMS 5.3.0的“采集节点”功能相关代码中通常位于类似/application/admin/controller/collection.class.php的文件中我们发现了类似下面的逻辑以下为模拟代码用于说明原理public function get_content() { $url $_POST[url]; // 直接获取用户传入的URL // ... 一些无关的逻辑 ... $html file_get_contents($url); // 关键危险操作 // ... 后续处理 ... }或者在使用cURL时public function collect_url() { $target_url input(param.url); $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $target_url); // URL完全可控 curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); $result curl_exec($ch); curl_close($ch); return $result; }这里的致命问题在于无协议限制 代码没有检查URL的协议。除了常见的http://和https://攻击者可以传入file://协议来读取服务器本地文件如file:///etc/passwd或者传入dict://、gopher://等协议来与内网的其他服务进行交互。无地址过滤 代码没有对URL指向的主机名或IP地址进行过滤。攻击者可以传入内网IP地址例如http://192.168.1.1:8080/admin尝试访问内网的管理系统。甚至可以传入http://127.0.0.1:6379来探测本机是否开放了Redis服务默认端口6379。无权限校验 “采集节点”的配置和触发通常需要管理员权限这看似是一道安全屏障。但现实中管理员账号可能被弱口令、社会工程学等方式攻破。一旦攻击者获得后台权限这个功能就成为了一个现成的、功能强大的内网探测和攻击跳板。注意 即使这个功能藏在后台它依然是一个高危漏洞。安全设计应遵循“纵深防御”原则不能假设后台就是绝对安全的。任何用户输入包括管理员输入都必须经过校验。2.3 为何说它像“后门”一个正常的采集功能应该对目标地址有基本的白名单限制或强校验。例如只允许采集配置时预设的那几个域名。但在此次审计的代码中我们看不到任何此类限制。这种“完全开放”的设计与一个后门的功能特征高度吻合隐蔽性 它是一个合法的、文档记录的功能而非一段隐藏的恶意代码。功能性 它具备发起任意网络请求的能力这正是攻击者渗透内网所需的关键能力。低权限触发 只需要后台一个功能点的操作权限无需系统级或数据库权限。这让我想起了安全界常说的“Living off the Land”——攻击者利用目标系统上合法的工具和功能来实施攻击极难被检测。这个“采集节点”就是一个完美的“Land”工具。3. 漏洞利用场景深度剖析与实战演示理解了原理我们来看看攻击者具体能利用这个漏洞做些什么。假设攻击者已经通过某种方式如弱口令进入了YzmCMS后台并找到了“采集节点”管理页面。3.1 场景一探测服务器本地文件与内网服务这是最基本的利用方式用于信息收集。读取服务器敏感文件攻击者可以在“网站地址”或测试采集的URL输入框中填入file:///etc/passwd。如果服务器PHP配置允许file_get_contents读取本地文件allow_url_fopenOn那么系统就会将/etc/passwd文件的内容作为“网页内容”抓取回来并在采集预览或日志中显示从而泄露系统用户信息。同理可以尝试读取Web应用的配置文件如file:///var/www/html/config/database.php获取数据库密码。探测内网存活主机和端口攻击者可以编写一个简单的脚本或者手动在URL中尝试常见的内部网段地址如http://192.168.1.1http://10.0.0.1http://172.16.0.1等。通过观察系统的响应时间或错误信息是“连接超时”还是“无法获取内容”亦或是“采集成功”可以判断该IP地址是否存在主机。进一步可以拼接端口进行探测例如http://192.168.1.1:8080(可能为Tomcat管理后台)http://127.0.0.1:6379(本地Redis)http://192.168.1.10:8080/manager/html(尝试访问Tomcat管理页面)http://192.168.1.100:9200(探测Elasticsearch服务)3.2 场景二攻击内网脆弱应用如果探测到了内网中存在的脆弱服务攻击就可以升级。攻击未授权访问的Redis如果内网192.168.1.100:6379的Redis服务未设置密码且绑定在0.0.0.0上攻击者可以利用SSRF直接与之交互。虽然HTTP协议无法直接与Redis通信但可以通过dict://或gopher://协议来构造攻击载荷。例如使用dict://协议dict://192.168.1.100:6379/SET mykey “hacked_by_ssrf”。如果服务器支持dict协议这条命令可能会被发送到Redis并执行。更强大的是gopher协议它可以构造任意格式的TCP数据包。攻击者可以构造一个完整的Redis命令序列通过gopher协议发送从而实现向Redis写入Webshell等操作。例如将一个PHP一句话木马写入网站目录下的shell.php文件。攻击HTTP服务对于内网的Web应用攻击可以直接进行。例如发现一个内网Jenkins服务http://192.168.1.200:8080且未设置强认证。攻击者可以通过SSRF以服务器身份向Jenkins发起请求创建任务、执行命令从而获取服务器权限。攻击者还可以利用SSRF访问云服务商如AWS、阿里云的元数据服务。例如访问http://169.254.169.254/latest/meta-data/来获取云服务器的敏感元数据包括临时密钥等。3.3 场景三作为跳板发起进一步攻击SSRF漏洞的服务器本身可能成为一个“烟雾弹”或“跳板”。绕过IP白名单限制 有些内网应用如数据库管理界面只允许特定IP如运维网段访问。但Web服务器通常在这些白名单内。攻击者通过SSRF就能以Web服务器的身份“合法”访问这些应用。隐藏真实攻击源 所有对内网发起的请求日志记录下来的源IP都是Web服务器自己的IP如127.0.0.1或服务器内网IP这给安全人员的溯源和入侵检测带来了极大困难。实操心得 在测试这类漏洞时我通常会使用一个“中间人”工具来观察服务器实际发出的请求。比如在本地搭建一个Burp Suite Collaborator服务器或者使用http://requestbin.net这类公共服务。将地址设置为http://your-unique-subdomain.requestbin.net然后在漏洞点触发请求。如果能在外部的请求记录中看到来自目标服务器的访问那就铁证如山了。对于内网探测我会准备一个简单的Python脚本将常见的私有IP段和端口组合成URL列表通过漏洞点进行批量、低速的测试并记录响应特征。4. 代码层面深度审计与漏洞定位让我们深入到代码层面看看在YzmCMS 5.3.0中问题具体出在哪个文件、哪个函数。通过对公开源码的分析我们可以定位到关键风险点。4.1 关键文件追踪采集功能的核心逻辑通常集中在后台控制器和模型文件中。经过搜索我们重点关注以下文件/application/admin/controller/collection.class.php 后台采集模块的控制器负责接收请求、调用逻辑。/application/admin/model/collection_model.class.php 采集模型封装了核心的采集业务逻辑如获取网页内容、解析规则。/application/system/libs/目录下可能存在的网络请求工具类。4.2 漏洞代码片段分析在collection_model.class.php中我们找到了一个名为get_content或get_url_content的方法其简化后的代码如下/** * 获取远程URL内容 * param string $url 网址 * return string */ public function get_url_content($url) { if (function_exists(curl_init)) { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); // 【风险点】$url直接传入未校验 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false); curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); curl_setopt($ch, CURLOPT_FOLLOWLOCATION, 1); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); curl_setopt($ch, CURLOPT_TIMEOUT, 30); $content curl_exec($ch); $httpcode curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpcode 200) { return $content; } } elseif (ini_get(allow_url_fopen)) { // 【风险点】使用file_get_contents同样未校验$url $content file_get_contents($url); if ($content) { return $content; } } return false; }风险点解析输入源不可信 这个$url参数在控制器中来源于$_POST或$_GET最终源自管理员在后台表单的输入。在整个传递链条中没有任何一个环节对这个URL进行安全性校验。cURL配置危险CURLOPT_FOLLOWLOCATION设置为1意味着cURL会自动跟随重定向。攻击者可以构造一个URL先指向一个攻击者控制的服务器该服务器返回一个302重定向到内网地址。这样即使后端有初步的域名检查实际上没有也可能被重定向绕过。CURLOPT_SSL_VERIFYPEER和CURLOPT_SSL_VERIFYHOST设置为false这虽然解决了某些环境下SSL证书验证失败的问题但也降低了安全性使得攻击者更容易使用自签名证书进行中间人攻击在此SSRF场景下影响次要。备用方案同样危险 当cURL不可用时回退到file_get_contents()函数该函数在allow_url_fopenOn时支持http://,https://,ftp://,file://等多种协议使得file://协议读取本地文件成为可能。4.3 调用链追溯在控制器collection.class.php中会有类似以下的方法调用模型中的危险函数public function test_collect() { $url $_POST[url]; // 直接获取测试URL $content D(‘collection’)-get_url_content($url); // 传入危险函数 // ... 将$content显示到前台供管理员预览 ... }这个test_collect方法可能对应后台的“测试采集规则”功能。管理员输入一个文章URL来测试规则是否写对而这个输入框就是攻击者注入恶意URL的入口。审计技巧 在审计此类功能时我习惯以“数据流”为核心进行跟踪。从用户输入点如表单$_POST[‘url’]开始在IDE中使用“查找引用”功能追踪这个变量在整个应用中的传递路径直到它被送入最终的执行函数如curl_exec(),file_get_contents(),fsockopen()。这条路径上的每一个处理环节都是需要重点检查的过滤点。5. 修复方案设计与安全加固实践发现了漏洞接下来就是如何修复和加固。修复的核心思想是对用户输入进行严格的“白名单”校验并在网络请求层进行限制。5.1 方案一代码层修复推荐这是最根本的修复方式需要修改YzmCMS的源代码。步骤1增加URL校验函数在公共函数库或模型基类中添加一个严格的URL校验函数。/** * 安全的URL校验函数用于采集功能 * param string $url 待校验的URL * param array $allowed_domains 允许采集的域名白名单可选从节点配置中读取 * return bool|string 校验通过返回规范化URL失败返回false */ function safe_collection_url($url, $allowed_domains array()) { // 1. 基础格式校验 $parsed_url parse_url($url); if (!$parsed_url || !isset($parsed_url[host])) { return false; } // 2. 协议白名单只允许http和https $allowed_schemes array(http, https); if (isset($parsed_url[scheme]) !in_array(strtolower($parsed_url[scheme]), $allowed_schemes)) { return false; // 禁止file://, dict://, gopher://等 } // 3. 域名/IP黑名单禁止内部地址 $host $parsed_url[host]; $ip gethostbyname($host); // 解析域名获取IP // 黑名单回环地址、私有IP段、内网域名等 $blacklist_regex /^(127\.|0\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.|192\.168\.|169\.254\.|localhost$)/; if (preg_match($blacklist_regex, $ip) || preg_match($blacklist_regex, $host)) { return false; } // 也可禁止访问服务器自身的外网IP需动态获取 // 4. 域名白名单如果配置了只允许采集指定域名的内容 if (!empty($allowed_domains)) { $domain_match false; foreach ($allowed_domains as $allowed_domain) { if (strpos($host, $allowed_domain) ! false || fnmatch($allowed_domain, $host)) { $domain_match true; break; } } if (!$domain_match) { return false; } } // 5. 重建安全的URL防止通过、#等注入其他内容 $safe_url (isset($parsed_url[scheme]) ? $parsed_url[scheme] . :// : http://); $safe_url . $parsed_url[host]; $safe_url . isset($parsed_url[port]) ? : . $parsed_url[port] : ; $safe_url . isset($parsed_url[path]) ? $parsed_url[path] : /; $safe_url . isset($parsed_url[query]) ? ? . $parsed_url[query] : ; // 注意忽略fragment(#之后的部分)通常采集不需要 return $safe_url; }步骤2修改采集模型中的网络请求方法在get_url_content函数开头强制调用校验函数。public function get_url_content($url, $allowed_domains array()) { // 新增安全校验 $safe_url safe_collection_url($url, $allowed_domains); if ($safe_url false) { log_message(采集安全拦截非法URL - . $url); // 记录日志 return false; } $url $safe_url; // 使用净化后的URL // 原有的cURL或file_get_contents逻辑... // ... 建议同时优化cURL配置 if (function_exists(curl_init)) { $ch curl_init(); curl_setopt($ch, CURLOPT_URL, $url); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // 改为true验证SSL证书 curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // 改为2严格校验主机名 curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false); // 禁止自动重定向或重定向前校验新URL // 如果需要重定向需手动处理并校验每个跳转URL curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); // 限制协议 curl_setopt($ch, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS); // ... 其他配置 } // ... }步骤3修改控制器调用逻辑在后台控制器中为每个采集节点绑定一个“允许采集的域名”白名单字段可在添加/编辑节点时设置。调用模型方法时传入该白名单。public function test_collect() { $url $_POST[url]; $node_id $_POST[node_id]; // 根据$node_id从数据库读取该节点预先配置的允许域名白名单 $allowed_domains $allowed_domains array(example.com, news.sina.com.cn); // 示例 $content D(‘collection’)-get_url_content($url, $allowed_domains); if ($content false) { $this-error(URL不安全或无法访问); } // ... 显示内容 }5.2 方案二网络层限制如果无法立即修改代码可以在服务器网络层面进行紧急限制。配置防火墙iptables/安全组 限制Web服务器进程如php-fpm, apache对外发起网络请求的能力。只允许其访问必要的端口如数据库端口和少数几个明确的外网采集源地址。这是一项比较严格的措施可能影响其他正常功能。使用PHP配置 在php.ini中设置allow_url_fopen Off来禁用file_get_contents对URL的读取。但这可能会影响其他正常功能且无法防护cURL方式的SSRF。配置cURL 通过curl_setopt设置CURLOPT_PROTOCOLS和CURLOPT_REDIR_PROTOCOLS仅允许HTTP/HTTPS协议。如上文代码所示。5.3 方案三使用安全的HTTP客户端库对于新项目或深度重构建议使用成熟的、安全性更高的HTTP客户端库例如Guzzle。这些库通常提供了更好的默认安全配置和便捷的中间件机制可以方便地添加域名解析校验、IP过滤等功能。修复后的工作流程对比表环节修复前危险状态修复后安全状态URL输入管理员任意填写无限制。管理员填写但系统会与节点预设的“允许域名”进行匹配校验。协议处理支持http、https、file、dict、gopher等。仅允许http和https协议。目标地址可指向任意IP包括127.0.0.1、192.168.x.x等内网地址。经过黑名单内网IP段过滤且必须在域名白名单内如果配置了。请求发起直接使用未经验证的URL发起cURL或file_get_contents请求。使用经过净化、校验后的安全URL发起请求cURL限制协议和重定向。错误处理请求失败可能只返回空内容。非法请求被拦截并记录安全日志便于审计。6. 安全开发启示与深度防御策略这次对YzmCMS“采集节点”的审计暴露的不仅仅是一个具体的漏洞更是一系列常见的安全开发误区。对于开发者、运维和安全人员都有深刻的启示。6.1 给开发者的启示功能安全不等于系统安全永远不要信任用户输入 这是安全开发的第一铁律。这里的“用户”甚至包括“管理员”。任何来自外部的数据包括表单、URL参数、Cookie、HTTP头都必须视为不可信的必须经过严格的验证和过滤。验证的黄金法则是“白名单”即只允许符合明确规则的输入而不是“黑名单”式地拒绝已知的坏输入。最小权限原则 一个功能应该只拥有完成其任务所必需的最小权限。采集功能需要访问外部网络但并不意味着它需要访问file://协议或内网地址。在代码设计时就应该明确限制其能力边界。避免直接拼接与执行 无论是SQL查询、系统命令还是网络请求URL直接拼接用户输入都是极度危险的。应该使用参数化查询、安全的API或经过严格校验的参数。代码审计应成为习惯 在开发过程中尤其是涉及网络I/O、文件操作、命令执行、反序列化等高风险函数时要养成自我审计的习惯。多问一句“这个参数用户可控吗我校验了吗”6.2 给运维与安全人员的启示纵深防御与持续监控纵深防御 不要将安全寄托在单一防线比如认为后台很安全。应在网络、主机、应用、数据等多个层面布防。即使应用层存在SSRF漏洞如果网络层防火墙严格限制了Web服务器出站流量攻击者的影响也会被大大限制。出站流量监控 大多数安全监控专注于入站攻击。对于SSRF这类漏洞监控服务器异常的出站网络连接同样重要。例如Web服务器突然向192.168.1.1:6379或169.254.169.254发起请求就是一个极高的危险信号。定期漏洞扫描与更新 对线上使用的CMS、框架、组件进行定期漏洞扫描和版本更新。关注官方发布的安全公告像此次审计的这类已知漏洞模式很多安全扫描器都能检测出来。安全配置强化 在服务器和运行环境层面进行安全配置如为PHP设置open_basedir限制文件访问目录。在php.ini中谨慎设置allow_url_fopen和allow_url_include。确保Web服务器进程以低权限用户运行。6.3 通用SSRF防御 checklist在设计和评审任何涉及“服务器发起外部请求”的功能时可以对照以下清单[ ]输入校验 是否对URL的协议仅允许http/https、域名/IP禁止内网地址、回环地址进行了白名单校验[ ]域名解析控制 是否对用户输入的域名进行解析并检查解析出的IP地址是否在禁止范围内注意DNS重绑定攻击。[ ]重定向控制 如果请求支持重定向是否对重定向的目标地址进行了同样严格的校验最好禁用自动重定向手动处理。[ ]网络层限制 应用程序所在服务器/容器的网络策略是否限制了其向非必要的外部地址和内网发起连接[ ]错误信息处理 当请求失败时返回的错误信息是否过于详细如泄露内网IP、端口状态应返回统一的模糊错误信息。[ ]权限隔离 执行网络请求的服务是否使用了独立的、低权限的身份和网络空间7. 从这次审计中学到的功能与安全的平衡回过头看YzmCMS的“采集节点”功能设计初衷无疑是好的为了给站长提供便利。但恰恰是这种追求“强大”和“灵活”的设计思路在安全上打开了潘多拉魔盒。安全与功能、便利性之间永远存在权衡。对于此类功能一个更安全的设计模式是在功能配置阶段进行严格限制在执行阶段进行二次校验。例如在“添加采集节点”时管理员只能从一个预定义的、经过审核的“源站列表”中选择或者输入域名后系统后台自动验证该域名是否可公开访问且不属于内网。在执行采集任务时再次用同样的规则校验解析出的每一个文章URL。这次深度审计也让我意识到在开源生态中很多中小型项目的开发重心在于功能实现和用户体验安全往往被置于次要位置甚至完全依赖社区来发现和反馈问题。作为使用者我们不能抱有侥幸心理在将任何系统上线前尤其是涉及内容管理、用户交互的系统对其核心功能进行一次基本的安全代码审查是非常有必要且值得投入的一项工作。毕竟守住自己的服务器就是守住业务的生命线。