MVEL解析表达式

0x00 概述

在google搜索Expression Language时,发现在维基百科中相关的词条叫做“统一表达式语言”。再次词条的引用部分,被一行内容吸引到:

MVEL - 一个被众多Java项目使用的开源的表达式语言。

最流行的内容研究的现阶段价值也就最大,攻防更是如此。所以,我打算在JWEI的研究中,先拿它来开刀。

本篇文章将针对MVEL解析表达式的过程进行简单的分析,来帮助我们在未来的研究中探索这种表达式的特性。

0x01 MVEL表达式执行方法

MVEL运行时提供给使用者两种使用模式——解释模式和编译模式。解释模式是一种无状态的即时编译并执行的特设模式,不会产生中间产物,但是表达式执行的速度会减慢。编译模式会产生大量的编译中间产物,用于缓存和预执行,但是执行速度比解释模式更快。

以上是官方文档中向使用者描述的两种使用方法,下面我们来看下,官方对于这两种模式的实现示例。

解释模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.mvel.MVEL;
public class MVELTest {
public static void main(String[] args) {
String expression = "foobar > 99";
Map vars = new HashMap();
vars.put("foobar", new Integer(100));
// We know this expression should return a boolean.
Boolean result = (Boolean) MVEL.eval(expression, vars);
if (result.booleanValue()) {
System.out.println("It works!");
}
}
}

编译模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import org.mvel.MVEL;
public class MVELTest2 {
public static void main(String[] args) {
String expression = "foobar > 99";
// Compile the expression.
Serializable compiled = MVEL.compileExpression(expression);
Map vars = new HashMap();
vars.put("foobar", new Integer(100));
// Now we execute it.
Boolean result = (Boolean) MVEL.executeExpression(compiled, vars);
if (result.booleanValue()) {
System.out.println("It works!");
}
}
}

除了上面的两种调用方法,我在看ElasticSearch代码时,发现它使用了另外的一种方法,来实现MVEL的执行。这种方法属于编译模式,我的测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.mvel2.MVEL;
import org.mvel2.compiler.ExecutableStatement;
import org.mvel2.integration.impl.MapVariableResolverFactory;
public class MvelDemo {
public static void main(String[] args) {
String expression = "foobar > 99";
String str = "a=123";
String exp = ";new java.lang.ProcessBuilder(\"calc\").start();";
MapVariableResolverFactory resolver = new MapVariableResolverFactory(new HashMap());
Map vars = new HashMap();
vars.put("foobar", new Integer(100));
ExecutableStatement script = (ExecutableStatement) MVEL.compileExpression(str+exp);
script.getValue(null,resolver);
}
}

即通过编译后的内容可以转换成ExecutableStatement类型,通过这种类型的getValue方法,也是可以执行表达式内容的。

0x02 解析执行过程

我们分析过程中用来测试的表达式是:“a=123;new java.lang.ProcessBuilder(“calc”).start();”。

测试代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.mvel2.MVEL;
import org.mvel2.compiler.ExecutableStatement;
public class MvelDemo {
public static void main(String[] args) {
String exp = "a=123;new java.lang.ProcessBuilder(\"calc\").start();";
Map vars = new HashMap();
vars.put("foobar", new Integer(100));
String result = MVEL.eval(exp, vars).toString();
}
}

首先进入到MVEL中的eval方法中,在这个方法中初始化MVELInterpretedRuntime类,并且调用它的parse方法。在parse方法中最主要的步骤是调用parseAndExecuteInterpreted方法,这个方法是解释模式中最终处理表达式执行的方法。我们来看它最开始的一部分代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
private Object parseAndExecuteInterpreted() {
ASTNode tk = null;
int operator;
lastWasIdentifier = false;
try {
while ((tk = nextToken()) != null) {
holdOverRegister = null;
if (lastWasIdentifier && lastNode.isDiscard()) {
stk.discard();
}
/**
* If we are at the beginning of a statement, then we immediately push the first token
* onto the stack.
*/
if (stk.isEmpty()) {
if ((tk.fields & ASTNode.STACKLANG) != 0) {
stk.push(tk.getReducedValue(stk, ctx, variableFactory));
Object o = stk.peek();
if (o instanceof Integer) {
arithmeticFunctionReduction((Integer) o);
}
}
else {
stk.push(tk.getReducedValue(ctx, ctx, variableFactory));
}

tk=nextToken()这条语句会将程序带入到AbstractParser这个类的nextToken方法中。而这个类的位置是org.mvel2.compiler.AbstractParser,我们在进入这个类后,可以看到下面这样的一行注释:

1
2
3
4
5
/**
* This is the core parser that the subparsers extend.
*
* @author Christopher Brock
*/

由此可见,不管使用哪种执行表达式的方法,对于表达式的解析都是由这个类来完成的。我们继续来跟进nextToken的执行过程。

初始化抓取标识capture,跳过debug这些内容,检测游标所指位置字符是否符合规范(标准的变量名),若符合则开启抓取(抓取标识置true),并且游标加一。然后判断此时游标字符是否符合规范,如果是则游标加一,不断往复。

检测游标所指之前内容是否存在NEW、IF、CONTAINS等操作关键字,我们此时的内容为a,所以跳过这个环节。skipWhitespace方法跳过回车、换行和注释等空白内容,此时游标指在“=”。

进入抓取循环,识别出当前字符为“=”,进入等号处理分支,在此分支的非操作符处理块中处理我们的表达式。先游标加一,此时游标指向“1”。通过captureToEOS方法抓取从当前位置到语句结束内容,即从“1”到分号前的内容(123),此时游标指向“;”。最后返回AssignmentNode实例,其中已解析完成语句内容(变量名a,值123)。

返回到paraseAndExcuteInterpreted方法,通过返回节点实例的getReducedValue方法来处理“a=123”这条语句(通过递归解析子表达式)。AssignmentNode的getReduceValue处理语句两种方式,一种是对已存在的变量,进行操作后,然后返回上下文环境;另一种是对不存在的变量进行创建,然后通过getValue返回。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
public Object getReducedValue(Object ctx, Object thisValue, VariableResolverFactory factory) {
checkNameSafety(varName );
if ( col) {
PropertyAccessor.set(factory.getVariableResolver( varName).getValue(), factory, index, ctx = MVEL.eval( expr, start , offset , ctx, factory), pCtx);
}
else {
return factory.createVariable(varName, MVEL. eval( expr, start , offset , ctx, factory)).getValue();
}
return ctx;
}

这里我们又进入了一个eval,不过这回eval的主角变成了“123”

根据返回的内容创建SimpleSTValueResolver实例,调用getValue方法返回其值,然后压入到ExecutionStatck中。

1

下面继续循环内容,游标向后移至语句截止符“;”,nextToken返回截止节点信息

1
2
3
4
case ';' :
cursor++;
lastWasIdentifier = false;
return lastNode = new EndOfStatement(pCtx);

2

ExecutionStatck不为空(之前已经压入了“123”)则继续向下执行,进入判断操作符分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
switch (procBooleanOperator(operator = tk.getOperator())) {
case RETURN :
variableFactory.setTiltFlag(true);
return stk .pop();
case OP_TERMINATE :
return stk .peek();
case OP_RESET_FRAME :
continue;
case OP_OVERFLOW :
if (!tk.isOperator()) {
if (!(stk .peek() instanceof Class)) {
throw new CompileException("unexpected token or unknown identifier:" + tk.getName(), expr, st);
}
variableFactory.createVariable(tk.getName(), null, (Class) stk.peek());
}
continue;
}

分号属于OP_RESTE_FRAME,并且通过procBooleanOperator方法清空ExecutionStatck等残留信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
case END_OF_STMT:
/**
* Assignments are a special scenario for dealing with the stack. Assignments are basically like
* held -over failures that basically kickstart the parser when an assignment operator is is
* encountered. The originating token is captured, and the the parser is told to march on. The
* resultant value on the stack is then used to populate the target variable.
*
* The other scenario in which we don't want to wipe the stack, is when we hit the end of the
* statement, because that top stack value is the value we want back from the parser.
*/
if (hasMore()) {
holdOverRegister = stk .pop();
stk.clear();
}
return OP_RESET_FRAME ;

然后进入下一循环,读取后面的语句。重复之前的捕获关键字的过程捕获到“NEW”(通过空格捕获到的),通过captureToNextTokenJunction 从当前位置抓取到下一个连接点(junction),此时游标指向“(”(“()”中所包含的内容,应作为子语句所解析,所以“(”之前部分应先解析)。然后根据圆括号之前的内容,构造NewObjectPrototype实例,并以此构建节点,最后返回节点。

1

然后进入org.mvel2.ast.NewObjectNode中的getReducedValue,这里“123”那里差不多,使用org.mvel2.MVEL.eval来递归解析圆括号中的内容。就像“123”那样解析,不同的是被双引号包围,所以作为字符串来解析,返回Literal节点。然后压栈,进行一些后续操作,结束操作返回上一层调用(NewObjectNode那里)。

后面就是通过反射构造实例,初始化执行的调用了。这里最需要注意的是org.mvel2.PropertyAccessor这个类,这个类中方法的调用大多是与反射相关的。所以一旦在分析过程中进入了这个类,并且调用了某个比较大的方法,那么很有可能这种MVEL表达式的用法会造成一些强大的破坏,例如这里:

再然后就如同上一条语句那样,检测到语句结束字符“;”,返回上一层,结束解析和执行。

0x03 一些方法功能的记录

AssignmentNode 创建节点实例,解析单条语句中的变量和值,或者方法操作。

balancedCapture 获取(、[分支中的内容。

captureStringLiteral 获取制定包围符中的字符串(if (expr[cursor] == ‘\\‘) cursor++;这句比较有趣)。

1
2
3
4
5
6
7
8
9
10
11
public static int captureStringLiteral(final char type, final char[] expr, int cursor, int end) {
while (++cursor < end && expr[cursor] != type) {
if (expr [cursor] == '\\' ) cursor++;
}
if (cursor >= end || expr[cursor] != type) {
throw new CompileException("unterminated string literal" , expr , cursor);
}
return cursor;
}

captureToEOS 抓取从当前位置到语句结束。

captureToNextTokenJunction 从当前位置抓取到下一个连接点(junction),即遇到“(”、“/”、“/*”和空白符时终止,遇到“[”则向后偏移至“]”。

captureContructorAndResidual 抓取()中的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static String[] captureContructorAndResidual(char[] cs, int start, int offset) {
int depth = 0;
int end = start + offset;
boolean inQuotes = false;
for ( int i = start; i < end; i++) {
switch (cs[i]) {
case '"' :
inQuotes = !inQuotes;
break;
case '(' :
depth++;
break;
case ')' :
if (!inQuotes) {
if (1 == depth--) {
return new String[]{createStringTrimmed (cs, start, ++i - start), createStringTrimmed(cs, i, end - i)};
}
}
}
}
return new String[]{new String(cs, start, offset)};
}

PropertyAccessor的getNormal 通过反射执行表达式。