Human 1 - sqlmap 0: defeating automation through manual exploitation

Summary

:warning: This bug was reported in a private program in which it is not allowed to publish the vulnerabilities found. So this is a partial disclosure, only the essential technical details are exposed.

In this post I will show the trickiest SQL injection I have ever exploited. Moreover, I have the pleasure to share this report with my friend Bernardo Viqueira Hierro, aka. IckoGZ, who discovered the asset and the vulnerability. However, this was not enough and the company told him that he had to demonstrate an impact to win the bounty, so he had to retrieve some data from the BBDD to prove the severity of the SQL injection. Some tools like sqlmap were able to detect the vulnerability but not to exploit it, so Icko contacted me to see if I could do something, so I performed the exploitation.

I hope you enjoy and learn a lot from this post!

1. Asset discovery

Icko discovered the asset through google dorking using the following tool

IP Google Dorking tool

developed by 0x21SAFE. This tool is used to find hidden assets by searching only IPs and can be useful during horizontal recon. This means that during the domain enumeration (domain1.com, domain2.es, domain3.us, …), it may also be interesting to enumerate IPs that do not have an associated domain or subdomain but belong to the company.

Allowing the web to open multiple tabs so that the tool can work properly and searching for one of the terms suggested by the tool

Facebook internal assets report

yields the following result

The tool generates several searches in different tabs using Google Dorking. The tab you see in the above capture corresponds to the following Google Dork

(Facebook internal assets report) (site:*.*.60.* |site:*.*.59.* |site:*.*.58.* |site:*.*.57.* |site:*.*.56.* |site:*.*.55.* |site:*.*.54.* |site:*.*.53.* |site:*.*.52.* |site:*.*.51.* |site:*.*.50.* |site:*.*.49.* |site:*.*.48.* |site:*.*.47.* |site:*.*.46.* |site:*.*.45.* |site:*.*.44.* |site:*.*.43.* |site:*.*.42.* |site:*.*.41.* |site:*.*.40.* |site:*.*.39.* |site:*.*.38.* |site:*.*.37.* |site:*.*.36.* |site:*.*.35.* |site:*.*.34.* |site:*.*.33.* |site:*.*.32.* |site:*.*.31.* |site:*.*.30.* |site:*.*.29.* |site:*.*.28.* |site:*.*.27.* |site:*.*.26.* |site:*.*.25.* |site:*.*.24.* |site:*.*.23.* |site:*.*.22.* |site:*.*.21.* |site:*.*.20.* |site:*.*.19.* |site:*.*.18.* |site:*.*.17.* |site:*.*.16.* |site:*.*.15.* |site:*.*.14.* |site:*.*.13.* |site:*.*.12.* |site:*.*.11.* |site:*.*.10.* |site:*.*.9.* |site:*.*.8.* |site:*.*.7.* |site:*.*.6.* |site:*.*.5.* |site:*.*.4.* |site:*.*.3.* |site:*.*.2.* |site:*.*.1.* |site:*.*.0.* )

This particular dork is used to search for all IPs of the form X.X.[0-60].X in which the term Facebook internal assets report appears. This is a partial result but joining the results of all the tabs we have the total result.

This tool can be useful to perform searches on terms that are very specific to the company, such as a footer. If you take the footer of a website that you know 100% that it belongs to the company and you search for it using this tool, it is likely that you will find other websites with the same footer and therefore belonging to the same company. Original recon method!

2. Vulnerability discovery

As I said before, the asset was an IP with no associated domain. Icko found an IP that had several routes with different PHPs, one of them took a number as a parameter but he discovered that there was a WAF so all the tests he did were manual. He sent in that param the following payload

28 AND SLEEP(2)

and it triggered a delay of almost 5 seconds, as you can see in the following capture

During the tests I did, an input of 2 seconds in the sleep triggers a delay between 4 and 5 seconds. The variable time between 4 and 5 seconds is the response time so we could say that the real multiplier is approximately 2, that means, an input of 2 seconds in the sleep triggers a delay of 4 seconds in the response. This happens quite often in blind SQL injections but with different multipliers.

3. Vulnerability exploitation

The company told Icko that finding a SQLi was not enough and that he had to exploit it to win the bounty. However, tools like sqlmap were not able to return any information and the exploitation of the SQLi did not seem obvious. Now is when I enter the game :sunglasses:.

3.1. Steps of exploitation

Since we already got a delay and the response always had the same size and content, I thought the smartest way to extract information was triggering time delays. This way of exploiting SQLi is difficult but I have always found it useful to extract some information from the BBDD and demonstrate that the SQLi is weaponizable. Besides, it is usually quite funny :smiley:.

If sqlmap is not able to retrieve any information from the database, it is because there are problems: sanitizations, keyword blacklisting… That is why the strategy to follow is to build the payload step by step making sure that all the characters, expressions, functions… are evaluated correctly by the backend.

The best way to do this is to evaluate boolean expressions that we know to be true and proceed as follows:

  • If a delay is triggered, it means that all the characters of the expression are accepted by the backend.
  • If a delay is not triggered, it means that some of the characters of the expression are sanitized or rejected by the backend.

To follow this strategy, the first thing to do is to build a request that evaluates a conditional statement. By sending the following payload

28 AND (IF(1 < 100000, SLEEP(2), null))

it returns the following response

The conditional statement is working because it evaluates 1 < 1000 as true and therefore triggers the delay.

3.1.1. Bypassing the sanitization of the symbol =

The ultimate goal is to compare characters so it will be necessary to use a comparison operator such as =. Therefore, using a payload as follows

28 AND (IF(1 = 1, SLEEP(2), null))

should trigger a delay. However the response I got was

where there was no delay.

All elements of the payload except the symbol = have been tested successfully so the only reason for not triggering the delay must be a problem with the symbol =, for example a sanitization. So… is there another way to check if two numbers or characters are the same? Yes, using the operator LIKE. Thus, using the following payload

28 AND (IF(1 LIKE 1, SLEEP(2), null))

the delay is triggered

3.1.2. Bypassing the sanitization of quotes

Ok, now we can check if 2 numbers are the same, but what we will want to compare in most situations are characters, so the following payload

28 AND (IF('a' LIKE 'a', SLEEP(2), null))

should trigger a delay. However there was no delay, as you can see in the following capture

Again, the problem must be the new element that we have introduced in the payload, which are the single quotes (the same happens with the double quotes). How can we compare characters if we cannot use single or double quotes?

As we have seen, we can work with numbers and with functions, so it would be good to use a function that given a number returns a character. This way we could work with characters without having to declare them explicitly. What do you know that relates numbers and characters? In effect, the ASCII code.

In MySQL the CHAR function returns the character associated to the ASCII code given as a parameter. For example, the character a has ASCII code 97, so the following payload

28 AND (IF(CHAR(97) LIKE CHAR(97), SLEEP(2), null))

should cause a delay, and the response was

As you can see, a delay is triggered, so the CHAR function was executed successfully.

3.1.3. Bypassing the blacklisting of the SUBSTR function

To extract information from a BBDD exploiting a SQLi, the SUBSTR function is usually used to guess by brute force the value of each string, character by character. Therefore it is necessary to verify that this function works correctly.

To test this function it is necessary to use a string as input, but as I said before, we cannot declare strings. For this reason I tried to use the VERSION function, that during the tests I verified that it worked correctly. This function returns the version of the MySQL installed and can be useful to check that the SUBSTR function works correctly. However the following payload

28 AND (IF(SUBSTR(VERSION(),1,1) LIKE CHAR(i), SLEEP(2), null))

did not trigger a delay for any value of i = 48, 1, …, 57 (the ASCII codes of 0, 1, …, 9). Again, considering that the VERSION function works correctly, there is no other option than that the SUBSTR function is blacklisted or not available because the first character of the version must be an integer between 0 and 9.

Here I thought I had a problem. However, after many tests I realized that there were some functions that worked, others that didn’t… in a seemingly random way. On the other hand, in programming languages it is quite common that a function can be rewritten in function of the others, so maybe it is possible to simulate the SUBSTR function by composing other available functions. Searching in Google for MySQL functions to work with strings, I found the LEFT and RIGHT functions.

After testing some payloads I found that although the SUBSTR function did not work, the LEFT and RIGHT functions were available, so after racking my brain for a long time I discovered that the following formula is satisfied:

i-th letter of the string of the variable input = SUBSTR(input, i, 1) = RIGHT(LEFT(input, i), 1) for i = 1, ..., LENGTH(input)

By reading the specifications of the LEFT and RIGHT functions you will see that it makes sense. You can also check it by executing the following SQL code

-- Set an input string and a position in the string to extract the character
SET @inputString := 'hackcommander';
SET @position := 6;

-- Displays the @position character of @inputString
SET @substrChar := SUBSTR(@inputString, @position, 1);
SELECT @substrChar;

-- Extract and print the first @position characters of @inputString
SET @leftString := LEFT(@inputString, @position);
SELECT @leftString;

-- Extract the last character of the first @position characters of @inputString
SET @rightChar := RIGHT(@leftString, 1);
SELECT @rightChar;

in the following online MySQL code interpreter, as you can see in the following capture

As you can see, both the SUBSTR function and the composition of the LEFT and RIGHT functions return the sixth character of the input string.

Now using the following payload

28 AND (IF(RIGHT(LEFT(VERSION(),1),1) LIKE CHAR(i), SLEEP(2), null))

for i = 48, 1, …, 57 there was a delay for i = 56, as you can see in the following capture

This means that the first number of the MySQL version was 8. Performing this process for each character of the string returned by the VERSION function (it had 6 characters), I demonstrated that the version was 8.0.15.

3.1.4. Weaponizing the payload to dump the database name

It is time to get some sensitive information. After trying many different functions, I found that the only available function to get the database name was the SCHEMA function. For some reason, the DATABASE function was not available. So, after some tests I came up with the following payload

28 AND (IF(LENGTH(SCHEMA()) LIKE 8, SLEEP(2), null))

which causes a delay as you can see in the following capture

This capture shows that the SCHEMA function works correctly and that it has 8 characters. Now it is only necessary to apply the payload that we have discovered to find out each of the characters of the string. The name of a database in MySQL can only be composed of the following characters

a-z,A-Z,0-9,_,$

whose associated ASCII codes are

97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 95, 36

Therefore, to find out which is the database name, it is only necessary to send the following payload

28 AND (IF(RIGHT(LEFT(SCHEMA(),i),1) LIKE CHAR(j), SLEEP(2), null))

for i = 1, …, 8 and j = each of the ASCII codes, which is easy to do using the burp intruder. The responses that trigger a delay are those associated with correct characters.

For example, the following payload

28 AND (IF(RIGHT(LEFT(SCHEMA(),6),1) LIKE CHAR(97), SLEEP(2), null))

triggers a delay, as you can see in the following capture

This means that the sixth letter of the database has ASCII code 97, that means, the sixth letter of the database is a.

However, this SQL injection was quite strange. In fact the intruder scan I sent to find out what was the sixth character returned 3 ASCII characters, as you can see in the following capture

A delay is triggered for the ASCII codes 65, 95 and 97, corresponding respectively to the characters A, _, a. This behavior is repeated for all the positions of the string, always returning the same letter in lowercase and uppercase, and the underscore. It does not make any sense to return the same uppercase and lowercase letter because the LIKE operator is case sensitive, but it makes even less sense to always return the underscore. As I said, the strangest SQLi I have ever exploited :satisfied:.

3.2. Why does the payload work??

The name of the vulnerable parameter was of the type id, value, price… which indicates that it is a numerical parameter. In fact, this fits with the payload that Icko used during the detection because if it were a string parameter, it would have been necessary to use some single or double quotes to break the string and introduce a new predicate in the where clause. However, if the parameter is numeric, there is no need to break any string, so there is no need to use single or double quotes.

If we suppose that the vulnerable parameter was id, the query in the backend should be similar to the following one

SELECT name, description FROM products WHERE id = $_GET["id"]

If we enter a “non-malicious” id, such as 1, the query that will be executed is

SELECT name, description FROM products WHERE id = 1

but if we enter a “malicious” id such as the one used by Icko during the detection

28 AND SLEEP(2)

and no input sanitization is being applied in the backend, the query that will be executed is

SELECT name, description FROM products WHERE id = 28 AND SLEEP(2)

As you can see, the injected SQL code is executed in the where clause of the query.

But the real question is… why the previous payloads I sent didn’t work? And the truth is that I don’t know the cause or if there were different causes. For example, not being able to use single quotes could be due to the WAF but I don’t think so. To simplify the post, I told a little lie since I said that “the quotes were sanitized”, but this was not true for all cases. When quotes were used to enclose numbers, such as ‘8’, the payload worked correctly, but when they were used to enclose characters, such as ‘hackcommander’, the payload did not work. What the hell is this? :angry:

And the problem of not being able to use the SUBSTR function is even stranger, although it could also have been caused by the WAF. This is the bad side of black box pentesting, sometimes we don’t know why things happen, and we will never know if we don’t get access to the source code :disappointed:.

4. Report resolution

Although it was not an important asset, it had confidential information stored in the BBDD, so we can consider that the asset criticality is medium. With respect to the severity of the vulnerability, they determined that the scoring was:

Medium (6.5) [CVSS:3.0/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N]

Some components of the vector don’t make much sense:

  • Confidentiality to Low doesn’t make sense because we were told to only get harmless information, such as the name of the BBDD, and we already demonstrated that we could get it.
  • Integrity to Low doesn’t make much sense either because I didn’t prove that I could upload files or edit data.

However, one component that does make sense and that under the severity was the Attack Complexity, which was set to Low, and it is normal because as we have seen the exploitation was quite difficult, and trying to dump entire tables would require a custom exploit.

Therefore, the report was classified as

  • Asset criticity: Medium
  • Vulnerability severity: Medium
  • Bounty: More than $2000 (it was paid as High, maybe because it was a SQLi)

5. Lessons learned

  • Collaborations are one of the most beautiful things in bug bounty. Both Icko and I learned from each other and, moreover, we made money with a report that initially they weren’t going to pay for. It’s a win-win. Therefore, I recommend collaborating whenever you can, always with serious and trustworthy people.
  • Automatic tools like sqlmap are quite useful but sometimes they’re not enough. In this report, we saw how sqlmap was unable to retrieve information from the database, but as a hacker, I was able to do it. Perhaps this will change with the advancement of AI, but for now, automatic tools don’t replace the skills of a hacker.
  • Many people say that knowing programming is not necessary to hack, which has always seemed absurd to me. It may not be necessary to know programming to run a ffuf scan or exploit an SQLi with sqlmap, but it is a fundamental skill to understand why vulnerabilities exist and to be able to improvise. In this case, my programming knowledge helped me to bypass the blacklisting of the SUBSTR function in MySQL by composing the RIGHT and LEFT functions. It’s not a big deal, but if you have no idea about programming, you wouldn’t even think that this is possible.
  • When you find a bug, always try to achieve maximum impact even if your report has already been accepted. In this case, I made a mistake because when I finished the PoC to retrieve the database name, I considered the exploitation finished. What I should have done is to continue escalating the vulnerability to dump any row of a table, attempt to upload a file to achieve RCE… but I did not. This way, the CVSS score could have been higher and therefore, the bounty too. Given the complexity of the exploitation, probably none of this was possible, but I should have tried.