管中窺豹——框架下的SQL注入 Java篇

  • 2019 年 10 月 3 日
  • 筆記

管中窺豹——框架下的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` &lt;= #{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();  }    // // tnrf 會被替換成空格,重構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表達式後測試以下幾點
      1. 在載入配置時能否觸發程式碼
      2. 在執行語句的能否觸發程式碼
      3. 在已經啟用的應用程式中動態插入能否觸發程式碼(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的框架梳理的還比較亂,有機會再理理。

參考