性能优化:JavaScript SSA 前端解析方案的替代探索
YAK 的 SSA 构建系统旨在打造通用的 SSA 后端框架,先前采用 Antlr4 作为通用前端,将多种语言统一转换为 AST,再译为 SSA IR,以驱动静态审计逻辑。但随着拓展,测试 JavaScript 生成 SSA IR 时,Antlr4 在解析某些语言的特定语法结构时存在效率低下的问题。
为提升 JavaScript 静态分析效率,引入微软 TypeScript-Go 项目中的 Go 实现 parser 替代 Antlr 前端,显著优化性能。本文聚焦于基于 TypeScript-Go AST 构建 JavaScript SSA 表达形式,探讨面对表达式语句、解构赋值、控制流等复杂结构时的通用 SSA 生成策略,并分享过程中踩的坑与实战经验,为多语言静态审计中应用 SSA IR 提供启发与参考。
实际迁移背景
在先前的公众号文章《SSA IR 代码审计入门》 中,我们向大家介绍了 YAK 中 SSA 的基础架构。
目前,我们使用 Antlr4 作为各类语言的通用前端,通过 Antlr4 先将不同编程语言( Java, PHP, Golang)的源代码文件解析成 AST,再通过 AST 生成 SSA IR,进而可以通过一套规则去审计代码。
随着 YAK 支持语言种类增多,使用 Antlr4 遇到了一些问题。
例如:在 JavaScript 的 Parse 过程中,因为 JavaScript 语法特点,导致解析 JavaScript 速度很慢;前端经常使用 webpack 打包项目文件,生成类似于下图这种形式的 bundle 文件。
经过测试一个 6700 行、大小约为 240KB 的打包文件,在使用 M1 Max CPU 进行 Parse 的情况下也需要 9s 左右:
这个效率在接入 MITM 后解析网页 JavaScript 的应用场景下是不可接受的,这还只是翻译到 SSA 的时间开销,还没有考虑后续可能根据 SF 规则文件进行审计的情况。使用 IDE 的 Profiler 进行简单分析后,发现 Antlr4 前端根据语法规则解析此类文件时,将大部分时间花在了递归解析上。
经过筛选,我们最后选择使用微软的 Typescript-Go 项目:https://github.com/microsoft/typescript-go 中的 parser 包作为我们的 JavaScript 前端解析部分。
运行 Typescript-Go 项目内的 TestParseTypeScriptRepo 测试,仅用 9s 就完成了对 TypeScript 源码仓库的解析。
微软正在将 TypeScript 编译器从 JavaScript 移植到 Go,以显著提升性能。这个项目旨在改善开发者体验,尤其是在处理大型代码库的时候。当前的 TypeScript 在大项目中加载和检查时间较长,而新的 Go 实现将大幅提升编辑器启动速度和构建速度,减少内存使用。这个迁移的结果就是 Typescript-Go,同时未来的 TypeScript7 将基于新的 Go 实现。
将新前端生成的 AST 翻译成 SSA
尽管采用的编译前端不同,但是总体的翻译 SSA 思路是相同的。
处理左右值
- 左值 (lvalue):代表可以“找得到地址”的东西,通常是“可持续存在”的。
- 右值 (rvalue):代表“只是一个值”的东西,是“短命”的,计算完就扔掉。
在使用 Antlr4 作为编译前端时可以在 g4 语法文件中显式区分左右值,例如:
assignExpression
: leftExpressionList ('=' | ':=') expressionList
| leftExpression ('++' | '--')
| leftExpression inplaceAssignOperator expression
;
通过这种形式显式的区分左右值。这样在 Visit 每个 AST 的节点时,就可以明确区分左右值的处理,例如赋值语句中左值可以是一个变量,右值则是表达式的计算结果。但是在 TypeScript-Go 解析出的 AST 中并没有区分左右值,形如:
我们创建两个辅助函数来帮助我们根据上下文选择接收左值还是右值。
// VisitLeftValueExpression 只接收左值
func(b *builder) VisitLeftValueExpression(node *ast.Expression) *ssa.Variable {
lval, _ := b.VisitExpression(node, true)
return lval
}
// VisitRightValueExpression 只接收右值
func(b *builder) VisitRightValueExpression(node *ast.Expression) ssa.Value {
_, rval := b.VisitExpression(node, false)
return rval
}
处理表达式语句
JavaScript 的表达式语句整体来说种类较多。对于简单的常量表达式,例如数字字面量和字符串字面量等,直接使用 EmitConstInst 函数发出 opcode 即可。但对于 JS 中较为复杂的字面量,例如 ArrayLiteral 和 ObjectLiteral ,分别对应形如 [1, 2, 3]和 {"a": 1, "b": 2} 的写法。
我们分别采用 Slice 和 Map 来模拟,依次访问这些容器中的元素,元素的类型是右值表达式,这样在遇到嵌套的情况也能自然的处理。
对于二元表达式,其实大体可以分为两类,带赋值的和不带赋值的。带赋值的就是 a = 1 这种不带赋值的就是类似于 a + 1 这种类型,对于这两种情况,存在赋值那就需要访问左值表达式获取变量,再访问右值表达式获取值,然后使用 AssignVariable 进行赋值。同时,在二元表达式中还存在一些特殊的表达式例如Nullish Coalescing(??)。
let value = 0;
let resultOr = value || '默认值';
let resultNullish = value ?? '默认值';
console.log(resultOr); // 输出: "默认值"
console.log(resultNullish); // 输出: 0
这时需要引入控制使用 IF 分支来模拟这种情况,这也是 SSA 的特性决定的,因为在这种情况下 result 会存在 phi。
handlerJumpExpression := func(
cond func(string) ssa.Value,
trueExpr, falseExpr func() ssa.Value,
valueName string,
) ssa.Value {
// 为了聚合产生Phi指令
id := valueName
variable := b.CreateVariable(id)
b.AssignVariable(variable, b.EmitValueOnlyDeclare(id))
// 只需要使用b.WriteValue设置value到此ID,并最后调用b.ReadValue可聚合产生Phi指令,完成语句预期行为
ifb := b.CreateIfBuilder()
ifb.AppendItem(
func() ssa.Value {
return cond(id)
},
func() {
v := trueExpr()
variable := b.CreateVariable(id)
b.AssignVariable(variable, v)
},
)
ifb.SetElse(func() {
v := falseExpr()
variable := b.CreateVariable(id)
b.AssignVariable(variable, v)
})
ifb.Build()
// generator phi instruction
v := b.ReadValue(id)
v.SetName(b.GetEditor().GetTextFromOffset(node.Loc.Pos(), node.Loc.End()))
return v
}
处理赋值语句
首先,我们根据变量声明的 Flag,也就是 let const var 来决定变量作用域,再访问右值(如果存在 Initializer )和左值表达式。这里主要是存在一种解构声明的写法:
const array = [1, 2, 3];
const [a, b, c] = array;
console.log(a); // 输出: 1
console.log(b); // 输出: 2
console.log(c); // 输出: 3
解构赋值较为复杂,存在较多特性,例如:
- 默认值
解构时可以为变量提供默认值:
const [a = 10, b = 20] = [5];
console.log(a); // 输出: 5console.log(b); // 输出: 20
- 嵌套解构解构可以用于嵌套结构:
const nested = { foo: { bar: 'baz' } };
const { foo: { bar } } = nested;
console.log(bar); // 输出: baz
- 剩余元素
在解构中使用剩余元素来收集剩余的数组元素或对象属性:
const [first, ...rest] = [1, 2, 3, 4];
console.log(first); // 输出: 1console.log(rest); // 输出: [2, 3, 4]const { a, ...others } = { a: 1, b: 2, c: 3 };
console.log(a); // 输出: 1console.log(others); // 输出: { b: 2, c: 3 }
支持解构的数据类型有两种 Array 和 Object,因此这两种情况要分开处理,使用递归处理循环嵌套,如果不存在解构出的结果但是存在默认值(Initializer)就使用默认值, 对于 rest 的情况将整个右侧表达式赋值,确保不丢失变量。
处理流程控制结构
传统的编程语言中的控制流结构一般是 if switch 还有 while do-while for 这样的循环结构。在 SSA 中已经提供了基础的 API 函数来处理这些结构。
- IF
// 创建if构建器
ifBuilder := b.CreateIfBuilder()
// 辅助结构体
type conditionBlock struct {
condition func() ssa.Value
block func()
}
// 递归收集所有if-else if-else链
var collectIfChain func(ifNode *ast.IfStatement) ([]conditionBlock, func())
collectIfChain = func(ifNode *ast.IfStatement) ([]conditionBlock, func()) {
var blocks []conditionBlock
var elseFunc func()
// 添加当前if条件和块
blocks = append(blocks, conditionBlock{
condition: func() ssa.Value {
if ifNode.Expression != nil {
return b.VisitRightValueExpression(ifNode.Expression)
}
return nil
},
block: func() {
if ifNode.ThenStatement != nil {
b.VisitStatement(ifNode.ThenStatement)
}
},
})
// 处理else部分
if ifNode.ElseStatement != nil {
if ifNode.ElseStatement.Kind == ast.KindIfStatement {
// 是else-if,递归收集
elseIfBlocks, nestedElse := collectIfChain(ifNode.ElseStatement.AsIfStatement())
blocks = append(blocks, elseIfBlocks...)
elseFunc = nestedElse
} else {
// 是纯else
elseFunc = func() {
b.VisitStatement(ifNode.ElseStatement)
}
}
}
return blocks, elseFunc
}
// 收集所有条件块
blocks, elseBlock := collectIfChain(node)
// 添加到if构建器
for _, block := range blocks {
ifBuilder.AppendItem(block.condition, block.block)
}
// 设置最终的else块
if elseBlock != nil {
ifBuilder.SetElse(elseBlock)
}
// 构建并执行if语句
ifBuilder.Build()
对于 if 语句处理其实和之前使用 Antlr 的情况有一些区别,Antlr 处理 IF 是按以下规则处理:
ifStatement
: If '(' expressionSequence ')' statement (Else If '(' expressionSequence ')' statement)* elseBlock?
;
相当于分为 IF ELSE-IF ELSE 这样的三段结构,但是 TypeScript-Go 解析出的 AST 采用的是 THEN ELSE 这样的两段结构,因此需要递归收集 ELSE-IF 的情况。
type IfStatement struct {
StatementBase
compositeNodeBase
Expression *Expression // Expression
ThenStatement *Statement // Statement
ElseStatement *Statement // Statement. Optional
}
- SWITCH
switchBuilder := b.BuildSwitch()
switchBuilder.AutoBreak = false
// 设置switch的表达式
switchBuilder.BuildCondition(func() ssa.Value {
exp := b.VisitRightValueExpression(node.Expression)
return exp
})
// 计算case分支数量
caseBlock := node.CaseBlock.AsCaseBlock()
var caseCount int
var commonCase []*ast.Node
var defaultCase *ast.Node
if caseBlock.Clauses != nil && caseBlock.Clauses.Nodes != nil {
lastCase := caseBlock.Clauses.Nodes[len(caseBlock.Clauses.Nodes)-1].AsCaseOrDefaultClause()
if lastCase.Expression == nil {
defaultCase = lastCase.AsNode()
commonCase = caseBlock.Clauses.Nodes[:len(caseBlock.Clauses.Nodes)-1]
} else {
commonCase = caseBlock.Clauses.Nodes
}
if defaultCase != nil {
caseCount = len(caseBlock.Clauses.Nodes) - 1
} else {
caseCount = len(caseBlock.Clauses.Nodes)
}
} else {
caseCount = 0
}
switchBuilder.BuildCaseSize(caseCount)
switchBuilder.SetCase(func(i int) []ssa.Value {
switchCase := commonCase[i].AsCaseOrDefaultClause()
return []ssa.Value{b.VisitRightValueExpression(switchCase.Expression)}
})
switchBuilder.BuildBody(func(i int) {
switchCase := commonCase[i].AsCaseOrDefaultClause()
b.VisitStatements(switchCase.Statements)
})
if defaultCase != nil {
switchBuilder.BuildDefault(func() {
switchDefault := defaultCase.AsCaseOrDefaultClause()
b.VisitStatements(switchDefault.Statements)
})
}
switchBuilder.Finish()
SWITCH 语句的翻译比较贴合语言的特性,这里值得一提的是,不同语言的 switch 在 case 语句执行完成后是否自动 break 的行为上存在一定差异。例如 Golang 需要手动指定 fallthrough 才会继续执行,但是在许多其他语言例如 C、C++、Java 和 JavaScript 中 switch 语句如果没有遇到 break,会继续执行后续的 case 语句。
因此需要根据翻译的语言设置 switchBuilder.AutoBreak(true/false)。
- WHILE 和 DO-WHILE
// VisitWhileStatement 访问while语句 - 简单条件循环
func(b *builder) VisitWhileStatement(node *ast.WhileStatement) interface{} {
if node == nil {
return nil
}
recoverRange := b.GetRecoverRange(b.sourceFile, &node.Loc, "")
defer recoverRange()
// 创建循环构建器
loop := b.CreateLoopBuilder()
// 设置循环条件
loop.SetCondition(func() ssa.Value {
if node.Expression != nil {
condition := b.VisitRightValueExpression(node.Expression)
if condition == nil {
return b.EmitConstInst(true)
}
return condition
}
return b.EmitConstInst(true)
})
// 设置循环体
loop.SetBody(func() {
if node.Statement != nil {
b.VisitStatement(node.Statement)
}
})
// 完成循环构建
loop.Finish()
return nil
}
// VisitDoStatement 访问do-while语句 - 至少执行一次的循环
func(b *builder) VisitDoStatement(node *ast.DoStatement) interface{} {
if node == nil {
return nil
}
recoverRange := b.GetRecoverRange(b.sourceFile, &node.Loc, "")
defer recoverRange()
// 创建一个无条件循环(条件永远为true)
loop := b.CreateLoopBuilder()
// 设置循环条件为true,这样循环体至少会执行一次
loop.SetCondition(func() ssa.Value {
return b.EmitConstInst(true)
})
// 设置循环体
loop.SetBody(func() {
// 执行循环体
if node.Statement != nil {
b.VisitStatement(node.Statement)
}
// 检查do-while的条件,如果条件为false则break
if node.Expression != nil {
condition := b.VisitRightValueExpression(node.Expression)
if condition == nil {
// 如果无法获取条件,可以选择继续循环或退出
// 这里我们选择退出循环
b.Break()
return
}
// 创建条件分支,当条件为false时退出循环
ifBuilder := b.CreateIfBuilder()
ifBuilder.SetCondition(func() ssa.Value {
// 对条件取反,当原条件为false时进入if分支
return b.EmitUnOp(ssa.OpNeg, condition)
}, func() {
b.Break() // 退出循环
})
ifBuilder.Build()
}
})
// 完成循环构建
loop.Finish()
return nil
}
其中 DO-WHILE 是设置条件默认为 True 来保证循环体至少执行一次。
- For
// VisitForStatement 访问for语句 - 经典三语句循环
func(b *builder) VisitForStatement(node *ast.ForStatement) interface{} {
if node == nil {
return nil
}
recoverRange := b.GetRecoverRange(b.sourceFile, &node.Loc, "")
defer recoverRange()
// 创建循环构建器
loop := b.CreateLoopBuilder()
// 设置初始化语句(first)
if node.Initializer != nil {
loop.SetFirst(func() []ssa.Value {
var results []ssa.Value
switch node.Initializer.Kind {
case ast.KindVariableDeclarationList:
// 变量声明初始化:for(let i = 0; ...)
b.VisitVariableDeclarationList(node.Initializer.AsVariableDeclarationList().AsNode())
// 变量声明可能不会返回值,这里不追加到results
default:
// 表达式初始化:for(i = 0; ...)
result := b.VisitRightValueExpression(node.Initializer)
if result != nil {
results = append(results, result)
}
}
return results
})
}
// 设置条件语句(condition)
loop.SetCondition(func() ssa.Value {
if node.Condition != nil {
condition := b.VisitRightValueExpression(node.Condition)
if condition == nil {
return b.EmitConstInst(true)
}
return condition
}
// 没有条件表达式,默认为true(无限循环)
return b.EmitConstInst(true)
})
// 设置增量语句(third/incrementor)
if node.Incrementor != nil {
loop.SetThird(func() []ssa.Value {
result := b.VisitRightValueExpression(node.Incrementor)
if result != nil {
return []ssa.Value{result}
}
return nil
})
}
// 设置循环体
loop.SetBody(func() {
if node.Statement != nil {
b.VisitStatement(node.Statement)
}
})
// 完成循环构建
loop.Finish()
return nil
}
JS 中大体存在三种 For 的情况,一种是标准的三语句 For 形式,另外两种是 For-In 和 For-Of。后两种通过 SSA 中提供的接口 EmitNext函数实现迭代器的翻译。
func(f *FunctionBuilder) EmitNext(iter Value, isIn bool) (key, field, ok Value)
控制流翻译验证
原先 SSA API 中只存在基于 Value 的图生成,这使得翻译控制流结构时不太直观,因为只能根据生成的 SSA-IR 来人工分析 JMP 和基本块之间的关系。因此可以考虑使用 dot 语言简单生成控制流结构图。
SSA 中的基本块结构如下:
// implement Value
type BasicBlock struct {
anValue
Index int
// BasicBlock graph
Preds, Succs []Value
/*
if Condition == true: this block reach
*/
setReachable bool
canBeReached int
Condition Value
// instruction list
Insts []Instruction
Phis []Value
// error catch
Handler *ErrorHandler
// for build
ScopeTable ScopeIF
finish bool // if emitJump finish!
}
可以看到结构体中存在前驱和后驱基本块,因此我们只需依次遍历每个基本块添加后继边即可。
// 首先添加所有基本块节点
for _, blockInst := range function.Blocks {
block, ok := ssa.ToBasicBlock(blockInst)
if !ok {
continue
}
g.addBlockNode(block)
}
// 然后添加边
for _, blockInst := range function.Blocks {
block, ok := ssa.ToBasicBlock(blockInst)
if !ok {
continue
}
fromNodeId, exists := g.Block2Node[block]
if !exists {
continue
}
// 添加后继边
for _, succInst := range block.Succs {
succ, ok := ssa.ToBasicBlock(succInst)
if !ok {
continue
}
toNodeId, exists := g.Block2Node[succ]
if !exists {
continue
}
// 检查是否有条件边
edgeLabel := ""
g.AddEdge(fromNodeId, toNodeId, edgeLabel)
}
}
例如对于如下 JavaScript 代码:
a = 1
println(a) // 1
if(c) {
if(b) {
a = 2
println(a) // 2
return
}else {
a = 3
println(a) // 3
return
}
println(a) // unreachable // phi[2, 3]
}
println(a) // phi(a)[Undefined-a,1]
将会生成如下控制流图 (使用 GraphvizOnline 渲染 dot ):
迁移前端后的性能表现
通过将前端解析器替换为 TypeScript-Go 的 Parser,webpack 打包文件的翻译效率显著提升,解析时间从原先的 9 秒缩短至 0.45 秒。
总结
通过引入 TypeScript-Go 作为 JavaScript 的解析前端,并结合 SSA 架构的表达能力,我们在提升分析效率的同时,也增强了对复杂语法结构的建模能力。
目前,SSA 对动态语言的支持正在逐步完善,期待未来在前端代码分析场景中发挥更大的价值。
本文首发于 Yak Project 公众号,阅读原文。
