一、前言与背景
Vue.js 是前端界广泛使用的框架,而 Vue 2在很多遗留项目中仍被广泛使用。Vue 2 已于 2023 年 12 月 31 日达到终止支持时间。它不再会有新增功能、更新或问题修复。但2024年曝出的两个安全漏洞 — CVE-2024-9506(ReDoS 漏洞)与 CVE-2024-6783(XSS / 原型污染漏洞)提醒我们:即使是在老版本框架里,也必须警惕潜在风险。
本文将带你从以下几个角度理解和实践:
- 漏洞细节与可被利用的路径
- 在 Vue 2 代码库中的定位方式
- 提供补丁或替换方案(包括开源补丁包示例)
- 如何在项目中安全落地这些修复
在动手之前,请备份你的代码库,并确保你在一个可回滚的环境(比如开发分支或 staging 环境)先尝试修复。
二、漏洞细节与危害分析
2.1 CVE-2024-9506:ReDoS 漏洞(正则表达式拒绝服务)
概念回顾: ReDoS(Regular Expression Denial of Service)是指攻击者通过构造特定输入,使正则表达式的匹配过程进入指数级回溯,从而占用大量 CPU 资源,拖慢或阻塞系统运行。
漏洞位置: 在 Vue 2 的 parseHTML 函数中存在不安全或效率问题的正则表达式处理。(国家漏洞数据库)
具体来说,如果模板字符串中含有 <script> … </not-script>、<style> 或 <textarea> 等标签被误闭合或半闭合结构,就可能触发正则回溯耗时极长。(herodevs.com)
危害: 在渲染阶段,恶意构造的模板可以挂起模板解析,使页面挂起或响应变慢,造成拒绝服务。
受影响版本: Vue 2 所有版本(>=2.0.0 且 <3.0.0)都可能受影响。(herodevs.com)
修复状态: 该漏洞在 Vue 官方主版本中未被修补,因为 Vue 2 已经停止维护状态。但社区中已有补丁或替代版本(如 Vue NES、patch 包、fork 版本)提供支持。(herodevs.com)
2.2 CVE-2024-6783:XSS / 原型污染漏洞
漏洞概要: 该漏洞影响到 vue-template-compiler,攻击者可以通过对象属性操作(如污染原型链)诱发 XSS(Cross-Site Scripting)攻击。(GitHub)
具体来说,在模板编译阶段,如果用户提供的模版被恶意构造,通过模板编译器或与原型链的交互,可能引入脚本执行。(herodevs.com)
影响版本: vue-template-compiler 的许多 Vue 2 项目都依赖它来进行模板编译,因此受影响的范围广。(GitHub)
修复状态: 社区已发布安全补丁版 vue-template-compiler-patched,用于替换受影响的版本。(GitHub)
注意: 该漏洞更偏向客户端模板编译(或服务端 SSR 模板编译)阶段的攻击链,在多数标准 Vue 应用中如果模板不接收不可信输入,风险较低。但仍建议补丁或规避。(GitHub)
三、在 Vue 2 项目中定位受影响处
在你自己的项目里,首先需要判断是否会触发或被利用这些漏洞。以下是建议步骤:
- 查找是否使用运行时模板编译
如果你在客户端或服务端将 HTML 字符串动态传入 Vue 的template,而不是预编译.vue文件,那么就存在风险。 - 检查
vue/vue-template-compiler的版本依赖
在package.json或锁文件里查看是否使用了 Vue 2、及其对应版本。若是 2.x,且未用补丁版或 patched 版本,那就有受影响的可能。 - 在编译器源码中寻找
parseHTML
在 Vue 2 源码(如 2.7.16 分支)中,可以定位src/compiler/html-parser.js或html-parser.ts,在parseHTML函数里查看对<script>/<style>/<textarea>标签闭合的正则。 - 查找模板编译器插件
若你用vue-template-compiler、vue-loader、vue-server-renderer等包,这些编译链都可能受影响。 - 检验是否引用被污染对象
在模板、指令、动态表达式里避免使用__proto__、constructor.prototype、__defineGetter__等敏感属性。
四、修复方案与补丁代码
下面提供几种修复与规避方案,以及对应的示例代码。
4.1 使用社区补丁包 vue-template-compiler-patched
这是较为便捷、安全的方式。该补丁包已在一定程度上修复上述两个 CVE 漏洞。(GitHub)
安装与替换步骤
在项目目录中执行:
# 使用 patched 版替代 vue-template-compiler
npm install --save-dev vue-template-compiler@npm:vue-template-compiler-patched@^2.7.16-patch.2
或者用 alias 的方式替换原名:
npm install --save-dev vue-template-compiler@npm:vue-template-compiler-patched@^2.7.16-patch.2 --save-dev
在 webpack / vue-loader 中保持对 vue-template-compiler 的引用不变,这样你的现有构建链无需改动,直接使用 patched 版本。
优点: 无需修改源码;兼容性较好;快速上手。
缺点: 依赖社区维护。如果补丁包停止更新,就要转用其它方案。
4.2 手动对进行安全增强(源码补丁)
如果你愿意维护一个自定义补丁,可以直接修修改源码中的 parseHTML 实现,引入输入长度限制、正则防护、回溯深度限制等。
CVE-2024-6783漏洞攻击示例
<head>
<script>
window.Proxy = undefined // Not necessary, but helpfull in demonstrating breaking out into `window.alert`
Object.prototype.staticClass = `alert("Polluted")`
</script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.7.16/dist/vue.js"></script>
</head>
<body>
<div id="app"></div>
<script>
new window.Vue({
template: `<div class="">Content</div>`,
}).$mount('#app')
</script>
</body>下面是一个基于vue2.7.16版本【CVE-2024-9506:ReDoS 漏洞】补丁:
vue/src/compiler/parser/html-parser.ts:
import { makeMap, no } from 'shared/util'
import { isNonPhrasingTag } from 'web/compiler/util'
import { unicodeRegExp } from 'core/util/lang'
import { ASTAttr, CompilerOptions } from 'types/compiler'
// Regular Expressions for parsing tags and attributes
// const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 【修复1】修复属性匹配正则表达式, 避免回溯,防止超长属性值时正则运算时间过长
const attribute =
/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s"'=<>`]+)))?/
// 【修复2】限制动态属性值的最大长度
const dynamicArgAttribute =
/^\s*((?:v-[\w-]+:|@|:|#)\[[^=]+?\][^\s"'<>\/=]*)(?:\s*(=)\s*(?:"([^"]{0,1000})"|'([^']{0,1000})'|([^\s"'=<>`]+)))?/
const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z${unicodeRegExp.source}]*`
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const doctype = /^<!DOCTYPE [^>]+>/i
// #7298: escape - to avoid being passed as HTML comment when inlined in page
const comment = /^<!\--/
const conditionalComment = /^<!\[/
// Special Elements (can contain anything)
export const isPlainTextElement = makeMap('script,style,textarea', true)
const reCache = {}
// 【修复3】新增安全限制参数
const MAX_TAG_CONTENT_LENGTH = 50000 // 标签内容最大长度限制
const MAX_ATTR_VALUE_LENGTH = 1000 // 属性值最大长度限制
const MAX_PARSING_TIME = 50000 // 最大解析时间(ms)
const decodingMap = {
'<': '<',
'>': '>',
'"': '"',
'&': '&',
'
': '\n',
' ': '\t',
''': "'"
}
const encodedAttr = /&(?:lt|gt|quot|amp|#39);/g
const encodedAttrWithNewLines = /&(?:lt|gt|quot|amp|#39|#10|#9);/g
// #5992
const isIgnoreNewlineTag = makeMap('pre,textarea', true)
const shouldIgnoreFirstNewline = (tag, html) =>
tag && isIgnoreNewlineTag(tag) && html[0] === '\n'
function decodeAttr(value, shouldDecodeNewlines) {
// 【修复4】限制解码值的长度
if (value.length > MAX_ATTR_VALUE_LENGTH) {
value = value.substring(0, MAX_ATTR_VALUE_LENGTH)
}
const re = shouldDecodeNewlines ? encodedAttrWithNewLines : encodedAttr
return value.replace(re, match => decodingMap[match])
}
export interface HTMLParserOptions extends CompilerOptions {
start?: (
tag: string,
attrs: ASTAttr[],
unary: boolean,
start: number,
end: number
) => void
end?: (tag: string, start: number, end: number) => void
chars?: (text: string, start?: number, end?: number) => void
comment?: (content: string, start: number, end: number) => void
}
export function parseHTML(html, options: HTMLParserOptions) {
const stack: any[] = []
const expectHTML = options.expectHTML
const isUnaryTag = options.isUnaryTag || no
const canBeLeftOpenTag = options.canBeLeftOpenTag || no
let index = 0
let last, lastTag
// 【修复5】添加解析超时检测
const startTime = Date.now()
let timeoutWarningShown = false
const checkTimeout = () => {
const elapsed = Date.now() - startTime
if (elapsed > MAX_PARSING_TIME && !timeoutWarningShown) {
timeoutWarningShown = true
if (__DEV__ && options.warn) {
options.warn(
`HTML parsing taking longer than expected (${elapsed}ms)`,
{
start: index,
end: index + html.length
}
)
}
}
return elapsed > MAX_PARSING_TIME * 2 // 只有严重超时才抛出错误
}
while (html) {
checkTimeout()
last = html
// Make sure we're not in a plaintext content element like script/style
if (!lastTag || !isPlainTextElement(lastTag)) {
let textEnd = html.indexOf('<')
if (textEnd === 0) {
// Comment:
if (comment.test(html)) {
const commentEnd = html.indexOf('-->')
if (commentEnd >= 0) {
if (options.shouldKeepComment && options.comment) {
options.comment(
html.substring(4, commentEnd),
index,
index + commentEnd + 3
)
}
advance(commentEnd + 3)
continue
}
}
// https://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
if (conditionalComment.test(html)) {
const conditionalEnd = html.indexOf(']>')
if (conditionalEnd >= 0) {
advance(conditionalEnd + 2)
continue
}
}
// Doctype:
const doctypeMatch = html.match(doctype)
if (doctypeMatch) {
advance(doctypeMatch[0].length)
continue
}
// End tag:
const endTagMatch = html.match(endTag)
if (endTagMatch) {
const curIndex = index
advance(endTagMatch[0].length)
parseEndTag(endTagMatch[1], curIndex, index)
continue
}
// Start tag:
const startTagMatch = parseStartTag()
if (startTagMatch) {
handleStartTag(startTagMatch)
if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
advance(1)
}
continue
}
}
let text, rest, next
if (textEnd >= 0) {
rest = html.slice(textEnd)
while (
!endTag.test(rest) &&
!startTagOpen.test(rest) &&
!comment.test(rest) &&
!conditionalComment.test(rest)
) {
// < in plain text, be forgiving and treat it as text
next = rest.indexOf('<', 1)
if (next < 0) break
textEnd += next
rest = html.slice(textEnd)
}
text = html.substring(0, textEnd)
}
if (textEnd < 0) {
text = html
}
if (text) {
// 【修复6】限制文本内容长度
if (text.length > MAX_TAG_CONTENT_LENGTH) {
text = text.substring(0, MAX_TAG_CONTENT_LENGTH)
if (__DEV__ && options.warn) {
options.warn(
`Text content exceeds ${MAX_TAG_CONTENT_LENGTH} characters, truncated to prevent ReDoS`,
{ start: index, end: index + text.length }
)
}
}
advance(text.length)
}
if (options.chars && text) {
options.chars(text, index - text.length, index)
}
} else {
// 【修复7】处理script/style/textarea时,先检查长度,超过限制直接截断
if (html.length > MAX_TAG_CONTENT_LENGTH) {
// 超长时触发警告(仅开发环境)
if (__DEV__ && options.warn) {
options.warn(
`Content length of <${lastTag}> exceeds ${MAX_TAG_CONTENT_LENGTH} characters, potential ReDoS attack`,
{ start: index, end: index + html.length }
)
}
// 截断内容,避免正则匹配超长字符串
html = html.slice(0, MAX_TAG_CONTENT_LENGTH)
}
let endTagLength = 0
const stackedTag = lastTag.toLowerCase()
// 【修复8】优化正则,使用非贪婪匹配+明确结束符,避免回溯
const reStackedTag =
reCache[stackedTag] ||
(reCache[stackedTag] = new RegExp(
'([\\s\\S]{0,' +
MAX_TAG_CONTENT_LENGTH +
'}?)(</' +
stackedTag +
'\\s*[>]|$)', // 添加$匹配防止无限匹配
'i'
))
const rest = html.replace(reStackedTag, function (all, text, endTag) {
endTagLength = endTag ? endTag.length : 0 // 处理无匹配结束符的情况
if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
text = text
.replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
.replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
}
if (shouldIgnoreFirstNewline(stackedTag, text)) {
text = text.slice(1)
}
if (options.chars) {
options.chars(text)
}
return endTag || ''
})
index += html.length - rest.length
html = rest
// 【修复9】无匹配结束符时,主动清理栈,避免无限循环
if (endTagLength === 0 && __DEV__ && options.warn) {
options.warn(`Unclosed <${stackedTag}> tag, potential ReDoS attack`, {
start: index - html.length,
end: index
})
// 强制关闭标签,清理栈
parseEndTag(stackedTag, index - html.length, index)
} else {
parseEndTag(stackedTag, index - endTagLength, index)
}
}
if (html === last) {
options.chars && options.chars(html)
if (__DEV__ && !stack.length && options.warn) {
options.warn(`Mal-formatted tag at end of template: "${html}"`, {
start: index + html.length
})
}
break
}
}
// Clean up any remaining tags
parseEndTag()
function advance(n) {
index += n
html = html.substring(n)
}
function parseStartTag() {
const start = html.match(startTagOpen)
if (start) {
const match: any = {
tagName: start[1],
attrs: [],
start: index
}
advance(start[0].length)
let end, attr
// 【修复10】添加属性解析数量限制
let attrCount = 0
const MAX_ATTR_COUNT = 100
while (
!(end = html.match(startTagClose)) &&
(attr = html.match(dynamicArgAttribute) || html.match(attribute)) &&
attrCount < MAX_ATTR_COUNT
) {
attr.start = index
advance(attr[0].length)
attr.end = index
match.attrs.push(attr)
attrCount++
}
if (end) {
match.unarySlash = end[1]
advance(end[0].length)
match.end = index
return match
}
}
}
function handleStartTag(match) {
const tagName = match.tagName
const unarySlash = match.unarySlash
if (expectHTML) {
if (lastTag === 'p' && isNonPhrasingTag(tagName)) {
parseEndTag(lastTag)
}
if (canBeLeftOpenTag(tagName) && lastTag === tagName) {
parseEndTag(tagName)
}
}
const unary = isUnaryTag(tagName) || !!unarySlash
const l = match.attrs.length
const attrs: ASTAttr[] = new Array(l)
for (let i = 0; i < l; i++) {
const args = match.attrs[i]
const value = args[3] || args[4] || args[5] || ''
const shouldDecodeNewlines =
tagName === 'a' && args[1] === 'href'
? options.shouldDecodeNewlinesForHref
: options.shouldDecodeNewlines
attrs[i] = {
name: args[1],
value: decodeAttr(value, shouldDecodeNewlines)
}
if (__DEV__ && options.outputSourceRange) {
attrs[i].start = args.start + args[0].match(/^\s*/).length
attrs[i].end = args.end
}
}
if (!unary) {
stack.push({
tag: tagName,
lowerCasedTag: tagName.toLowerCase(),
attrs: attrs,
start: match.start,
end: match.end
})
lastTag = tagName
}
if (options.start) {
options.start(tagName, attrs, unary, match.start, match.end)
}
}
function parseEndTag(tagName?: any, start?: any, end?: any) {
let pos, lowerCasedTagName
if (start == null) start = index
if (end == null) end = index
// Find the closest opened tag of the same type
if (tagName) {
lowerCasedTagName = tagName.toLowerCase()
for (pos = stack.length - 1; pos >= 0; pos--) {
if (stack[pos].lowerCasedTag === lowerCasedTagName) {
break
}
}
} else {
// If no tag name is provided, clean shop
pos = 0
}
if (pos >= 0) {
// Close all the open elements, up the stack
for (let i = stack.length - 1; i >= pos; i--) {
if (__DEV__ && (i > pos || !tagName) && options.warn) {
options.warn(`tag <${stack[i].tag}> has no matching end tag.`, {
start: stack[i].start,
end: stack[i].end
})
}
if (options.end) {
options.end(stack[i].tag, start, end)
}
}
// Remove the open elements from the stack
stack.length = pos
lastTag = pos && stack[pos - 1].tag
} else if (lowerCasedTagName === 'br') {
if (options.start) {
options.start(tagName, [], true, start, end)
}
} else if (lowerCasedTagName === 'p') {
if (options.start) {
options.start(tagName, [], false, start, end)
}
if (options.end) {
options.end(tagName, start, end)
}
}
}
}你需要在 Vue 编译器中找到 parseHTML,然后把它替换为 safeParseHTML。注意保持调用接口兼容。
此外,还可以加入回溯深度限制(tracking 正则备份深度、最坏回溯步数上限)等安全策略。
CVE-2024-6783漏洞攻击示例
// 攻击者进行原型污染
Object.prototype.staticClass = 'alert("XSS攻击")'
// Vue编译模板时会使用被污染的属性
new Vue({
template: '<div class="">Content</div>'
}).$mount('#app')下面是一个基于vue2.7.16【CVE-2024-6783:XSS / 原型污染漏洞】补丁:
vue/src/platforms/web/compiler/modules/class.js
vue/src/platforms/web/compiler/modules/style.js
修复前:
function genData (el: ASTElement): string {
let data = ''
if (el.staticClass) {
data += `staticClass:${el.staticClass},`
}
if (el.classBinding) {
data += `class:${el.classBinding},`
}
return data
}修复后:
function genData (el: ASTElement): string {
let data = ''
// 修复CVE-2024-6783:检查属性是否为对象自有属性,防止原型污染
if (el.hasOwnProperty('staticClass') && el.staticClass) {
data += `staticClass:${el.staticClass},`
}
if (el.hasOwnProperty('classBinding') && el.classBinding) {
data += `class:${el.classBinding},`
}
return data
}4.3 使用 Vue NES(Never-Ending Support)版本
HeroDevs 提供的 Vue NES 是一个对 Vue 2 提供长期安全支持的版本,其中包含多个安全补丁(例如对 CVE-2024-9506 的修复)(herodevs.com)。部分用户在社区报告中也提到他们用 fork 版本或 patched 版本去除 CVE 报警。(Reddit)
你可以考虑将 Vue 2 升级到 NES 版本作为一种折中方案。
需要注意:NES 是 付费方案,并非社区免费提供。
五、修复落地流程(建议操作步骤)
以下是一个推荐的修复流程:
- 备份 + 创建分支
在版本控制系统中创建一个安全修复分支。 - 安装 Patched 版本或补丁包
如果使用vue-template-compiler-patched或 patched Vue 包,先在 dev 环境尝试构建、测试是否有编译 / 兼容性问题。 - 引入自定义补丁
如果选择源码补丁方式,需要将补丁合入编译链(如 webpack loader、vue-loader 中的编译器路径指向你的补丁版)。 - 添加安全测试用例
编写覆盖性测试用例,模拟恶意模板或注入输入,确保程序不会因模板挂起或 XSS 损害。 - 灰度验证
在 staging 环境部署,监控性能指标(是否有模板编译慢、CPU 占用异常)与错误日志。 - 逐步上线
在确定无异常后,逐步将修复发布到生产环境。 - 长期监控 & 漏洞预警
关注 Vue 社区 / 安全公告,定期审计前端依赖库。对于 EoL 框架(如 Vue 2),建议尽快计划迁移或使用长期支持版本。
六、示例项目演示
下面我给出一个极简 Vue 2 项目示例,模拟如何集成 patched compiler 与安全补丁。
项目目录结构(简化):
my-vue2-project/
├─ src/
│ ├─ App.vue
│ └─ main.js
├─ patched/
│ └─ vue-template-compiler-patched/ ← 补丁包或源码拷贝
├─ webpack.config.js
└─ package.json
package.json(重点部分)
{
"dependencies": {
"vue": "^2.7.16"
},
"devDependencies": {
"vue-template-compiler": "npm:vue-template-compiler-patched@^2.7.16-patch.2"
}
}
webpack.config.js(让 vue-loader 使用 patched 编译器)
const path = require('path')
const VueLoaderPlugin = require('vue-loader/lib/plugin')
module.exports = {
mode: 'development',
entry: './src/main.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
resolve: {
alias: {
'vue-template-compiler': require.resolve('vue-template-compiler-patched')
}
},
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
}
]
},
plugins: [ new VueLoaderPlugin() ]
}
这样,当 vue-loader 要在内部 require vue-template-compiler 时,实际会被映射为 patched 版本,从而绕过原始漏洞风险。
你也可以在 patched 目录放置自己修改过的 html-parser.js 补丁,然后在构建链里把 vue-template-compiler 源码替换为你补丁版。
七、风险评估与注意事项
- 性能开销:补丁中的长度检查、正则判断可能引入性能损耗。务必做好性能测试,保证没有负面影响。
- 兼容性问题:补丁可能与某些高级模板语法、第三方插件冲突,需逐步确认兼容性。
- 安全不是绝对:补丁只能防护当前已知漏洞,不代表没有新的漏洞。
- 长期方案:迁移或支持版本:即便补丁可用,Vue 2 长期使用终究有安全隐患,建议逐步向 Vue 3 或其他框架迁移,或者使用长期支持版本。
八、总结
- CVE-2024-9506 是一个影响 Vue 2 的 ReDoS 漏洞,由于模板编译器中的正则回溯问题可被利用。(security.snyk.io)
- CVE-2024-6783 是一个针对
vue-template-compiler的 XSS / 原型污染漏洞。(GitHub) - 对于 Vue 2 项目而言,风险主要在于动态模板编译机制与不可信输入。
- 推荐采用补丁包(如
vue-template-compiler-patched)、源码增强补丁或迁移至长期支持版本(Vue NES)来缓解风险。 - 在修补过程中,要做好测试覆盖、灰度验证、性能监控和兼容性验证。
文章评论