管中窺豹——框架下的SQL注入 Java篇
管中窺豹——框架下的SQL注入 Java篇
背景
- SQL注入漏洞應該算是很有年代感的漏洞了,但是現在依然活躍在各大漏洞榜單中,究其原因還是資料和程式碼的問題。
- SQL 語句在DBMS系統中作為表示式被解析,從儲存的內容中取出相應的資料, 而在應用系統中只能作為資料進行處理。
- 各個資料庫系統都或多或少的對標準的SQL語句進行了擴充套件
- Oracle的PL/SQL
- SQL Server的儲存過程
- Mysql也作了擴充套件(PS:不過我不知道這擴充套件叫什麼名字
- 既然問題很清楚是什麼了,大佬們的解決方案也不會慢——預編譯和ORM框架
從我目前來感覺來看,就是封裝,把你可能用到的語句封裝起來,明確你資料的位置,再根據SQL語句的語法防止資料影響到真正的語義
ORM框架與預編譯
預編譯
預編譯的指令方式用起來多少有點繁瑣,大部分都會採用相關的ORM框架來解決問題,但是多少需要了解,另外呢,再嘗試編寫sql的轉義器的時候,我估計我還得讀讀這些底層的實現作為參考,原因嘛,自然是場景幾乎一致,老司機的東西肯定比我拍腦袋的強(PS:實際上我需要的太簡單了,預編譯對不同型別均有不同的處理)。
JAVA
// Java.sql 包 PreparedStatement preparedStatement=connection.prepareStatement("SELECT * FROM users WHERE name =?;"); // ?號為佔位符,表示此處有輸入的變數 preparedStatement.setString(1,name); // 通過set的方式設定變數
C
涉及的類,分別是sqlParameter、DataAdapter、
// 參考:https://www.cnblogs.com/wangwangwangMax/p/5551614.html public string Getswhere() { StringBuilder sb = new StringBuilder(); sb.Append("select ID,username,PWD,loginname,qq,classname from Users where 1=1"); //獲取到它的使用者名稱 string username = TxtUserName.Text.Trim(); if (!string.IsNullOrEmpty(username)) { //sb.Append(string.Format("and username='{0}'", username)); //防SQL注入,通過@傳參的方式 sb.Append(string.Format("and username=@username")); //怎麼把值傳進去,通過sqlParameter陣列 //SqlParameter[] para = new SqlParameter[] //{ // //建立一個SqlParameter物件(第一個傳名稱,第二個傳值) // new SqlParameter("@username",username) //}; // para[0]表示陣列物件的第一個裡面新增 //para[0] = new SqlParameter("@username",username); para.Add(new SqlParameter("@username", username)); } if(ddlsclass.SelectedIndex>0) { //sb.Append(string.Format("and ClassName='{0}'", ddlsclass.SelectedValue)); sb.Append(string.Format("and ClassName=@ClassName")); //para[1] = new SqlParameter("@ClassName",ddlsclass.SelectedValue); para.Add(new SqlParameter("@ClassName", ddlsclass.SelectedValue)); } return sb.ToString(); }
ORM框架
Java
Java下目前基本上都是採用了mybatis框架進行處理了吧,反正我目前接觸到的都是這個。
mybatis
在java程式碼呼叫mapper的方法,實現資料庫查詢,框架將查詢的結果對映到xml檔案中配置的結果集上,詳細的底層原理可以檢視圖片下方的原文連結。
參考:https://blog.csdn.net/luanlouis/article/details/40422941
- 當然除了xml配置檔案的方式,還支援註解,不過目前接觸到的主流都是xml,偶爾有在程式碼中看到幾行簡單查詢的註解。
一般而言${}表示動態拼接——容易導致SQL注入,#{}表示引數繫結——不會導致SQL注入 (後文會嘗試從mybatis框架上看看到底什麼區別) - xml檔案一個個去寫,其實也是蠻大的工作量,當然大佬們已經想到這個問題了,基本上都會採用相關的外掛來生成一個能滿足基本需求的xml檔案、mapper類以及實體類(處理輸入和輸出)
目前我接觸到的有兩個- mybatis-generator (maven的外掛)
- idea mybatis-generator (idea的外掛)
mybatis-generator (maven的外掛)
- 需要配置 generatorConfig.xml (包含了jdbc的賬號和密碼,一般會放在resouces目錄下)
PS: 可以關注的資訊洩露的點 - 生成的實體類包括 tableName 和tableNameExample
tableNameExample作為查詢的條件輸入類,tableName主要用於結果輸出類,兩者在功能上做了分離
/**
* This method was generated by MyBatis Generator.
* This method corresponds to the database table asset_group
*
* @mbg.generated Fri Aug 10 18:44:32 CST 2018
*/
List<AssetGroup> selectByExample(AssetGroupExample example);
/**
* This method was generated by MyBatis Generator.
* This method corresponds to the database table asset_group
*
* @mbg.generated Fri Aug 10 18:44:32 CST 2018
*/
AssetGroup selectByPrimaryKey(Integer id);
- tableNameExample作為條件的實現,依賴了動態引數(欄位名動態), 下文會探討這樣做會不會有什麼問題
<where>
<foreach collection="oredCriteria" item="criteria" separator="or">
<if test="criteria.valid">
<trim prefix="(" prefixOverrides="and" suffix=")">
<foreach collection="criteria.criteria" item="criterion">
<choose>
<when test="criterion.noValue">
and ${criterion.condition}
</when>
<when test="criterion.singleValue">
and ${criterion.condition} #{criterion.value}
</when>
<when test="criterion.betweenValue">
and ${criterion.condition} #{criterion.value} and #{criterion.secondValue}
</when>
<when test="criterion.listValue">
and ${criterion.condition}
<foreach close=")" collection="criterion.value" item="listItem" open="(" separator=",">
#{listItem}
</foreach>
</when>
</choose>
</foreach>
</trim>
</if>
</foreach>
</where>
idea mybatis-generator (idea的外掛)
idea是商用的IDE,我先放個圖看看
- 與上文的不同,該外掛生成的實體類只有兩個,但是mapper和xml均生成了兩組,有繼承關係
tableNameBaseMapper 和 tableNameMapper
(程式碼裡沒有體現,實現在使用的時候有,新增的sql語句可以放到tableNameMapper裡,看起來比較清爽,點開basemapper對應的xml檔案就知道了)
實體類中封裝了內部類,用於構造複雜的查詢條件
- xml檔案也寫的完全不一樣,因為沒有采用動態的方式,所以每個xml都很大。 估計設計上分離就是因為這個原因,如果也在這個檔案裡,可能會找不到...
<trim prefix="where" suffixOverrides="and | or">
<!-- 基礎的欄位 省略了很多 -->
<if test="ID != null">
`ID` = #{ID} and
</if>
<if test = "(_parameter instanceof xx.xxx.xxx.xxx.ApplicationFunctions$QueryBuilder) == true">
<!-- 列表型別 -->
<if test="IDList != null">
`ID` in
<foreach collection="IDList" close=")" open="(" separator="," item="item">
#{item}
</foreach> and
</if>
<!-- 模糊查詢 -->
<if test ="fuzzyNAME!=null and fuzzyNAME.size()>0">
(
<foreach collection="fuzzyNAME" separator="or" item="item">
`NAME` like concat('%',#{item},'%')
</foreach>
) and
</if>
<if test ="rightFuzzyNAME!=null and rightFuzzyNAME.size()>0">
(
<foreach collection="rightFuzzyNAME" separator="or" item="item">
`NAME` like concat(#{item},'%')
</foreach>
) and
</if>
<!-- 比較 -->
<if test="cREATETIMESt !=null">
`CREATE_TIME` >= #{cREATETIMESt} and
</if>
<if test="cREATETIMEEd!=null">
`CREATE_TIME` <= #{cREATETIMEEd} and
</if>
</if>
</trim>
- mapper裡封裝的方法
預設生成的以[query|update]{EntityName}[Limit1]? 以及query|update構成的方法名稱
python
- django 自帶的ORM框架
Flask flask_sqlalchemy
C
簡單搜了下花樣比較多...就不寫了
mybatis框架解析原理
- SqlSessionFactoryBuilder.build 入口
- 生成DefaultSqlSessionFactory ,呼叫xmlconfigbuilder進行初始化
- XMLConfigBuilder (org.apache.ibatis.builder.xml)
- 負責解析mapper的配置檔案,其中mapperParser.parse();函式會對配置的主體部分(sql語句、mapper節點下的內容)進行解析
- 解析完成後,將Sql節點存放到 Map<String, XNode> sqlFragments 結構上;
- 進一步的解析呼叫buildStatementFromContext進一步解析
- 最終生成了MappedStatement儲存在configuration物件中
呼叫SqlSessionFactory.opensession,預設生成DefaultSqlSession,呼叫其方法進行查詢等操作
MappedStatement ms = configuration.getMappedStatement(statement); // 取出之前生成的mappedstatement return executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER); // 呼叫執行器,執行 預設將parameter封裝成陣列, 其他根據其型別支援collection 和 list // 執行器最終會呼叫preparestatement 通過預編譯完成
- MappedStatement的getBoundSql方法
DynamicContext context = new DynamicContext(configuration, parameterObject); // 包裝輸入的引數parameterObject rootSqlNode.apply(context); // 實際上在這個階段完成SQL預計動態拼接的,同時會呼叫OGNL表示式獲取相關值,根據不同型別的SQLNode不同的拼接方式,文字是直接新增,其他的部分可能呼叫ognl表示式獲取值 // .... SqlSource sqlSource = sqlSourceParser.parse(context.getSql(), parameterType, context.getBindings()); // 引數拼接的函式
- #{}型別 -> 轉化呼叫java的預編譯
// parse(...) #{}形式的引數處理, GenericTokenParser parser = new GenericTokenParser("#{", "}", handler); /* 轉化成固定的返回 ? 用於預編譯 */ String sql = parser.parse(originalSql); return new StaticSqlSource(configuration, sql, handler.getParameterMappings()); // Class: GenericTokenParser // parse(String) public String parse(String text) { if (text == null || text.isEmpty()) { return ""; } // search open token int start = text.indexOf(openToken, 0); // ..... while (start > -1) { if (start > 0 && src[start - 1] == '\\') { // this open token is escaped. remove the backslash and continue. 如果存在反斜槓的轉義自動掠過 // .. } else { // found open token. let's search close token. if (expression == null) { expression = new StringBuilder(); // 實際上就是處理完一些特殊符號後#{}中間的內容 } else { expression.setLength(0); } builder.append(src, offset, start - offset); offset = start + openToken.length(); int end = text.indexOf(closeToken, offset); while (end > -1) { if (end > offset && src[end - 1] == '\\') { // this close token is escaped. remove the backslash and continue. 如果存在反斜槓的轉義自動掠過 // ..... } if (end == -1) { // close token was not found. builder.append(src, start, src.length - start); offset = src.length; } else { /* 轉化成固定的返回 ? 用於預編譯 // SqlSourceBuilder public String handleToken(String content) { parameterMappings.add(buildParameterMapping(content)); return "?"; } 根據之前宣告的引數型別對映prepare相應的set函式,例如setString */ builder.append(handler.handleToken(expression.toString())); offset = end + closeToken.length(); } } start = text.indexOf(openToken, offset); } if (offset < src.length) { builder.append(src, offset, src.length - offset); } return builder.toString(); } // // \t\n\r\f 會被替換成空格,重構sql語句 // org.apache.ibatis.executor.statement // class: PreparedStatementHandler : instantiateStatement(connection) String sql = boundSql.getSql(); if (mappedStatement.getKeyGenerator() instanceof Jdbc3KeyGenerator) { String[] keyColumnNames = mappedStatement.getKeyColumns(); if (keyColumnNames == null) { return connection.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS); } else { return connection.prepareStatement(sql, keyColumnNames); } } else if (mappedStatement.getResultSetType() != null) { return connection.prepareStatement(sql, mappedStatement.getResultSetType().getValue(), ResultSet.CONCUR_READ_ONLY); } else { return connection.prepareStatement(sql); // jdbc的預編譯 }
PS: 後續有時間再去了解底層的實現。
常見的安全問題
資訊洩露/拒絕服務風險
- 提供空值或者空的物件,導致查詢空值條件失效,實現了全庫查詢,可能造成資訊洩露或者DOS風險。
- idea生成的例子如下:
<if test="ID != null"> `ID` = #{ID} and </if> <!-- 基本上如上,會包含一個test語句,用於確認當前的條件是否為null,對於字串還會判斷是否為空的字串,如果為null或者空,當前的條件控制失效 #{}方式如果是字串會預設新增'',空值的方式可能會差不到資料, null的情況下會查詢條件被忽略 -->
- maven generator外掛生成的程式碼由於沒有強制的判定,似乎不會造成該風險(僅限select語句)
SQL注入風險
- #{}採用了jdbc的預編譯不存在風險,但是${}在構建語句的過程是需要進行表示式的計算的是動態拼接到語句中,如果直接採用這種方式存在SQL注入的風險。
- 在預編譯中各個型別都有相應的set函式,還有一些的函式,例如setInternal, 對於輸入的變數不做任何處理,如果直接拼接了變數到其中也會存在相應的安全風險
- 對於maven上的generator外掛而言,生成的mapper.xml大致如下:
<select id="selectByExample" parameterType="*.*.*Example" resultMap="BaseResultMap">
<!--
WARNING - @mbg.generated
This element is automatically generated by MyBatis Generator, do not modify.
This element was generated on Mon Mar 18 14:12:57 CST 2019.
-->
select
<if test="distinct">
distinct
</if>
<include refid="Base_Column_List" />
from asset_app
<if test="_parameter != null">
<include refid="Example_Where_Clause" />
</if>
<if test="orderByClause != null">
order by ${orderByClause}
<!-- 存在了${}拼接 -->
</if>
</select>
- 其中 order by就存在注入的風險,語句如下:
- mysql 如下:
IF(1=1,1,(select+1+from+information_schema.tables)) updatexml(1,if(1=1,1,user()),1) (CASE+WHEN+(1=1)+THEN+name+ELSE+price+END)
- oracle如下:
CASE WHEN (ASCII(SUBSTRC((SELECT NVL(CAST(USER AS VARCHAR(4000)),CHR(32)) FROM DUAL),3,1))>96) THEN DBMS_PIPE.RECEIVE_MESSAGE(CHR(71)||CHR(106)||CHR(72)||CHR(73),1) ELSE 7238 END) order by CASE WHEN 1=1 THEN 1 ELSE 0 END DESC
- mssql:
- https://github.com/incredibleindishell/exploit-code-by-me/blob/master/MSSQL%20Error-Based%20SQL%20Injection%20Order%20by%20clause/Error%20based%20SQL%20Injection%20in%20%E2%80%9COrder%20By%E2%80%9D%20clause%20(MSSQL).pdf
- 另外還有一處如下:
<sql id="Update_By_Example_Where_Clause">
<!--
WARNING - @mbg.generated
This element is automatically generated by MyBatis Generator, do not modify.
This element was generated on Mon Mar 18 14:12:57 CST 2019.
-->
<where>
<foreach collection="example.oredCriteria" item="criteria" separator="or">
<if test="criteria.valid">
<trim prefix="(" prefixOverrides="and" suffix=")">
<foreach collection="criteria.criteria" item="criterion">
<choose>
<when test="criterion.noValue">
<!-- 存在了${}拼接 -->
and ${criterion.condition}
</when>
<when test="criterion.singleValue">
and ${criterion.condition} #{criterion.value}
</when>
<when test="criterion.betweenValue">
and ${criterion.condition} #{criterion.value} and #{criterion.secondValue}
</when>
<when test="criterion.listValue">
and ${criterion.condition}
<foreach close=")" collection="criterion.value" item="listItem" open="(" separator=",">
#{listItem}
</foreach>
</when>
</choose>
</foreach>
</trim>
</if>
</foreach>
</where>
</sql>
不過生成的相關example類的時候已經封裝了各種方法,只要不去直接呼叫addCriterion去嘗試對欄位名(函式的第一個引數)進行動態設定,不存在安全風險,如下:
public Criteria andIdIsNull() {
addCriterion("ID is null");
return (Criteria) this;
}
public Criteria andIdIsNotNull() {
addCriterion("ID is not null");
return (Criteria) this;
}
public Criteria andIdEqualTo(Integer value) {
addCriterion("ID =", value, "id");
return (Criteria) this;
}
public Criteria andIdNotEqualTo(Integer value) {
addCriterion("ID <>", value, "id");
return (Criteria) this;
}
public Criteria andIdGreaterThan(Integer value) {
addCriterion("ID >", value, "id");
return (Criteria) this;
}
- idea的generator外掛生成的mapper中不存在注入的風險,但是也沒有提供order by的封裝,可能會需要人工去編寫相關的語句,在此時就要關注可能存在的注入風險。
刪庫風險
- 與第一條可能比較像,但是風險不太一樣,單獨拉了一條。
- 我們看generator外掛生成的xml檔案中關於delete方法的宣告(PS:idea生成的mapper中沒有關於delete方法的宣告)
<delete id="deleteByExample" parameterType="com.sse.security.sys.entity.VulDetailsExample">
<!--
WARNING - @mbg.generated
This element is automatically generated by MyBatis Generator, do not modify.
This element was generated on Tue Sep 25 15:41:07 CST 2018.
-->
delete from vulnerability_details
<if test="_parameter != null">
<include refid="Example_Where_Clause" />
</if>
</delete>
- _parameter是mybatis的內建變數,代表整個輸入的物件,如果物件為null,就會造成刪庫,但是貌似這種情況條件有一點苛刻。
- 不過對於目前的應用系統而言,delete方式應該處於被棄用的狀態,除了針對賬號登出的這類場景。
OGNL引入可能帶入的後門問題
- 在mybatis的框架中動態引數實際上是採用OGNL表示式進行處理
package org.apache.ibatis.ognl
通過getValue定位的相關函式如下:
- 那麼可知支援OGNL表示式有以下這些標籤或者屬性:
- if/when標籤的test屬性
- foreach標籤的collection屬性
- #{}或者${}中間的變數部分
bind標籤的value屬性(由name和value組成的變數會注入到context中)
- 注: 參考以下動態節點對應的相關類
map.put("trim", new TrimHandler()); map.put("where", new WhereHandler()); map.put("set", new SetHandler()); map.put("foreach", new ForEachHandler()); map.put("if", new IfHandler()); map.put("choose", new ChooseHandler()); map.put("when", new IfHandler()); map.put("otherwise", new OtherwiseHandler()); map.put("bind", new BindHandler()); handleToken,方法
- 嘗試過程
- 選擇以下payload進行嘗試
@java.lang.Runtime@getRuntime().exec('calc')
- 在相關位置新增OGNL表示式後測試以下幾點
- 在載入配置時能否觸發程式碼
- 在執行語句的能否觸發程式碼
- 在已經啟用的應用程式中動態插入能否觸發程式碼(PS:實際測試過程均不行,但是針對不同應用場景下,可能存在熱載入的問題)
- if/when標籤的test屬性
- 情況1 觸發程式碼
- 情況2 觸發程式碼
- 情況3 無法觸發
- PS: when標籤放在預設語句的最後一行無法觸發,但是第一行卻可被觸發
- foreach標籤的collection屬性
- 情況1 觸發程式碼
- 情況2 觸發程式碼
- 情況3 無法觸發
- PS: 由於返回的物件不一定是一個iterable,日誌中會有相關的錯誤提示。影響正常請求的訪問
- bind標籤的value屬性
- 情況1 觸發程式碼
- 情況2 觸發程式碼
- 情況3 無法觸發
- #{}或者${} PS: #{}無法觸發 (會呼叫get/set方法,沒有使用ognl)
- 情況1 觸發程式碼
- 情況2 觸發程式碼
- 情況3 無法觸發
- 補充測試,在原來目錄下直接新增一個mapper檔案檢視,是否會被載入
- 不會自動載入
解決方案
- 通用情況
- 對資料進行非空、非null的判斷,避免一些條件被規避
- 框架有些地方沒辦法轉換成相應合適的預編譯,有條件還是需要去配置一個全域性的過濾器
- 針對idea的生成器
- 需要對條件進行分析,哪些的必要條件,哪些不是。必要條件必須對空值和null值判斷,可以去修正自動生成的mapper
- 針對maven外掛的生成器
- 避免直接呼叫addCriterion函式,第一個引數避免由外部輸入,如果有必要可以通過列舉類結合switch case控制
- orderByClause屬性設定時,注意避免外部輸入。如果有必要進行動態設定。那麼需要採用列舉類結合switch case控制或者對輸入的資料進行過濾,僅保留字母數字下劃線逗號,至於遞增還是遞減的控制,通過switch case 控制後拼接字串常量。
- 後門問題
- 框架實現的機制,沒有辦法修復。
總結
- mybatis的框架梳理的還比較亂,有機會再理理。
參考
- Mybatis解析動態sql原理分析.