引子
通过《OpenRASP Java源码分析与总结(一)——启动与检测》的分析,我们对OpenRASP Java部分的整体启动和检测流程有了大致的了解。本文将对OpenRASP所支持的js插件、安全基线等几类检测方式中,选取几个比较有代表性的检测进行详细分析。
准备
1
-
agent Java代码包结构
/+ |-boot // rasp.jar代码所在目录 | +-engine+ // rasp-engine.jar代码所在目录 +src+ +main+ +java+ +com.baidu.openrasp+ |-config // 配置代码 |-hook+ // hoot代码,主要分析这个目录下的代码 | |-file // 文件hook | |-server // 服务器hook | |-sql // sql hook | +-ssrf // ssrf hook | |-plugin+ // 插件代码 | |-antlr // sql语法分析代码 | |-checker+ // checker代码 | | |-js // js checker代码 | | |-local | | +-policy // 安全基线checker代码 | +-js.engine // js引擎代码 | |-tool +-transformer
-
《OpenRASP Java源码分析与总结(一)——启动与检测》中介绍过检测的基本流程,入口为hook,hook再委托给checker。因此,下文的分析也将按照此流程进行:先分析入口hook,再分析对应的checker。
-
分析过程中涉及一些常规Web服务器、JDBC规范、SQL语法规则,本文不做详细分析,请自行学习了解。
-
分析过程中涉及到的配置和代码,均为精简过,去掉了和分析无关的内容,所以和真正源码并非一一对应,但对了解整个过程已经足够了。
源码分析
2
Web请求检测
2.1
Tomcat是Java生态圈里最常见和常用的Web容器,它实现了Servlet规范(版本不同实现的规范版本也不同),所有的Web请求均由Tomcat接收解析后,调用用户程序进行处理。因此对Web请求检测可以从Tomcat相关的Hook进行入手,ApplicationFilterHook就是其中一个:
代码2-1
@HookAnnotationpublic class ApplicationFilterHook extends ServerRequestHook { // 1 @Override public boolean isClassMatched(String className) { return className.endsWith("apache/catalina/core/ApplicationFilterChain"); } @Override protected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException { // 2 String src = getInvokeStaticSrc(ServerRequestHook.class, "checkRequest", "$0,$1,$2", Object.class, Object.class, Object.class); // 3 insertBefore(ctClass, "doFilter", null, src); }}public abstract class ServerRequestHook extends AbstractClassHook { // 4 public static void checkRequest(Object filter, Object request, Object response) { HookHandler.checkRequest(filter, request, response); }}
-
判断入参类是否需要被hoook。这里检查的是Tomcat对Servlet规范中FilterChain的实现类ApplicationFilterChain。通过Tomcat部署的Web应用的所有HTTP请求均会经过ApplicationFilterChain,因此以此类作为hook入口非常适合;
-
获取静态方法HookHandler.checkRequest()的源码;
-
将2中的代码插入到实例方法ApplicationFilterChain.doFilter()的开始处。ApplicationFilterChain.doFilter()为HTTP请求的入口方法,在这里可以对所有的HTTP请求进行检测;
-
调用HookHandler.checkRequest()进行请求检测。
代码2-2
public class HookHandler { public static void checkRequest(Object servlet, Object request, Object response) { // 1 HttpServletRequest requestContainer = new HttpServletRequest(request); // 2 HttpServletResponse responseContainer = new HttpServletResponse(response); // 3 responseContainer.setHeader(OPEN_RASP_HEADER_KEY, OPEN_RASP_HEADER_VALUE); // 4 responseContainer.setHeader(REQUEST_ID_HEADER_KEY, requestContainer.getRequestId()); // 5 requestCache.set(requestContainer); // 6 responseCache.set(responseContainer); // 7 doCheck(CheckParameter.Type.REQUEST, JSContext.getUndefinedValue()); }}
-
包装原有的request对象为自定义的request;
-
包装原有的response对象为自定义的response;
-
增加请求头X-Protected-By,值为OpenRASP;
-
增加请求头X-Request-ID,值为一个UUID;
-
保存自定义的request到当前线程上下文中;
-
保存自定义的response到当前线程上下文中;
-
检测请求。
根据7中的第一个入参,查找CheckParameter.Type:
public class CheckParameter { public enum Type { ..., REQUEST("request", new JsChecker()), ...;}
可以得知,CheckParameter.Type.REQUEST对应的Checker实现为JsChecker。因此,最终检测逻辑即委托给了JsChecker。在《OpenRASP Java源码分析与总结(一)——启动与检测》中我们曾分析过,JsChecker的检测逻辑实际又委托给了plugins目录下的js实现。
OpenRASP官方仅提供了几个请求检测的Demo插件作为演示使用,如果要投入到实际生产环境,还需要做进一步的开发。下面,我们分别看下官方提供的两个Demo插件,了解下请求检测的大致逻辑,分别是plugins/addons/001-xss-demo.js和plugins/addons/002-detect-scanner.js:
代码2-3
var plugin = new RASP('offical')// 1plugin.register('request', function(params, context) { // 2 function detectXSS(params, context) { // 3 var xssRegex = /||javascript:(?!(?:history\.(?:go|back)|void\(0\)))/i var parameters = context.parameter; var message = ''; // 4 Object.keys(parameters).some(function (name) { parameters[name].some(function (value) { if (xssRegex.test(value)) { message = 'XSS 攻击: ' + value; return true; } }); }); return message } // 5 var message = detectXSS(params, context) if (message.length) { return {action: 'block', message: message, confidence: 90} } return clean })
-
注册一个request检测函数;
-
定义一个XSS检测函数;
-
定义XSS检测的正则表达式;
-
循环请求里的每个参数,判断是否匹配XSS正则表达式;
-
调用XSS检测函数,如果命中,则返回命中信息。
代码2-4
var plugin = new RASP('offical')plugin.register('request', function(params, context) { var foundScanner = false // 1 var scannerUA = [..., "bsqlbf", "sqlmap", "nessus", "arachni", "metis", ...] var headers = context.header // 2 var ua = headers['user-agent'] if (ua) { // 3 for (var i = 0; i < scannerUA.length; i++) { if (ua.indexOf(scannerUA[i].toLowerCase()) != -1) { foundScanner = true break } } } // 4 if (foundScanner) { return {action: 'block', message: '已知的扫描器探测行为,UA 特征为: ' + scannerUA[i], confidence: 90} } return clean})
-
定义扫描器可能产生的特定浏览器User-Agent(以下简称为UA)请求头;
-
从请求中获取UA请求头;
-
循环1中定义的扫描器UA,判断是否和2中的UA进行比较;
-
如果命中扫描器UA,则返回命中信息。
通过以上Demo的分析,可以得知,如果要自行编写Web请求检测的插件,需要注册一个request检测函数,通过检测函数的第二个入参context(其实就是OpenRASP自定义的HttpServletRequest对象)获取请求头、请求参数等信息,对请求信息进行检测即可。通过自行编写的请求检测插件,可以完成诸如XSS、扫描器、异常请求头等检测。
基于语法分析的SQL检测
2.2
Java通过JDBC来访问数据库,而JDBC只是一套规范和接口,不同的数据库厂商会根据JDBC规范实现自己的访问逻辑。因此,RASP SQL检测其实就是在不同数据库的JDBC实现(主要是Statement、PrepareStatement接口的实现)中注入对SQL的检测逻辑。
下面我们就来分析下OpenRASP是如何在Statement中完成SQL的检测(PrepareStatement同理),分析的入口类为SQLStatementHook:
代码2-5
@HookAnnotationpublic class SQLStatementHook extends AbstractSqlHook { // 1 public static LRUCache sqlCache = new LRUCache(); @Override public boolean isClassMatched(String className) { // 2 if ("com/mysql/jdbc/StatementImpl".equals(className) || "com/mysql/cj/jdbc/StatementImpl".equals(className)) { this.type = "mysql"; this.exceptions = new String[]{"java/sql/SQLException"}; return true; } ... return false; } @Override protected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException { // 3 String checkSqlSrc = getInvokeStaticSrc(SQLStatementHook.class, "checkSQL", "\"" + type + "\"" + ",$0,$1", String.class, Object.class, String.class); // 4 insertBefore(ctClass, "execute", checkSqlSrc, new String[]{"(Ljava/lang/String;)Z", "(Ljava/lang/String;I)Z", "(Ljava/lang/String;[I)Z", "(Ljava/lang/String;[Ljava/lang/String;)Z"}); ... } public static void checkSQL(String server, Object statement, String stmt) { // 5 if (!sqlCache.isContainsKey(stmt)) { JSContext cx = JSContextFactory.enterAndInitContext(); Scriptable params = cx.newObject(cx.getScope()); params.put("server", params, server); params.put("query", params, stmt); HookHandler.doCheck(CheckParameter.Type.SQL, params); } }}
-
定义一个SQL缓存,用于存放已经检测过可放行的SQL;
-
检查入参的类是否需要被hook。这里检查的是各个数据库的JDBC驱动对Statement接口的具体实现类,比如MySQL的com.mysql.jdbc.StatementImpl;
-
获取静态方法SQLStatementHook.checkSQL()的源码;
-
将4中的代码插入到实例方法StatementImpl.execute()的开始处。StatementImpl.execute()总共有4个同名方法,因此insertBefore()的第四个入参提供了对应的4个不同的方法签名;
-
判断是否命中SQL缓存,命中则说明该SQL可放行,未命中则调用HookHandler.doCheck()。除了execute()之外,executeUpdate()、executeQuery()和addBatch()也被注入了SQLStatementHook.checkSQL()。
通过上述代码,实现了类似如下的逻辑(以MySQL的StatementImpl.execute(String sql)为例):
public StatementImpl implements Statement { public boolean execute(String sql) { // 插入的SQLStatementHook.checkSQL(this, sql),展开后的代码 if (!sqlCache.isContainsKey(sql)) { JSContext cx = JSContextFactory.enterAndInitContext(); Scriptable params = cx.newObject(cx.getScope()); params.put("server", params, server); params.put("query", params, sql); HookHandler.doCheck(CheckParameter.Type.SQL, params); } // 原有逻辑 ... }}
根据5中的第一个入参,查找CheckParameter.Type:
public class CheckParameter { public enum Type { ..., SQL("sql", new SqlStatementChecker()), ...;}
可以得知,CheckParameter.Type.REQUEST对应的Checker实现为SqlStatementChecker。因此,最终检测逻辑即委托给了SqlStatementChecker,下面进行分析:
代码2-6
public class SqlStatementChecker extends ConfigurableChecker { public List checkSql(CheckParameter checkParameter, Map parameterMap, JsonObject config) { List result = new LinkedList(); String query = (String) checkParameter.getParam("query"); String message = null; // 1 String[] tokens = TokenGenerator.detailTokenize(query, new TokenizeErrorListener()); // 2 for (Map.Entry entry : parameterMap.entrySet()) { String value = entry.getValue()[0]; // 3 int para_index = query.indexOf(value); if (para_index < 0) { continue; } // 4 int start = tokens.length, end = tokens.length, distance = 2; ... // 5 if (end - start > distance) { message = "SQLi - SQL query structure altered by user input, request parameter name: " + entry.getKey(); } } if (message != null) { result.add(AttackInfo.createLocalAttackInfo(checkParameter, action, message, "sqli_userinput", 90)); } else { // 6 for (String token : tokens) { if (token.equals("select")) { int nullCount = 0; // 7 for (int j = i + 1; j < tokens.length && j < i + 6; j++) { if (tokens[j].equals(",") || tokens[j].equals("null") || StringUtils.isNumeric(tokens[j])) { nullCount++; } else { break; } } // 8 if (nullCount >= 5) { message = "SQLi - Detected UNION-NULL phrase in sql query"; break; } } if (token.equals(";") && i != tokens.length - 1) { // 9 message = "SQLi - Detected stacked queries"; break; } else if (token.startsWith("0x")) { // 10 message = "SQLi - Detected hexadecimal values in sql query"; break; } else if (token.startsWith("/*!")) { // 11 message = "SQLi - Detected MySQL version comment in sql query"; break; } else if (i < tokens.length - 2 && tokens[i].equals("into") && (tokens[i + 1].equals("outfile") || tokens[i + 1].equals("dumpfile"))) { // 12 message = "SQLi - Detected INTO OUTFILE phrase in sql query"; break; } else if (i < tokens.length - 1 && tokens[i].equals("from"))) { // 13 String[] parts = tokens[i + 1].replace("`", "").split("\\."); if (parts.length == 2) { String db = parts[0].trim(); String table = parts[1].trim(); if (db.equals("information_schema") && table.equals("tables")) { message = "SQLi - Detected access to MySQL information_schema.tables table"; break; } } } if (message != null) { result.add(AttackInfo.createLocalAttackInfo(checkParameter, action, message, "sqli_policy", 100)); } } } return result; }}
1.对SQL进行语法分析(严格来说,这里只完成了词法分析),获取分析后的token(词)。例如对SQL:select c2 from t1 where c1 = ‘a’,进行语法分析后,可以得到如下的token表:
2.循环用户的输入,检测每个输入项;
3.获取用户输入项在SQL中的索引位置,如果未找到,则进入下一轮循环;
4.定义并计算用户输入项在token表中的start(开始索引)和end(结束索引),以及定义可能发生SQL注入时,用户输入项所占的最少token项,这里为2。例如SQL:select c2 from t1 where c1 = ‘${input}’,${input}为用户输入项:
-
正常的用户输入,${input} = a:
-
存在SQL注入的用户输入,${input} = ‘ or ‘1’ = ‘1:
-
上面的例子中,正常的用户输入项只会占7这一索引的token,而当用户输入项发生SQL注入的时候,占用了7~11总共4个索引的token。因此,可以通过计算用户输入项所占的token项(11-7=4)来判定用户的输入是否可能产生SQL注入。
5.判断用户输入项所占的token是否大于2,大于2则可能为攻击。为什么没有直接使用大于1来判定是否产生SQL注入?个人觉得可能的原因为,为了产生SQL注入,首先要对SQL中两个字符引号(即where c1 = ‘${input}’中的两个字符单引号’)进行闭合(‘ or ‘1’ = ‘1中的第一个和最后一个’分别用于闭合where c1 = ‘${input}’中的两个引号),闭合后自然产生了两个token。由此可知,为了产生SQL注入,用户输入项所占的token项必须大于2;
6.循环每个token,检测token是否符合安全策略;
7.计算连续出现null、,或者数字的token。这里的检测是为了防止可能为UNION查询攻击,例如:select c1, c2, c3, c4 from t1 where c1 = ‘a’ union select null, null, null, c5 from t2 where c6 = ‘b’;
8.判断连续出现的null、,或者数字的token是否大于5,大于5则可能为攻击。选用5应该是出于经验,null, null, null、1, 2, 3,这些token均为5,太小,容易产生误判,因为偶尔也会有特殊场景下需要使用null占一个或两个查询字段。个人觉得可以再加入union这个token作为判断依据会更精确;
9.判断非最后一个token是否为;,如果是,则可能为攻击。这里的检测是为了防止可能为堆叠查询,例如select c1 from t1 where c1 = ‘a’; select c2 from t2;
10.判断token是否为十六进制符号,如果是,则可能为攻击;
11.判断token是否包含/*!,如果包含,则可能为攻击。这里的检测是为了防止可能为内联注释攻击;
12.判断当前和下一个token是否为into outfile或者into dumpfile,如果是,则可能为攻击。这里的检测是为了防止可能为文件导出攻击;
13.判断当前和下一个token是否为from information_schema.tables,如果是,则可能为攻击。
注:上述分析中涉及到的攻击方式不做详细解释,请自行百度。
由上述分析可以得知,OpenRASP的SQL检测是基于对SQL的语法分析。相比传统的,特别是WAF,基于关键字、正则表达式匹配的方式,基于语法分析由于从语言层面去分析和理解SQL,可以做到更加的精准,减少误杀。当然,基于语法分析带来的问题是,更大的性能损耗和内存占用(需要构建一棵完整的语法树)。
安全基线检查
2.3
代码2-7
@HookAnnotationpublic class TomcatStartupHook extends ServerStartupHook { @Override public boolean isClassMatched(String className) { // 1 return "org/apache/catalina/startup/Catalina".equals(className); } @Override protected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException { // 2 String src = getInvokeStaticSrc(TomcatStartupHook.class, "checkTomcatStartup", ""); insertBefore(ctClass, "start", null, src); } public static void checkTomcatStartup() { // 3 HookHandler.doCheckWithoutRequest(CheckParameter.Type.POLICY_TOMCAT_START, CheckParameter.EMPTY_MAP); }}
-
判断入参类是否需要被hook。这里检查的是Tomcat启动的核心类Catalina;
-
获取静态方法TomcatStartupHook.checkTomcatStartup()的源码,插入到实例方法Catalina.start()开始处;
-
调用静态方法HookHandler.doCheckWithoutRequest()进行检测。
根据3中的第一个入参,查找CheckParameter.Type:
public class CheckParameter { public enum Type { ..., POLICY_TOMCAT_START("tomcatStart", new TomcatSecurityChecker()), ...;}
可以得知,CheckParameter.Type.POLICY_TOMCAT_START对应的Checker实现为TomcatSecurityChecker。因此,最终检测逻辑即委托给了TomcatSecurityChecker,下面进行分析:
代码2-8
public abstract class ServerPolicyChecker extends PolicyChecker { @Override public List checkParam(CheckParameter checkParameter) { List infos = new LinkedList(); // 1 checkStartUser(infos); checkServer(checkParameter, infos); return infos; } private void checkStartUser(List infos) { String osName = System.getProperty("os.name").toLowerCase(); if (osName.startsWith("linux") || osName.startsWith("mac")) { // 2 if ("root".equals(System.getProperty("user.name"))) { infos.add(new SecurityPolicyInfo(SecurityPolicyInfo.Type.START_USER, "Java security baseline - should not start application server with root account", true)); } } else if (osName.startsWith("windows")) { // 3 Class ntSystemClass = Class.forName("com.sun.security.auth.module.NTSystem"); Object ntSystemObject = ntSystemClass.newInstance(); String[] userGroups = (String[]) ntSystemClass.getMethod("getGroupIDs").invoke(ntSystemObject); for (String group : userGroups) { // 4 if (group.equals("S-1-5-32-544")) { infos.add(new SecurityPolicyInfo(SecurityPolicyInfo.Type.START_USER, "Java security baseline - should not start application server with Administrator/system account", true)); } } } } public abstract void checkServer(CheckParameter checkParameter, List infos);}
-
Server安全基线检查的基类,包含用户、Server自检查,其中Server自检查留给子类实现;
-
如果系统为Linux或者Mac,如果当前用户为root,则记录触发安全基线;
-
加载com.sun.security.auth.module.NTSystem类,通过该类可以获取Windows NT系统的安全信息;
-
通过NTSystem.getGroupIDs()获取当前用户组信息,如果为S-1-5-32-544(Windows NT系统上最高权限Administrators用户组),则触发安全基线。
代码2-9
public class TomcatSecurityChecker extends ServerPolicyChecker { @Override public void checkServer(CheckParameter checkParameter, List infos) { // 1 String tomcatBaseDir = System.getProperty("catalina.base"); checkHttpOnlyIsOpen(tomcatBaseDir, infos); checkManagerPassword(tomcatBaseDir, infos); checkDirectoryListing(tomcatBaseDir, infos); checkDefaultApp(tomcatBaseDir, infos); } private void checkHttpOnlyIsOpen(String tomcatBaseDir, List infos) { // 2 File contextFile = new File(tomcatBaseDir + File.separator + "conf/context.xml"); Element contextElement = getXmlFileRootElement(contextFile); // 3 String httpOnly = contextElement.getAttribute("useHttpOnly"); boolean isHttpOnly = true; if (httpOnly != null && httpOnly.equals("false")) { isHttpOnly = false; } // 4 if (!isHttpOnly) { infos.add(new SecurityPolicyInfo(Type.COOKIE_HTTP_ONLY, "Tomcat security baseline - httpOnly should be enabled in " + contextFile.getAbsolutePath(), true)); } }}
-
获取Tomcat安装根目录,我们先只分析checkHttpOnlyIsOpen();
-
获取根目录下的conf/context.xml文件,并解析XML;
-
获取根元素的userHttpOnly属性的值;
-
如果userHttpOnly为false,则触发安全基线。
除了HttpOnly的检查,TomcatSecurityChecker.checkServer()里还包括管理员密码、目录列表等安全配置检查。
从上述两段代码可以得知,OpenRASP Java的安全基线检查和普通的Java程序并没有任何区别。当然,也正因为和普通的Java程序没有区别,因此OpenRASP也只能完成运行当前应用程序的用户所能完成的检查,比如一些需要特殊用户权限才能完成的检查。
其他检查
2.4
代码2-10
public class SQLResultSetHook extends AbstractSqlHook { @Override public boolean isClassMatched(String className) { // 1 if ("com/mysql/jdbc/ResultSetImpl".equals(className) || "com/mysql/cj/jdbc/result/ResultSetImpl".equals(className)) { this.type = "MySQL"; return true; } ... return false; } @Override protected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException { // 2 String src = getInvokeStaticSrc(SQLResultSetHook.class, "checkSqlQueryResult", "\"" + type + "\"" + ",$0", String.class, Object.class); insertBefore(ctClass, "next", "()Z", src); } public static void checkSqlQueryResult(String server, Object sqlResultSet) { ResultSet resultSet = (ResultSet) sqlResultSet; // 3 int queryCount = resultSet.getRow(); HashMap params = new HashMap(4); params.put("query_count", queryCount); params.put("server", server); // 4 HookHandler.doCheck(CheckParameter.Type.SQL_SLOW_QUERY, params); }}
-
判断入参类是否需要被hook。这里检查的是MySQL JDBC驱动ResultSet接口的实现类ResultSetImpl;
-
获取静态方法SQLResultSetHook.checkSqlQueryResult()的源码,插入到实例方法ResultSetImpl.next()开始处,即获取数据库查询结果的方法开始处;
-
获取查询结果返回的总条数;
-
调用静态方法HookHandler.doCheck()进行检测。
根据3中的第一个入参,查找CheckParameter.Type:
public class CheckParameter { public enum Type { ..., SQL_SLOW_QUERY("sqlSlowQuery", new SqlResultChecker(false)), ...;}
可以得知,CheckParameter.Type.SQL_SLOW_QUERY对应的Checker实现为SqlResultChecker。因此,最终检测逻辑即委托给了SqlResultChecker,下面进行分析:
代码2-11
public class SqlResultChecker extends AttackChecker { @Override public List checkParam(CheckParameter checkParameter) { LinkedList result = new LinkedList(); // 1 Integer queryCount = (Integer) checkParameter.getParam("query_count"); // 2 int slowQueryMinCount = Config.getConfig().getSqlSlowQueryMinCount(); // 3 if (queryCount == slowQueryMinCount) { result.add(AttackInfo.createLocalAttackInfo(checkParameter, EventInfo.CHECK_ACTION_INFO, "慢查询: 使用SELECT语句读取了大于等于" + slowQueryMinCount + "条数据", "slow query")); } return result; }}
-
获取查询结果返回的总条数;
-
从配置文件(RELEASE版本的conf/rasp.properties)中的SQL慢查询最小条数,配置项为sql.slowquery.min_rows,默认配置值为500;
-
判断实际查询返回的总条数是否大于等于配置值(源码中为==,BUG),大于等于则记录安全检查结果。
总结
3
经过上述的分析,我们了解到,OpenRASP:
-
通过往特定Web容器实现中注入检测逻辑,可以完成对所有Web请求的检测;
-
通过往特定JDBC驱动实现中注入检测逻辑,可以完成对所有SQL的检测。其中,SQL的检测基于语法分析;
-
通过往特定类中注入检测逻辑,可以完成安全基线的检查,以及一些非安全相关的检查。
因为RASP天然具备获取应用运行过程中上下文,所以RASP具备理解应用上下文的能力。在理解应用上下文的前提下,可以获取到精准的应用数据进行检测,检测的结果可以直接反馈给应用,应用依据反馈结果进行决策。