引子
通过《OpenRASP Java源码分析与总结(一)——启动与检测》的分析,我们对OpenRASP Java部分的整体启动和检测流程有了大致的了解。本文将对OpenRASP所支持的js插件、安全基线等几类检测方式中,选取几个比较有代表性的检测进行详细分析。
准备
1agent 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@Overridepublic boolean isClassMatched(String className) {return className.endsWith("apache/catalina/core/ApplicationFilterChain");}@Overrideprotected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException {// 2String src = getInvokeStaticSrc(ServerRequestHook.class, "checkRequest","$0,$1,$2", Object.class, Object.class, Object.class);// 3insertBefore(ctClass, "doFilter", null, src);}}public abstract class ServerRequestHook extends AbstractClassHook {// 4public 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-2public class HookHandler {public static void checkRequest(Object servlet, Object request, Object response) {// 1HttpServletRequest requestContainer = new HttpServletRequest(request);// 2HttpServletResponse responseContainer = new HttpServletResponse(response);// 3responseContainer.setHeader(OPEN_RASP_HEADER_KEY, OPEN_RASP_HEADER_VALUE);// 4responseContainer.setHeader(REQUEST_ID_HEADER_KEY, requestContainer.getRequestId());// 5requestCache.set(requestContainer);// 6responseCache.set(responseContainer);// 7doCheck(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-3var plugin = new RASP("offical")// 1plugin.register("request", function(params, context) {// 2function detectXSS(params, context) {// 3var xssRegex = /||javascript:(?!(?:history\.(?:go|back)|void\(0\)))/ivar parameters = context.parameter;var message = "";// 4Object.keys(parameters).some(function (name) {parameters[name].some(function (value) {if (xssRegex.test(value)) {message = "XSS 攻击: " + value;return true;}});});return message}// 5var message = detectXSS(params, context)if (message.length) {return {action: "block", message: message, confidence: 90}}return clean})
注册一个request检测函数;
定义一个XSS检测函数;
定义XSS检测的正则表达式;
循环请求里的每个参数,判断是否匹配XSS正则表达式;
调用XSS检测函数,如果命中,则返回命中信息。
代码2-4var plugin = new RASP("offical")plugin.register("request", function(params, context) {var foundScanner = false// 1var scannerUA = [..., "bsqlbf", "sqlmap", "nessus", "arachni", "metis", ...]var headers = context.header// 2var ua = headers["user-agent"]if (ua) {// 3for (var i = 0; i < scannerUA.length; i++) {if (ua.indexOf(scannerUA[i].toLowerCase()) != -1) {foundScanner = truebreak}}}// 4if (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 {// 1public static LRUCache sqlCache = new LRUCache();@Overridepublic boolean isClassMatched(String className) {// 2if ("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;}@Overrideprotected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException {// 3String checkSqlSrc = getInvokeStaticSrc(SQLStatementHook.class, "checkSQL","\"" + type + "\"" + ",$0,$1", String.class, Object.class, String.class);// 4insertBefore(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) {// 5if (!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-6public 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;// 1String[] tokens = TokenGenerator.detailTokenize(query, new TokenizeErrorListener());// 2for (Map.Entry entry : parameterMap.entrySet()) {String value = entry.getValue()[0];// 3int para_index = query.indexOf(value);if (para_index < 0) {continue;}// 4int start = tokens.length, end = tokens.length, distance = 2;...// 5if (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 {// 6for (String token : tokens) {if (token.equals("select")) {int nullCount = 0;// 7for (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;}}// 8if (nullCount >= 5) {message = "SQLi - Detected UNION-NULL phrase in sql query";break;}}if (token.equals(";") && i != tokens.length - 1) {// 9message = "SQLi - Detected stacked queries";break;} else if (token.startsWith("0x")) {// 10message = "SQLi - Detected hexadecimal values in sql query";break;} else if (token.startsWith("/*!")) {// 11message = "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"))) {// 12message = "SQLi - Detected INTO OUTFILE phrase in sql query";break;} else if (i < tokens.length - 1 && tokens[i].equals("from"))) {// 13String[] 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 {@Overridepublic boolean isClassMatched(String className) {// 1return "org/apache/catalina/startup/Catalina".equals(className);}@Overrideprotected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException {// 2String src = getInvokeStaticSrc(TomcatStartupHook.class, "checkTomcatStartup", "");insertBefore(ctClass, "start", null, src);}public static void checkTomcatStartup() {// 3HookHandler.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-8public abstract class ServerPolicyChecker extends PolicyChecker {@Overridepublic List checkParam(CheckParameter checkParameter) {List infos = new LinkedList();// 1checkStartUser(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")) {// 2if ("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")) {// 3Class 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) {// 4if (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-9public class TomcatSecurityChecker extends ServerPolicyChecker {@Overridepublic void checkServer(CheckParameter checkParameter, List infos) {// 1String tomcatBaseDir = System.getProperty("catalina.base");checkHttpOnlyIsOpen(tomcatBaseDir, infos);checkManagerPassword(tomcatBaseDir, infos);checkDirectoryListing(tomcatBaseDir, infos);checkDefaultApp(tomcatBaseDir, infos);}private void checkHttpOnlyIsOpen(String tomcatBaseDir, List infos) {// 2File contextFile = new File(tomcatBaseDir + File.separator + "conf/context.xml");Element contextElement = getXmlFileRootElement(contextFile);// 3String httpOnly = contextElement.getAttribute("useHttpOnly");boolean isHttpOnly = true;if (httpOnly != null && httpOnly.equals("false")) {isHttpOnly = false;}// 4if (!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-10public class SQLResultSetHook extends AbstractSqlHook {@Overridepublic boolean isClassMatched(String className) {// 1if ("com/mysql/jdbc/ResultSetImpl".equals(className)|| "com/mysql/cj/jdbc/result/ResultSetImpl".equals(className)) {this.type = "MySQL";return true;}...return false;}@Overrideprotected void hookMethod(CtClass ctClass) throws IOException, CannotCompileException, NotFoundException {// 2String 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;// 3int queryCount = resultSet.getRow();HashMap params = new HashMap(4);params.put("query_count", queryCount);params.put("server", server);// 4HookHandler.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-11public class SqlResultChecker extends AttackChecker {@Overridepublic List checkParam(CheckParameter checkParameter) {LinkedList result = new LinkedList();// 1Integer queryCount = (Integer) checkParameter.getParam("query_count");// 2int slowQueryMinCount = Config.getConfig().getSqlSlowQueryMinCount();// 3if (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具备理解应用上下文的能力。在理解应用上下文的前提下,可以获取到精准的应用数据进行检测,检测的结果可以直接反馈给应用,应用依据反馈结果进行决策。