笔者是大四学生,初涉安全的萌新,如果文章有错误之处还请大佬指出!
最初考虑采用纯正则等方式匹配,但这种方式过于严格,程序员编写的代码有各种可能的组合
于是尝试自行实现Java词法分析和语法分析,稍作尝试后发现这不现实,一方面涉及到编译原理的一些算法,另外相比C语言等,Java语言本身较复杂,不是短时间能搞定的,深入研究编译原理背离了做审计工具的目的
后来找到了几种解决方案:Antlr,JavaCC,JDT,javaparser
经过对比,最终选择javaparser项目,该项目似乎是基于JavaCC,核心开发者是effective java的作者。使用起来比较方便,可以简单地以依赖的方式导入
com.github.javaparser
javaparser-symbol-solver-core
3.23.0
笔者本想采用Golang编写该工具,查找相关资料后发现,Golang本身提供AST库,可以对Golang本身做语法分析,但找不到实现Java语法分析的库(考虑后续复习下编译原理自己尝试)
javaparser最根本的类是CompilationUnit,如果我们想对代码做分析,首先需要实例化该对象
// code是读入的java代码字符串
// 也有其他重载,但这个比较方便
CompilationUnit compilationUnit = StaticJavaParser.parse(code);
给出一段最简单的XSS代码
package testcode.xss.servlets;
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class Demo extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String param = req.getParameter("xss");
resp.getWriter().write(param);
}
}
针对于该案例,我们写审计工具的原理
关于验证导入包的情况,简单做了一个方法
public static boolean isImported(CompilationUnit compilationUnit, String fullName) {
// lambda表达式中必须用这种方式修改值
final boolean[] flag = new boolean[1];
compilationUnit.getImports().forEach(i -> {
if (i.getName().asString().equals(fullName)) {
flag[0] = true;
}
});
return flag[0];
}
如果要验证请求和相应包的导入情况
final String SERVLET_REQUEST_IMPORT = "javax.servlet.http.HttpServletRequest";
final String SERVLET_RESPONSE_IMPORT = "javax.servlet.http.HttpServletResponse";
boolean imported = isImported(compilationUnit, SERVLET_REQUEST_IMPORT) &&
isImported(compilationUnit, SERVLET_RESPONSE_IMPORT);
if (!imported) {
logger.warn("no servlet xss");
return results;
}
首先拿到Demo这个Class,因为一个java文件中不一定只有一个类
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
// 不是接口且不是抽象类
.filter(c->!c.isInterface()&&!c.isAbstract()).forEach(c->{
System.out.println(c.getNameAsString());
});
// 输出
// Demo
进一步,我们需要判断该类是否继承自HttpServlet
compilationUnit.findAll(ClassOrInterfaceDeclaration.class).stream()
.filter(c->!c.isInterface()&&!c.isAbstract())
.forEach(c->{
boolean isHttpServlet = false;
// 继续用lambda反而不方便
NodeList eList = c.getExtendedTypes();
for (ClassOrInterfaceType e:eList){
if (e.asString().equals("HttpServlet")){
isHttpServlet = true;
break;
}
}
if (isHttpServlet){
// 这里面做进一步的逻辑
System.out.println("hello");
}
});
只有得到类节点,才可以继续遍历抽象语法树拿到方法等信息
遍历得到方法节点,并且拿到具体的请求和响应参数名称
之所以要拿到方法参数名,是为了做进一步的追踪
if (isHttpServlet){
c.getMethods().forEach(m->{
// lambda不允许直接复制,所以借助map
Map params = new HashMap<>();
m.getParameters().forEach(p->{
// resp(真实情况未必一定是resp)
if (p.getType().asString().equals("HttpServletResponse")) {
params.put("response", p.getName().asString());
}
// req(真实情况未必一定是req)
if (p.getType().asString().equals("HttpServletRequest")) {
params.put("request", p.getName().asString());
}
});
System.out.println("request:"+params.get("request"));
System.out.println("response:"+params.get("response"));
});
}
// 输出
// request:req
// response:resp
审计漏洞的关键点就在于参数的可控,这也是难点
就本案例而言,如果某个参数是req.getParameter("…")获取的,那么就可以认为是可控
实际上这个req并不一定是req,可能是request,requ等,这也是上一步需要一个map保存的原因
可以加上参数校验
if (params.get("request") != null && !params.get("request").equals("") ||
params.get("response") != null && !params.get("response").equals("")) {
return;
}
获取所有的赋值表达式,确定是否调用了req.getParameter这样的参数
并且参考上文的方式使用map保存这个参数结果,用于后续校验
Map var = new HashMap<>();
m.findAll(VariableDeclarationExpr.class).forEach(v->{
MethodCallExpr right;
boolean isGetParam = false;
// 获取赋值语句右边部分
if (v.getVariables().get(0).getInitializer().get() instanceof MethodCallExpr) {
// 强转不验证会出问题
right = (MethodCallExpr) v.getVariables().get(0).getInitializer().get();
if (right.getScope().get().toString().equals(params.get("request"))){
// 确定是否调用了req.getParameter
if (right.getName().asString().equals("getParameter")){
isGetParam = true;
}
}
}
if(isGetParam){
var.put("reqParameter",v.getVariables().get(0).getNameAsString());
logger.info("find req.getParameter");
}
});
触发点在本案例中是resp.getWriter().write()
这是一个方法调用,所以搜索MethodCallerExpr
m.findAll(MethodCallExpr.class).forEach(im -> {
if (im.getScope().get().toString().equals(params.get("response"))) {
// 如果调用了response.getWriter
if (im.getName().asString().equals("getWriter")) {
MethodCallExpr method;
// 直接强转会出问题
if (im.getParentNode().get() instanceof MethodCallExpr) {
// 后一步方法
method = (MethodCallExpr) im.getParentNode().get();
} else {
return;
}
// response.getWriter.write();
if (method.getName().asString().equals("write")) {
// 该案例中write的是常量param,所以搜NameExpr
method.findAll(NameExpr.class).forEach(name -> {
// 这里用到了之前保存在map的reqParameter
if (name.getNameAsString().equals(var.get("reqParameter"))) {
// 认为存在XSS
logger.info("find xss");
}
});
}
}
}
});
针对于这个基础案例,可以再加入几个规则,针对于response.getOutputStream方式
if (im.getName().asString().equals("getOutputStream")) {
MethodCallExpr method;
if (im.getParentNode().get() instanceof MethodCallExpr) {
method = (MethodCallExpr) im.getParentNode().get();
} else {
return;
}
// response.getOutputStream.print();
// response.getOutputStream.println();
if (method.getName().asString().equals("print") ||
method.getName().asString().equals("println")) {
method.findAll(NameExpr.class).forEach(name -> {
if (name.getNameAsString().equals(var.get("reqParameter"))) {
logger.info("find xss");
}
});
}
}
尝试让原来的XSS代码复杂一些,看看审计的效果
public class Demo extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
String param = req.getParameter("xss");
if(param.equals("hello world")){
// do other
}else{
demoService.doSearch();
}
int a = 1;
int b = 2;
logger.log(String.format("%d+%d=%d",a,b,a+b));
try{
// todo
}catch (Exception e){
e.printStackTrace();
}
resp.getWriter().write(param);
}
}
这篇文章只针对最基本的Servlet XSS做了审计,实际上无论从广度还是深度,都有巨大的工作量:
代码在github:https://github.com/EmYiQing/XVulnFinder
【获取网络安全学习资料以及工具】可以关注私我