公告 / 【VK技术分享】基于数据流的越权检测

作者:m0l1ce 公布时间:2020-04-28 阅读次数:301


前言
随着技术的发展和安全技术的普及,在大部分互联网企业web安全漏洞可能出现在业务逻辑上的漏洞比较突出,尤其是越权问题。黑盒测试会带来大量脏数据,同时白盒扫描让人无法接受的误报率、代码审计的低效率都是挖掘越权漏洞的大山,是否有更好的思路来解决以上问题,是笔者一直在思考的事情。


0x1找到问题
随着研发-测试流程的完善,大部分测试部门的同学会在新的代码部署到测试环境以后会进行功能测试,在进行功能测试的同时安全同学可以通过抓取流量,然后通过对流量进行回放实现部分漏洞的检测,比如我们经常对get类型的请求做sql注入检测,还可以通过替换身份信息进行普通的未授权、越权检测。
通过以上我们应该也可以发现一些问题,那么实际生产中存在大量的需要和数据库进行交互的场景,比如修改个人信息、修改收货地址、添加收货地址、删除收获地址类似的场景,针对这些类型的越权检测大部分安全同学可能是申请两个账号然后进行测试,然后通过比对返回的结果判断是否存在越权,以上也可以实现自动化检测,无非是替换身份认证字段重放请求,但是以上检测方法会存在一些问题,比如可能会带来大量脏数据然后对qa的测试会产生一定的影响,想想以前针对post类型的注入是不是加过 or 1=1,那么有没有方法解决这些问题呢,下面就是我的一些思考。



0x2如何判断是否存在漏洞
首先思考下我们在日常挖洞的时候是如何判断一个接口是否存在安全问题的

先拿post类型的sql注入来讲,假如存在一个场景是修改自己的address,我们截获的请求如下:

POST /rest/parentrest/api/pc/address/updateHTTP/1.1
Host: 127.0.0.1:8001
User-Agent: Mozilla/5.0 (Macintosh; Intel MacOS X 10.13; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: image/webp,*/*
Accept-Language:zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: keep-alive
Cookie: SESSION=SSSIONFORSARA;

username=sara&address=china+beijing


假设可以拦截到执行的sql,那么sql语句应该如下:

UPDATE users SET address = "china+beijing" WHERE username ="sara"

那么我们要判断username字段是否存在注入,我们可以修改username=sara"+and+"1


POST /rest/parentrest/api/pc/address/updateHTTP/1.1
Host: 127.0.0.1:8001
User-Agent: Mozilla/5.0 (Macintosh; Intel MacOS X 10.13; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: image/webp,*/*
Accept-Language:zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate
Connection: keep-aliveCookie: SESSION=SSSIONFORSARA;


username=sara"+and+"1&address=china+beijing

假设后端存在sql拼接,那么sql语句可能是这样子的:
UPDATE users SET address = "china+beijing" WHERE username ="sara" and "1"

很明显这样就拼接成了一条可以执行的sql语句,然后会提交给数据库去执行了通过以上分析对以上的简单案例其实我们可以看到我们只要拿到执行的sql语句基本就可以判断是否存在注入了,而无需让sql继续去提交给数据库引擎去执行,在做安全评估时经常会遇到的一个问题就是担心产生脏数据。
举个例子,假如开发同学对username做了过滤,假设只留了or "1可以提交通过,那么在我们进行测试的时候就有风险把其他人的信息全部改掉,相信身边人有遇到过通过加 or 1=1把表中全部信息都改掉的光辉历史,那么假如我们可以获取到提交请求的详细信息以及这些请求带来了哪些数据交互也就是执行了哪些sql语句,我们是不是可以做更多的事情呢?比如:

  • 使用原身份替换username的值为 username=sara" and "iast,判断拦截的sql语句是否存在拼接
  • 替换身份进行重放,记录/拦截update的sql语句判断是否存在越权
  • 拦截扫描器请求带来的update类型sql语句

今天重点分析越权检测所以我们继续拿越权漏洞做例子讲假如我们替换了身份认证字段
Cookie :SESSION=SSSIONFORBOB

POST /rest/parentrest/api/pc/address/updateHTTP/1.1
Host: 127.0.0.1:8001
User-Agent: Mozilla/5.0 (Macintosh; Intel MacOS X 10.13; rv:75.0) Gecko/20100101 Firefox/75.0
Accept: image/webp,*/*
Accept-Language:zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflateConnection: keep-aliveCookie: SESSION=SSSIONFORBOB;


username=sara&address=china+beijing


假设不存在越权那么开发同学应该会通过session中获取用户的信息标示作为条件执行update,比如从session中获取用户id
UPDATE users SET address = "china+beijing" WHERE id = 12


假设存在越权那么where条件中的信息应该来自我们提交的参数username
UPDATE users SET address = "china+beijing" WHEREusername="sara"

通过比对替换身份认证以后执行的sql语句就可以判断是否存在越权。



0x3技术实现


大体思路先梳理下:


技术上主要解决以下几个问题:

  • 流量处理(获取、清洗、去重)
  • 请求标记、重放
  • 判断sql是否一致
  • 拦截update类型sql语句

>>0x301 数据流信息处理
这里的数据流信息不仅包括http请求的流量,还包括执行的sql语句、sql执行完返回的对象信息,为了能够标示我们发起的请求对应执行了哪些sql,我们需要在请求中添加自定义的标示比如requestID,所有的http信息、sql语句信息、sql执行完返回的对象信息都可以通过requestId进行存储和查询
下面简单说下各种信息该如何获取以及处理思路:


http信息
http请求的信息大家比较熟悉,对http请求的信息进行了拆分存储
·  headers
·  url
· method
· body
· parameters


sql语句处理
sql的处理主要包括以下三点:
·  记录
· 判断前后两个请求sql语句是否相同
· 拦截update类型sql语句防止产生脏数据


我们拿 mysql-connector-java-8.0.13.jar 举例,在 com.mysql.cj.NativeSession 类中的 execsql 方法中可以看到参数有一个 packet,这个其实就是我们发送给 mysql 的执行的 sql 语句数据,然后看了下 NativePacketPayload 类发现可以通过packet.setByteBuffer(byte[] bytes) 方法修改 packet 的值,此时检测到扫描器发起的请求如果包含 update 类型的 sql 语句则可以对 packet 进行篡改。
com.mysql.cj.NativeSession.java 中 execSQL方法对应的部分代码


public <T extends Resultset> TexecSQL(Query callingQuery, String query, int maxRows, NativePacketPayloadpacket, boolean streamResults,
ProtocolEntityFactory<T, NativePacketPayload> resultSetFactory,String catalog, ColumnDefinition cachedMetadata, boolean isBatch) {
//可以在此处通过插桩插入检测的代码
long queryStartTime = 0;
int endOfQueryPacketPosition = 0;
if (packet != null) {
endOfQueryPacketPosition = packet.getPosition();
}

if (this.gatherPerfMetrics.getValue()) {
queryStartTime = System.currentTimeMillis();
}
this.lastQueryFinishedTime = 0; // we're busy!
if (this.autoReconnect.getValue() &&(getServerSession().isAutoCommit() || this.autoReconnectForPools.getValue())&& this.needsPing && !isBatch) {
try { ping(false, 0);
this.needsPing = false;

} catch (Exception Ex) {
invokeReconnectListeners();
}
}
...



在openrasp中可以编写自定义的编码转换器SqlTransformer2类插入到EngineBoot类的initTransformer方法中,可以实现将 requestId sql 语句一起存储到redis中,当然还需要各位自己写下com.baidu.openrasp.hook.InsertRedis.InsertInfoToRedis()类用于往 redis 中写入数据


SqlTransformer2.java


package com.baidu.openrasp.hook.molice;

import javassist.*;

importjava.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;


/**
*Description
*<p>
*</p>
* DATE2020/4/5.
* *@author molice.
*/public class SqlTransformer2 implementsClassFileTransformer {
/*
* 记录/拦截执行的update语句
*todo 根据requestId判断请求是否来自扫描器
*todo 如果不是来自扫描器则记录执行的sql语句,并对返回结果进行序列化操作
*todo 如果判断是来自扫描器,则记录sql语句并对存储的原始数据进行反序列化并返回该对象,保证在不和数据库进行交互的前提下完成业务流程
*
**/
@Override
public byte[] transform(final ClassLoader loader, final StringclassName, final Class <?> classBeingRedefined, final ProtectionDomainprotectionDomain, final byte[] classfileBuffer) {//
if ("com/mysql/cj/jdbc/ClientPreparedStatement".equals(className )) {


if ("com/mysql/cj/NativeSession".equals( className ))
{ try {
//1、所引用的类型,必须通过ClassPool获取后才可以使用
//2、代码块中所用到的引用类型,使用时必须写全量类名
final CtClass clazz =ClassPool.getDefault().get( className.replace( "/", "." ));
System.out.println("className is :" + clazz.getName() );
CtMethod executeUpdateInternal= clazz.getDeclaredMethod( "execSQL" );
executeUpdateInternal.insertBefore( "" +
"com.baidu.openrasp.request.AbstractRequest request =com.baidu.openrasp.HookHandler.requestCache.get();" +
"com.mysql.cj.protocol.a.NativePacketPayloadpack = $4;" +
"StringcurrentSql = String.valueOf(pack);" +
"if (pack!= null){String resultStr = (new com.baidu.openrasp.hook.molice.DetectAuthorityVunlClass()).detect(request,currentSql);" +
"if(resultStr.equals(\"1\")){int len = pack.getPayloadLength();byte[] b= new byte[len];byte[] c = pack.getByteBuffer();" + "if (c[1]== 85) {b[1] = 115;pack.setByteBuffer( b );}}}"
);
// 返回字节码,并且detachCtClass对象
byte[] byteCode =clazz.toBytecode();
//detach的意思是将内存中曾经被javassist加载过的Date对象移除,如果下次有需要在内存中找不到会重新走javassist加载
clazz.detach();
return byteCode;
} catch (Exception ex) {
ex.printStackTrace();
}
}
// 如果返回null则字节码不会被修改
return null;
}

}

DetectAuthorityVunlClass类

package com.baidu.openrasp.hook.molice;

importcom.baidu.openrasp.hook.InsertRedis.SelectInfoFromRedis;
importcom.baidu.openrasp.hook.InsertRedis.InsertInfoToRedis;
importcom.baidu.openrasp.request.AbstractRequest;

/**
*Description
*<p>
*</p>
* DATE2020/4/18.
*
*@author molice.
*/public class DetectAuthorityVunlClass {
public String detect(AbstractRequest request, String bytes) {

String currentSql = hexStr2Str( bytes );
String ua = request.getHeader( "user-agent" );
//记录当前requestId检测检测的sql偏移
InsertInfoToRedis insertInfoToRedis = new InsertInfoToRedis();
// 首先判断请求是否来自扫描器
if (ua.contains( "TestBySecurityTeamForVunl" )) {
System.out.println( "匹配到了发起的检测请求,开始执行越权判断的逻辑" );
//记录当前requestId检测检测的sql偏移
insertInfoToRedis.setSqlCount( request.getRequestId() );

String[] uaSplited = ua.split( "-" );
String origRequestid = uaSplited[1];
// 根据执行的sql语句是否一致存在漏洞
com.baidu.openrasp.hook.InsertRedis.SelectInfoFromRedis getInfoFromRedis= new com.baidu.openrasp.hook.InsertRedis.SelectInfoFromRedis();
int sqlIndex = insertInfoToRedis.getSqlCount( request.getRequestId() ) -1;
System.out.println( "index:" + sqlIndex );
if (sqlIndex < 0) {
sqlIndex = 1;
}
String oriGSql = getInfoFromRedis.getSqlString( origRequestid, sqlIndex);

if (currentSql.equals( oriGSql )) {
String url = getInfoFromRedis.getUrl(origRequestid );
System.out.println( "检测到替换身份信息以后执行了相同的SQL语句,url:" + url );
System.out.println( " origSql:" + oriGSql );
System.out.println("currentSql:" + currentSql );
} else {
String url =getInfoFromRedis.getUrl( origRequestid );
System.out.println( "检测到替换身份信息以后执行了不同的SQL语句,url:" + url );
System.out.println( " origSql:" + oriGSql );
System.out.println( "currentSql:"+ currentSql );
}
return "1";
} else {
//正常请求则仅记录发送的packet"+
String requestId = request.getRequestId();
(newcom.baidu.openrasp.hook.InsertRedis.InsertInfoToRedis()).InsertToList(requestId + ":sql", currentSql );
return "0";
}
}
public static String hexStr2Str(String hexStr) {
String s = "";
for (String s1 : hexStr.split( "\n" )) {
s = s + s1.substring( 0, 24 );

}
String str = "0123456789abcdef";
char[] hexs = s.replace( " ", "" ).toCharArray();
byte[] bytes = new byte[s.replace( " ", "").length() / 2];
int n;
for (int i = 0; i < bytes.length;i++) {
n = str.indexOf( hexs[2 * i] ) * 16;
n += str.indexOf( hexs[2 * i + 1] );
bytes[i] = (byte) (n & 0xff);
}
return new String( bytes );
}
}

EngineBoot.java的initTransformer方法中插入我们自定义的字节码转换器类

private void initTransformer(Instrumentationinst) throws UnmodifiableClassException {
inst.addTransformer( new SqlTransformer2(), true );
transformer = new CustomClassTransformer( inst );
transformer.retransform();
}

然后在 openrasp-master/agent/java 下执行以下命令对 agent 重新编译打包

mvn clean package

重新启动项目发现我们已经可以获取到RequestId和sql对应的信息了