【技術分享】sqlmap源碼解讀(1)

介紹
作為web滲透界的神器之一,無論是挖掘src或者滲透測試,不少的師傅們都離不開這個工具。他的強大也不只是簡單地自動化注入,后續文章我會逐漸帶大家熟悉這個工具的原理。其實網上已有大佬做了很多的分析,我將更細致更基礎地進行分析
當然,一開始就直接拿最新版本分析是不妥的,目前該工具已經趨于完善,內置各種插件腳本,直接閱讀將會受到很大的影響,因此我找到一個比較老且穩定的版本

初始化
sqlmap全局變量如下
# 路徑相關paths = advancedDict()# 配置相關conf = advancedDict()# 共享一些對象kb = advancedDict()# 臨時對象temp = advancedDict()# 每個DBMS用到的語句queries = {}# 日志logger = LOGGER
全局變量使用的是自帶dict和它實現了的advancedDict類型,具體代碼并不是很復雜,初始化加入一個__initialised屬性。在執行__init__的self.__initialised = True及之前時都會調用__setattr__,執行到第一個if條件進入,做到了在初始化的時候進行一些屬性的賦值。后續以advancedDistObj.attr=value對advancedDictObj賦值時會直接走第2個和第3個條件。額,其實說這么多,sqlmap這樣做是為了區別賦值方式,全局變量中凡是使用到advancedDict類型的在后續使用中只有advancedDistObj.attr=value這樣的格式,而全局變量中的dict類型會使用dictObj[key]=value這樣的格式
class advancedDict(dict): ...... def __init__(self, indict=None, attribute=None): ...... self.attribute = attribute dict.__init__(self, indict) self.__initialised = True ...... def __setattr__(self, item, value): if not self.__dict__.has_key('_advancedDict__initialised'): return dict.__setattr__(self, item, value) elif self.__dict__.has_key(item): dict.__setattr__(self, item, value) else: self.__setitem__(item, value)
main函數
# 在全局變量path中初始化一些路徑相關(輸出目錄等)setPaths()# 打印banner信息banner()# 解析命令行輸入參數cmdLineOptions = cmdLineParser()# 初始化init(cmdLineOptions)if conf.start: # 啟動 start()
初始化部分代碼量不小,簡單概括如下:
- 合并命令行的一些參數
- 初始化日志相關
- 初始化全局變量conf和kb
- 過濾命令行參數的多于字符
- 設置Cookie/Referer/UA頭
- 設置請求方法默認為GET
- 處理HTTP基礎認證頭
- 處理HTTP代理相關
- 是否已知DBMS
- 如果用戶使用了谷歌語法這個功能進行處理
- 初始化urllib2的opener
- 嘗試更新sqlmap版本和mssql的xml
- 解析query的xml
mssql.xml:mssql的xml是一個類似數據庫的文件,保存了每個版本的mssql的指紋信息(為了方便具體版本的識別)
<root> <signatures release="2008"> <signature> <version> 10.00.1750 version> <servicepack> 0+Q956718 servicepack> signature> ...... signatures>root>
queries.xml:保存了注入需要用到的一些SQL語句
<dbms value="MySQL"> <cast query="CAST(%s AS CHAR(10000))"/> <length query="LENGTH(%s)"/> <isnull query="IFNULL(%s, ' ')"/> <delimiter query=","/> <limit query="LIMIT %d, %d"/> <limitregexp query="\s+LIMIT\s+([\d]+)\s*\,\s*([\d]+)"/> <limitgroupstart query="1"/> <limitgroupstop query="2"/> <limitstring query=" LIMIT "/> ......
準備工作
根據輸入參數得到URL后做基本的校驗
def initTargetEnv(): # 正則結合分割字符串方式拿到url的host,port等基本信息 parseTargetUrl() # 如果是GET注入的方式直接分割字符串拿到請求參數 # 如果是POST或HTTP頭注入需要輸入參數存在data文件,解析得到具體參數 __setRequestParams() # 處理恢復功能(如果程序中斷下次啟動用到) __setOutputResume()
檢測是否連接成功(并沒有采用requests而是使用原生urllib2)
checkConnection()
然后進行Cookie的封裝,向用戶詢問使用新Cookie或提供的輸入參數。如果沒有進行Cookie注入會進行所有可能參數的注入檢測,這也是核心的一部分
檢測閉合符號
值得一看的是檢測注入前先進行穩定性檢測,延時請求三次目標頁面,如果三次結果不一致認為是不穩定的
firstResult = Request.queryPage() time.sleep(0.5)
secondResult = Request.queryPage() time.sleep(0.5)
thirdResult = Request.queryPage()
condition = firstResult == secondResult condition &= secondResult == thirdResult
檢測每個參數是否動態,如果該參數不是動態的,也就是改變它不會造成頁面改變,那么認為它不存在注入,將會檢測下一個參數是否動態。而動態檢測類似穩定性檢測,都是三次請求頁面對比結果
# 構造隨機數 randInt = randomInt() # 這個agent相當于是做了個字符串拼接 payload = agent.payload(place, parameter, value, str(randInt)) dynResult1 = Request.queryPage(payload, place)
# 如果改變這個參數但返回頁面一致,認為它不是動態的 if kb.defaultResult == dynResult1: return False
logMsg = "confirming that %s parameter '%s' is dynamic" % (place, parameter) logger.info(logMsg)
payload = agent.payload(place, parameter, value, "'%s" % randomStr()) dynResult2 = Request.queryPage(payload, place)
payload = agent.payload(place, parameter, value, "\"%s" % randomStr()) dynResult3 = Request.queryPage(payload, place)
condition = kb.defaultResult != dynResult2 condition |= kb.defaultResult != dynResult3
檢測到可能存在注入的參數后,將會進行核心函數checkSqlInjection,檢測是否存在注入以及注入類型。注意這里的注入類型不是報錯注入盲注這樣的意思,而是檢測它的閉合符號,是id=0這樣的數字注入還是key=value這樣的字符串注入,而字符串注入又分為單雙引號。下文的parenthesis是處理括號問題,例如select * from table where id=((1));,默認范圍是0-4,即沒有括號或最多三個括號,一般不會有超過三個括號的情況
注意到首先構造一個true的payload,如果返回結果和不包含payload的頁面相等,進入第一個if。這時候構造一個false的payload,將結果再次對比,如果false和true的結果不一致,可以初步確認存在注入
payload = agent.payload(place, parameter, value, "%s%s AND %s%d=%d" % (value, ")" * parenthesis, "(" * parenthesis, randInt, randInt)) trueResult = Request.queryPage(payload, place)
if trueResult == kb.defaultResult: payload = agent.payload(place, parameter, value, "%s%s AND %s%d=%d" % (value, ")" * parenthesis, "(" * parenthesis, randInt, randInt + 1)) falseResult = Request.queryPage(payload, place) if falseResult != kb.defaultResult: ......
進行最終確認的代碼如下,由于這里是判斷數字型注入,注意上面的初步判斷使用的是randint隨機數字,而不是randstr隨機字符串。下方隨機的字符串構造的payload在存在數字注入的情況下不可能注入成功,根據這個條件最終確認數字注入
payload = agent.payload(place, parameter, value, "%s%s AND %s%s" % (value, ")" * parenthesis, "(" * parenthesis, randStr)) falseResult = Request.queryPage(payload, place)
if falseResult != kb.defaultResult: ...... return "numeric"
單雙引號類型的注入基本邏輯類似,最終確認payload如下,and后的條件也是不可能滿足的
payload = agent.payload(place, parameter, value, "%s'%s and %s%s" % (value, ")" * parenthesis, "(" * parenthesis, randStr))
最終判斷出注入類型會添加到injData中,如果有多個注入點會調用__selectInjection讓用戶自行選擇一個
if injType: injData.append((place, parameter, injType)) ......if len(injData) == 1: injDataSelected = injData[0]elif len(injData) > 1: injDataSelected = __selectInjection(injData) checkForParenthesis()檢查最終是幾個括號進行閉合的。createTargetDirs()函數創建輸出目錄。action()是核心部分的函數if condition: checkForParenthesis() createTargetDirs() action()
檢測DBMS
action()函數首先在確認目標DBMS,因為不同數據庫的語句和注入方式都有區別,首先初始化Handler,最后調用getFingerprint()方法
conf.dbmsHandler = setHandler()......conf.dbmsHandler.getFingerprint()
setHandler()中具體識別的插件是這里的每個Map。遍歷dbmsMap拿到Map插件,直接()調用,并在后續使用checkDbms()函數進行檢測
dbmsMap = ( ( MYSQL_ALIASES, MySQLMap ), ( ORACLE_ALIASES, OracleMap ), ( PGSQL_ALIASES, PostgreSQLMap ), ( MSSQL_ALIASES, MSSQLServerMap ), )
for dbmsAliases, dbmsEntry in dbmsMap: if conf.dbms and conf.dbms not in dbmsAliases: debugMsg = "skipping to test for %s" % dbmsNames[count] logger.debug(debugMsg) count += 1 continue
dbmsHandler = dbmsEntry()
if dbmsHandler.checkDbms(): if not conf.dbms or conf.dbms in dbmsAliases: kb.dbmsDetected = True
return dbmsHandler
return None
注意到一個基類,各種數據庫的識別插件都繼承自此類,其中的escape和unescape主要做編碼和解碼的作用
class Fingerprint: @staticmethod def unescape(expression) @staticmethod def escape(expression) def getFingerprint(self) def checkDbms(self)
無需具體分析每一個DBMS,可以重點關注大家最常用的MySQL,它的初始化又調用了Enumeration,無需關心,只是簡單的一個類,包含很多MySQL相關的屬性
class MySQLMap(Fingerprint, Enumeration, Filesystem, Takeover): def __init__(self): self.excludeDbsList = MYSQL_SYSTEM_DBS Enumeration.__init__(self, "MySQL")
unescaper.setUnescape(MySQLMap.unescape)
跟入MySQL的checkDbms(),首先就看到大家比較熟悉的一個細節,判斷是否大于5.0,因為MySQL5.0以上有至關重要的information_schema
if int(kb.dbmsVersion[0]) >= 5: self.has_information_schema = True
初步判斷版本邏輯,根據CONCAT語法邏輯進行判斷。其中inject.getValue這個函數很復雜,后續分析,現在認為它是根據注入的語句返回注入的結果即可。這里有一個小坑:randInt * 2是什么意思?如果randInt是1,那么答案應該是11而不是2,因為randInt = str(randomInt(1))
randInt = str(randomInt(1))query = "CONCAT('%s', '%s')" % (randInt, randInt)
if inject.getValue(query) == (randInt * 2): logMsg = "confirming MySQL"
使用LENGTH函數再次確認
query = "LENGTH('%s')" % randInt
if not inject.getValue(query) == "1": warnMsg = "the back-end DMBS is not MySQL"
嘗試從information_schema獲取數據,如果可以拿到,說明是MySQL5.0以上
if inject.getValue("SELECT %s FROM information_schema.TABLES LIMIT 0, 1" % randInt) == randInt: setDbms("MySQL 5") self.has_information_schema = True
MySQL6某些小版本的檢測。例如PARAMETERS表存放這存儲過程和存儲函數的參數信息以及存儲函數的返回值,及我們一般意義上的存儲過程和函數;PROFILING表提供了語句分析信息。這兩個表分別在6.0.5和6.0.3版本提供
if inject.getValue("SELECT %s FROM information_schema.PARAMETERS LIMIT 0, 1" % randInt) == randInt: if inject.getValue("SELECT %s FROM information_schema.PROFILING LIMIT 0, 1" % randInt) == randInt: kb.dbmsVersion = [">= 6.0.5"] else: kb.dbmsVersion = [">= 6.0.3", "< 6.0.5"]
后續的代碼可以跳過了,都是根據information_schema中某些表是否存在進行精確版本判斷

最后一個else使用了我們常用的函數self.banner = inject.getValue("VERSION()")
判斷結束后,會在conf.dbmsHandler.getFingerprint()中格式化輸出,而格式化輸出中有再次校驗DBMS的一個函數__commentCheck,這里用到一個技術正是大家繞WAF常用的:內斂版本注釋。首先/* NoValue */請求確認響應和默認響應一致,然后構造內斂版本注釋判斷語句是否能正常執行,對版本信息進行再次確認
query = agent.prefixQuery("/* NoValue */")query = agent.postfixQuery(query)payload = agent.payload(newValue=query)result = Request.queryPage(payload)
if result != kb.defaultResult: warnMsg = "unable to perform MySQL comment injection" logger.warn(warnMsg)
return None
# MySQL valid versions updated at 10/2008versions = ( (32200, 32233), # MySQL 3.22 (32300, 32354), # MySQL 3.23 (40000, 40024), # MySQL 4.0 (40100, 40122), # MySQL 4.1 (50000, 50072), # MySQL 5.0 (50100, 50129), # MySQL 5.1 (60000, 60008), # MySQL 6.0)......randInt = randomInt()version = str(version)query = agent.prefixQuery("/*!%s AND %d=%d*/" % (version, randInt, randInt + 1))query = agent.postfixQuery(query)payload = agent.payload(newValue=query)result = Request.queryPage(payload)
if result == kb.defaultResult: ......
確認完DBMS之后,將進行具體的注入,下一篇文章將分析,順便分析至關重要的inject.getValue是如何做到傳入一個注入表達式得到結果的