我离开了 WordPress,打开 phpMyAdmin,却发现我的旧网站被黑了

离开 WordPress 已经是我为网站做出的较好技术决策之一。我想要更好地控制代码、更少的依赖、更出色的性能、更严密的安全性、与 OpenAI 的直接集成,以及一个真正围绕我的需求运作的内容管理系统,而不是每次想改点什么都强迫我再安装一个插件。

于是我构建了自己的定制 CMS,并开始逐步将一些较早的文章从 WordPress 迁移出来。这个过程相当直接。我打开 phpMyAdmin,开始浏览旧 WordPress 数据库,找到想要保留的文章,然后在将内容转移到新系统之前,着手清理所有内容。

然后我注意到了一些有意思的东西。

数据库中有些行不该出现在那里。数据库各处散落着无用的垃圾。还有一些奇怪的条目,以及一些我确定自己不记得创建过的文章。我越是浏览数据库,就越明显地意识到,似乎有人曾在某个时候进入了旧 WordPress 安装,并决定重新装饰一番。


真棒。

如果不对服务器进行完整的取证检查,我无法确定旧版 WordPress 安装究竟是如何遭到入侵的。可能是 SQL 注入,也可能是存在漏洞或已过时的插件。还可能是主题漏洞、管理员凭据被盗、FTP 或主机托管凭据遭到泄露、旧的 PHP 后门、恶意上传、暴露的数据库凭据,或者服务器上多年来一直被遗忘的其他组件。

重要的是,在数据库中发现垃圾记录,并不能自动证明存在 SQL 注入。任何人在看到异常的数据库行后,不检查服务器就立即大喊“SQL 注入!”,都只是在猜测。入侵 WordPress 安装有很多途径。遗憾的是,当一个安装经过多年累积了足够多的插件、主题、用户、备份和被遗忘的文件后,WordPress 就会为攻击者提供一份非常庞大的选择菜单。

WordPress 消失了,但安全教训留了下来

我当前的网站完全不使用 WordPress。它运行在一个我专门根据自身需求构建的定制 CMS 上。它管理页面、出版物、分类、媒体、SEO、翻译、由 OpenAI 驱动的内容工具、评论、Stripe 支付、管理功能、自定义插件、安全审计,以及我实际使用的其他功能。

移除 WordPress 消除了大量不必要的攻击面。网站底层不再有庞大的插件生态系统。也不再有从十个不同开发者那里引入库的随机主题。不存在五年前就停止接收安全更新、却被遗忘的插件。如果我需要某项功能,我确切知道该功能在哪里实现,也知道由哪些代码负责。

这并不意味着定制软件会自动变得安全。定制软件同样可能存在漏洞;如果开发者以为没人会理解其工作原理,情况有时甚至会糟糕得多。通过隐蔽性来实现安全并不是真正的安全。区别在于,使用我自己的 CMS 时,我可以审查整个攻击面,移除不必要的功能,记录可疑行为,并在发现不满意之处时更改应用程序架构。

在我旧的 WordPress 数据库中发现那些垃圾内容,让我有了充分理由以更加偏执的心态重新检查一遍新的 CMS。在网络安全领域,适度的偏执基本上就是预防性维护。

从数据库开始

我审查的首要领域之一是数据库安全。新的 CMS 使用 PDO 预处理语句处理用户控制的值,而不是将随机表单字段直接拼接到查询字符串中来构建 SQL 查询。这大幅减少了传统 SQL 注入的攻击面。

对于正常的应用程序数据库连接,MySQL 多语句执行也已被禁用。数据库账户应当仅拥有 CMS 实际需要的权限。面向公众的 PHP 应用程序不需要被授予不受限制的数据库管理权限,仅仅因为授予全部权限更方便。

便利与安全从来都不是特别合得来的搭档。

这些措施都不会让数据库神奇地变得不可入侵。任何连接到互联网的事物都不应被描述为不可入侵。目标是大幅提高利用漏洞的难度,并在某一防御层失效时限制可能造成的后果。

我构建了一个安全中心,因为我想知道是谁在敲门

在看到旧网站发生的事情后,我希望新 CMS 具备的最大功能之一就是可见性。如果有人在探测我的服务器、反复进行身份验证失败的尝试、寻找配置文件、访问虚假的 WordPress 端点、滥用表单,或执行可疑的管理操作,我希望留有记录。

CMS 现在拥有一个专门的安全中心,用于记录与安全相关的活动。根据事件类型,它可以记录源 IP 地址、日期和时间、请求方法、请求路径、用户代理、已认证的管理员、事件类别、严重性,以及相同活动被观测到的频率。

安全系统可以跟踪成功和失败的管理员登录、授权失败、可疑 URL 探测、速率限制违规、管理 POST 请求、内容修改、Stripe webhook 失败、联系和评论活动,以及访问虚假 WordPress 登录系统的尝试。

与此同时,日志记录器会刻意避免成为攻击者的藏宝箱。密码不会被存储。Cookie 不会被倾倒到日志中。授权标头、API 密钥、Stripe 密钥、CSRF 令牌和类似凭据都会被排除或编辑。创建一个复杂的安全系统,然后又恰好把攻击者想要的每个秘密都存放在一张数据库表中,这毫无意义。

管理员

IP 封禁已内置于 CMS 中

如果我发现某个扫描器特别顽固,或者有人反复尝试发送可疑请求,我可以直接从 Security Center 中封禁单个 IPv4 或 IPv6 地址。必要时也可以封禁 CIDR 范围。

CMS 可以在应用层强制执行这些封禁,但也可以生成 Apache 规则,以便在服务器层面进行封禁。这一区别很重要。在 PHP 内部封禁某人,意味着 Web 应用仍然必须接收并处理部分请求。而在 Apache、防火墙或网络边缘封禁该地址则更好,因为这样可以在应用不得不浪费资源处理请求之前就将其拒绝。

如果我要把某人赶出大楼,我宁愿在前门就拦住他,也不愿先让他乘电梯上楼。

我不会让访客自行选择 IP 地址

另一个出人意料地常见的安全问题涉及 X-Forwarded-ForX-Real-IP 等标头。设计不佳的应用有时会自动信任这些标头,并记录客户端发送的任何 IP 地址。

问题在于,任何使用 Burp Suite、curl、自定义脚本或其他 HTTP 客户端的人都可以提交伪造的转发标头。攻击者可以发送 X-Forwarded-For: 8.8.8.8,这样一来,你的安全系统就会突然认为是 Google 在攻击你。

我的 CMS 只有在实际连接来自我明确配置为受信任的代理地址时,才会信任转发的客户端 IP 标头。否则,CMS 会依赖实际的远程连接地址。我不想维护这样一份安全日志:被调查的人竟然可以选择其活动旁边显示的 IP 地址。

互联网上的每个人似乎都想要我的 .env 文件

在我开始更加密切地关注安全日志后不久,我注意到了另一件事。自动扫描器非常喜欢寻找环境文件。

它们会探测诸如 /.env/.env.production/.env.local/.env.backup/.env.old/.git/config/.aws/credentials/database.sql/backup.sql,以及各种配置文件和备份文件。

显然,我的环境变量极其受欢迎。我应该开始收门票了。

现在,Apache 配置会拒绝隐藏文件、配置文件、备份文件、数据库转储文件、源代码管理目录、临时文件、日志文件以及其他敏感资源,且请求甚至还未到达常规 CMS 路由系统。

.well-known 目录被有意排除在一般隐藏文件限制之外,因为 ACME 和 Let's Encrypt 等合法服务可能需要访问它。其他所有内容都会受到明显不那么热情的对待。

对于许多侦察请求,我也更倾向于返回通用的 404,而不是确认受保护资源确实存在。没有理由告诉自动扫描器:“恭喜,你找到了我的 .env 文件,但很遗憾,我不会让你读取它。”简单地说一句“这里什么都没有”能提供更少的信息。

为了增加一层保护,可以将生产环境的机密信息完全存储在公开可访问的文档根目录之外。这样,即使 Apache 规则被意外更改或重写失败,网站目录中也不会存在可供 Web 服务器交付的环境文件。

内部目录保持内部状态

CMS 具有用于配置、包含文件、数据库代码、存储、插件和文档的内部目录。这些目录之所以存在,是因为应用程序需要它们。它们不是公开页面,互联网上没有任何人有正当理由浏览这些目录。

对敏感应用程序目录的直接 Web 访问已被阻止。目录索引已禁用,不必要的 CGI 执行也同样已禁用。静态资源和有意公开的应用程序仍可正常提供,但内部源文件会被作为内部源文件处理。

我不需要 Apache 为进行侦察的人礼貌地生成我的应用程序结构索引。如果他们想要参观我的源代码树,那就等这本书出版吧。

安全标头是另一层防护

Web 服务器还会返回一组面向安全的 HTTP 标头。其中包括针对内容类型嗅探、框架嵌入、引用来源信息、浏览器权限、HTTPS 强制执行、跨域策略行为以及可执行嵌入内容的保护措施。

该配置包含 X-Content-Type-OptionsReferrer-PolicyX-Frame-OptionsX-Permitted-Cross-Domain-PoliciesPermissions-PolicyStrict-Transport-SecurityContent-Security-Policy 等控制项。

安全标头并不能替代安全的应用程序代码。它们只是另一层防御。良好的安全性通常来自叠加多个合理的控制措施,而不是安装一个神奇的功能后就宣布大功告成。

管理会话得到加强

管理员身份验证系统也接受了严格的安全审查。新管理员密码具有更高的最低要求,并且当 PHP 环境支持时,密码哈希会使用 Argon2id 等现代算法。较旧但兼容的哈希可以在身份验证成功后升级。

登录尝试受到限制,因此无法让某人无限期地反复提交登录表单而不承担后果。身份验证失败时会使用通用响应,而不会透露某个用户名或电子邮件地址是否确实存在。会话标识符会在重要的身份验证边界重新生成,管理会话同时具有非活动限制和绝对过期时间。

会话 Cookie 使用适当的 Secure、HttpOnly 和 SameSite 设置。严格的会话行为已启用,并且还可以验证其他会话特征,从而使被盗会话更难被重复使用。

我对此仍然持现实态度。如果有人窃取了实际的托管账户、数据库凭据或服务器级访问权限,那么应用程序会话安全就不再是我生活中最大的问题。安全性是分层的,而每一层都能防范不同类型的故障。

安装程序不应成为攻击面

在安全审计期间,我还在自己的 CMS 中发现了一些不太喜欢的东西。安装程序以前有一个应急机制,原本是为了帮助从未完成的安装中恢复。

在开发软件时,这可能很方便。但网站投入生产环境运行后,它就变得不那么理想了。

强制安装绕过机制已被移除。一旦 CMS 中包含真实的生产内容,安装程序就会被锁定。如果我确实需要恢复未完成的安装,就必须通过服务器配置明确启用恢复功能。

我宁愿让自己多花三十秒,也不愿把一条安装程序通道留在公网上,接下来十年都暴露在那里。

PHP 插件编辑器通常处于禁用状态

我的 CMS 有自己的自定义插件系统。这很有用,因为我可以扩展功能,而不必把核心应用变成一团乱麻。这也意味着 CMS 可能包含能够修改 PHP 代码的功能。

任何能够修改服务器端可执行代码的内容都值得获得额外保护。因此,在正常运行条件下,生产环境中的插件编辑器处于禁用状态。如果我确实需要编辑插件代码,就会在服务器级别启用它,以超级管理员身份完成工作,然后立即再次将其禁用。

同样的一般规则也适用于能够存储不受限制的 HTML 或 JavaScript 的可信管理字段。由于从安全角度来看,向网站注入可执行 JavaScript 的能力,与更改按钮颜色并不是一回事,因此这些功能仅限于权限极高的用户。

上传的图片一律被当作陌生人对待

文件上传也获得了额外的安全控制。CMS 不会仅仅因为浏览器声称某个文件是图片,就盲目信任它。系统会检查并验证文件的实际内容。

上传的出版物图片会被解码,然后重新编码为全新的 WebP 文件。它们会统一调整为 CMS 使用的尺寸,而这一过程也有助于移除原始文件中不必要的嵌入式附加内容。应用程序还会验证文件扩展名、实际 MIME 信息、图片尺寸、文件大小,以及潜在的解压缩相关问题。

可执行文件扩展名会被拒绝,并且上传目录经过配置,可防止上传的文件突然决定自己其实更想成为 PHP 应用程序。

如果有人上传了一个名为 totally-not-a-webshell.php 的文件,我更希望服务器不要回复:“在我看来,这像是一张全家福。”

速率限制可在完全无需入侵的情况下防止数据库垃圾

遭到入侵的 WordPress 数据库还让我想到了一件实际上完全不需要利用漏洞的事情。合法的公共功能有时也会被滥用到足以产生与攻击相同的实际结果。

我的网站具有付费联系和评论功能。为了将某人发送到 Stripe Checkout,CMS 可能需要在付款完成前创建一条临时的待处理记录。如果没有控制措施,机器人可以反复启动这一流程数千次,却从不付款,并逐渐用无用的待处理记录填满数据库。

这些端点现在都受到速率限制。被放弃且未付款的联系和评论记录也会在规定期限后自动清理。速率限制系统使用紧凑型计数器,而不是为每个普通请求创建一条巨大的安全日志记录。

并非每次攻击都需要复杂的漏洞利用。有时,有人只需按下一万个完全合法的按钮,就能把你的数据库变成垃圾填埋场。

管理操作使用 CSRF 防护

管理更改同样受到跨站请求伪造防护。仅仅登录 CMS,不应允许其他网站诱骗浏览器在用户不知情的情况下提交管理操作。

敏感的管理请求需要经过适当的验证和 CSRF 防护,而重要的管理操作则可以写入安全审计日志。这样我就能获得一份记录,了解更改了什么、何时发生、哪个账户执行了该操作,以及请求来自何处。

当只有一位主要管理员时,记录日志似乎有些过度。但当某些事情发生变化,而你盯着屏幕想着“我绝对没有做过这件事”时,记录日志就不再显得那么过度了。

然后,我打造了最有趣的安全功能:虚假的 WordPress

这可能是整个安全系统中我最喜欢的部分。

我的网站已经不再运行 WordPress。普通访客没有任何正当理由请求 /wp-admin/wp-login.php。正常读者不会早上醒来后,在我的域名后面不小心输入 /wp-admin

另一方面,机器人非常喜欢这些 URL。

所以,我没有简单地返回一个无聊的错误页面,而是给它们准备了一个小小的惊喜。

如果有人访问 /wp-admin/wp-login.php,CMS 就会提供一个虚假的 WordPress 登录界面。它看起来足够像 WordPress,可以让自动探测程序和好奇的访客满意,但其背后并没有真正的 WordPress 安装。

他们可以输入用户名。他们可以输入密码。他们可以点击“登录”。他们可以盯着它看。他们可以再试一次。他们可以质疑自己的人生选择。这个页面会继续对他们完全没用,而安全中心则会悄悄记录这次尝试中有趣的部分。

蜜罐会记录诸如 IP 地址、时间戳、请求的端点、用户代理、命中次数以及尝试使用的用户名等信息。它明确不会存储提交的密码。我不需要任何人的密码。我只需要知道某个 IP 地址花了二十分钟,试图向一个根本不存在的 WordPress 安装进行身份验证。

它基本上就是一扇画在砖墙上的数字门。

WordPress 蜜罐不只是个玩笑

蜜罐很有趣,但它也会产生有用的安全信号。由于网站实际上并未使用 WordPress,对 WordPress 专用资源的请求会立即变得值得关注。

如果有人开始请求 /wp-admin/wp-login.php/wp-config.php/xmlrpc.php,我就已经知道那个访客可能并不是在阅读我的文章。该请求可能只是自动化的全网漏洞扫描的一部分,但也可能是在针对这个特定网站进行侦察。

无论是哪种情况,这名访客都很方便地表明了自己值得被记录。

扫描器试图对 WordPress 进行指纹识别,而真正的 CMS 却在幕后静静地观察,这种感觉颇令人满足。

WordPress 本身未必就是罪魁祸首

有一点我应该说清楚。我无法断言一定是 WordPress 本身导致了这次入侵。WordPress 为互联网的很大一部分提供支持,只要得到妥善维护,绝对可以实现安全运行。

更大的问题往往是 WordPress 周围随着时间推移不断增加的东西。一个成熟的安装可能包含 WordPress 核心、自定义主题、二三十个插件、没人记得是谁创建的管理员账户、备份归档、缓存系统、迁移工具、被弃用的插件、旧 PHP 文件、数据库实用工具,以及多年积累下来的各种代码。

其中每一个组件都会成为另一件必须信任、打补丁、监控,并最终移除的东西。

我更倾向于简化这个问题。我想确切知道究竟有哪些软件在运行我的网站。如果现在有东西表现异常,我不必猜测罪魁祸首究竟是 WordPress、主题、Elementor、WooCommerce、插件 47、插件 47 的兼容性扩展,还是某个在 2018 年从互联网消失的家伙开发的插件。

我知道路由在哪里。我知道 SQL 查询在哪里。我知道身份验证在哪里进行。我知道文件在哪里上传。我知道哪些端点接受 POST 请求。我知道我的 Stripe 集成是如何工作的。我知道 OpenAI 请求从哪里发起。我知道哪些目录绝不应该公开。

这种可见性很难用价格来衡量。

安全永远不会完成

从发现旧 WordPress 入侵事件中,我得到的最重要的教训是:安全不是安装一次后就可以忘掉的东西。不存在一个写着“网站安全:是”的复选框。

安全日志需要审查。软件需要更新。凭据偶尔需要轮换。文件权限需要检查。需要调查意外的数据库活动。备份必须存在,但最好不要让它以 /backup.zip 的形式放在公开网站内,等着扫描器发现。

旧文件需要删除。生产环境中需要禁用开发工具。管理员需要使用强身份验证。上传内容需要验证。数据库查询需要参数化。敏感目录需要受到保护。可疑请求需要可见。

而且,如果有人想花一个晚上,试图对一个实际上并不运行 WordPress 的网站上的虚假 WordPress 安装进行暴力破解,有时你只能佩服这份执着。

为什么我仍然庆幸自己离开了 WordPress

我最初离开 WordPress,是因为我想要一个更快、更简洁、更易于控制的网站。我想要一个围绕自己的发布工作流程构建的 CMS,而不是把时间花在安装插件、解决其他插件带来的问题上。

发现旧安装似乎已经遭到入侵,只是让我有了继续朝这个方向发展的另一个理由。

我目前使用的 CMS 并非坚不可摧。任何暴露在互联网中的东西都不是。区别在于,现在应用程序由我控制。数据库层由我控制。身份验证由我控制。上传流程由我控制。路由由我控制。安全日志由我控制。我控制有哪些管理功能,以及它们何时启用。

最重要的是,当有人在不断试探门锁时,我确实看得见。

如果那些扫描器不顾一切地坚持要查找 WordPress 呢?

好吧。

我给它们留了一个。

算是吧。