Skip to main content0xAdham

Command Palette

Search for a command to run...

EYCC: Mall Albostan

Written by
Avatar of 0xAdham
0xAdham
Published on
--
EYCC: Mall Albostan

Platform: EYCC Challenge: Mall Albostan Category: Web Difficulty: Medium Points: 484 Author: 00xcanelo

Solved by 0xAdham and 0xMero.

Mall Albostan, Downtown Cairo's go-to spot for laptops, GPUs, and everything in between, just launched its first online storefront and internal vendor dashboard. The dev team built it fast to get ahead of the holiday rush — registration's open to anyone, but the actual store management lives behind an admin login that, as far as they're concerned, nobody outside the team will ever see.

Link: https://mall-albostan.chals.eycc.2hwa.xyz

From SQL Injection to RCE

TL;DR

This challenge chained together five bugs:

  1. SQL injection → leak the JWT secret
  2. Forge a session with role: admin → authenticate as admin
  3. Bypass the file-upload restrictions → upload a PHP shell
  4. XXE → leak the uploaded file's UUID
  5. Access the shell with that UUID → RCE → read the flag

Attack chain diagram

Chapter 1: Examining the Source Code

I won't walk through all of the source, just the parts that matter.

After reading register.php and login.php I found nothing abnormal — simple login and register logic.

Let's look at search.php in the api folder:

That $q goes straight into the query string — first vulnerability: SQL injection.

In seed.php we found:

This page hands us the whole schema — the table structures (column names and datatypes) and the two databases in play (app.db, uploads.db).

Writing down the findings:

  1. We know the table structure in both app.db and uploads.db.
  2. There's an app_config table with key/value columns, and one of the keys is literally jwt_secret.

Keep this in mind — we'll need it soon.

Let's look at a more interesting folder: admin.

In admin we find import.php, upload.php, and index.php. There's nothing interesting in index.php (except that we need an admin role in our session to reach it), so we focus on the other two.

upload.php has an upload function with a blocklist of extensions: php, php1, php2, php3, php4, php5, php7, php8, pht, phps, phar, pl, py, rb, sh, exe, bat.

But notice — Canelo forgot to add .phtml 😃

See how I'll get RCE on your server, Canelo — you have to bear the consequences of your mistake 💀

import.php has a weak XML validation function that we can bypass several easy ways (covered in the Exploiting XXE section).

So, to recap what we have:

  1. Full knowledge of the database schema — database names, tables, columns, and datatypes.
  2. A SQLi in the search field via search.php.
  3. A non-blocked PHP extension (.phtml) we can use to upload a shell.
  4. A weak XML validation function.

Now for the fun part.

Chapter 2: Exploiting the Discovered Vulnerabilities

After logging in, we land on this page.

Landing page after login

Section 1: SQL Injection — Leaking the JWT Secret

After clicking Browse Catalog, we're redirected to products.php, where whatever we type in the search box is passed as the q GET parameter to /api/search.php and dropped straight into the SQL query — no sanitization, no parameterized statements.

Products search page

From the schema we already know there's a table holding the JWT secret, so that's the first thing we grab.

Back to this table:

We know the columns and types, but first we need the number of columns in the original query to line up a UNION. 🔥

And we need to know how the app behaves on error, so we can tell a broken query from a working one.

First, deliberately break it:

Broken query returns no output

The error isn't shown.

Now a valid query:

Valid query returns rows

Good. 🔥 Behavior confirmed: nothing on error, and on a correct query it reflects the output back. Let's extract app_config:

UNION leaks the JWT secret

|| concatenates TEXT values in SQLite.

Section 2: Forging an Admin Session

Now that we have the secret, we sign our own session cookie with it and set role to admin without breaking the signature.

For this we use jwt_tool:

The tool takes the JWT as the first positional argument; the rest are options:

  • -S hs256 — the signing algorithm for the signature.
  • -p <secret> — the secret key used to sign the token.
  • -T — interactive tamper mode. We edit the claims, and the tool re-signs with the provided secret, producing a valid JWT.

And that's our new valid admin session 😍:

After swapping the token into browser storage, the admin page opens right up:

Admin page accessible with the forged token

Section 3: Uploading a Shell

The admin page lets us upload an attachment (JPEG, PNG, GIF, WebP) and import an XML profile.

Admin upload + import XML page

Start with the upload function. It claims to accept only (JPEG, PNG, GIF, WebP) — in practice, not exactly, as we'll break it. 🥱

Upload a normal GIF first.

Normal GIF accepted

As expected, accepted. But I want to know how the server validates — the Content-Type header, the magic bytes (the first few bytes of a file), or the extension. Let's find out.

A blocked extension gives this message:

Blocked extension rejected

I uploaded a .php file (knowing it's a blocked extension) and got rejected. Yes, I'm trying to hack you, and I will.

Now upload a .phtml file with Content-Type: application/x-httpd-php, to see whether the server checks content type:

.phtml accepted

Accepted — so it doesn't check the content type. Let's turn it into a shell.

Write this into the .phtml file and upload it for RCE:

It runs whatever command we pass in the cmd parameter. Example: example.com/shell.phtml?cmd=ls runs ls and reflects its output.

Upload the shell:

Shell rejected

Rejected? Maybe the server reads the magic bytes. Let's prepend a GIF magic byte.

Shell accepted with GIF magic bytes

There we go — shell uploaded. Getting close, Canelo. Only a matter of time.

But things don't always go as planned — this isn't the last step. The server stores uploads under a generated UUID, not their original name, because of this in upload.php:

So we still need the UUID our shell landed under.

Section 4: XXE — Leaking uploads.db

The problem Canelo left us with: the shell is uploaded, but it's stored under a random UUID we don't know. That UUID lives in uploads.db — a file that's Require all denied over HTTP, so we can't just request it. We need to read it off the filesystem. That's where the import.php XXE comes in.

The "Import Settings" page takes XML from POST xml, parses it server-side, and echoes every element's text content back into the Result box. That echo is our exfil channel — whatever an entity resolves to gets reflected straight back to us (classic in-band XXE).

Import Settings result box

The vulnerable code (/admin/import.php):

Where the vuln is: the parser is configured maximally dangerous — everything at (2) pushes the same way:

SettingEffect
resolveExternals = trueResolve external entities (SYSTEM "...")
substituteEntities = trueExpand entities into the node tree
LIBXML_NOENTSubstitute entities (misleading name — it enables substitution)
LIBXML_DTDLOADLoad the DTD / internal subset (<!DOCTYPE ...>)

If a <!DOCTYPE> carrying an external entity reaches loadXML, libxml fetches the file off disk and inlines it; the loop at (3) prints it back → arbitrary file read as www-data.

Canelo knew XXE was the risk — that's the whole point of is_safe_xml() at (1). The bug: the "defense" is a string blacklist on the raw request bytes (stripos for the literal <!DOCTYPE), not a parser-level control. And a byte blacklist guarding a spec-driven parser is exactly the kind of gap we look for.

The bypass — encoding mismatch (UTF-16):

stripos($xml, '<!DOCTYPE') searches for the literal ASCII bytes 3C 21 44 4F 43 54 59 50 45. But DOMDocument::loadXML is a full XML processor — per the XML spec it auto-detects encoding from the BOM / XML declaration. That gap is the bypass.

We submit the same document as UTF-16:

  • On the wire, <!DOCTYPE becomes 3C 00 21 00 44 00 4F 00 43 00 54 00 59 00 50 00 45 00 — every ASCII char followed by a 00. The contiguous ASCII string <!DOCTYPE no longer exists in the bytesstripos returns false → filter passes.
  • libxml sees the UTF-16 BOM (FF FE), decodes back to real characters, sees a valid <!DOCTYPE>, and processes the entity.

One reader parses bytes, the other parses characters — the blacklist loses.

This must be sent as raw UTF-16 bytes in the xml param. Pasting into the browser textarea fails — the browser re-encodes the form to UTF-8, restoring the ASCII <!DOCTYPE → blocked. So we build it in Burp.

The payload:

Simple proof first (a text file, reflected directly):

But our real target, uploads.db, is a binary SQLite file. Raw binary bytes break the XML text node (null bytes, non-UTF-16 sequences), so we wrap it in php://filter/convert.base64-encode — that turns the file into clean base64 text that survives the echo intact. That's why this specific payload:

Delivery — send the UTF-16 bytes URL-encoded into xml=. Raw Burp request (spaces as %20, every char followed by %00, BOM %FF%FE up front, no trailing newline after the last %00):

These UTF-16 bytes are alignment-sensitive — every byte counts. Three ways it silently breaks (all cost me a send before I caught them):

Symptom in the Result boxCauseFix
Rejected: invalid document format.Body sent as ASCII (browser textarea, or %3C%21DOCTYPE… with no %00 nulls) → stripos matchesSend real UTF-16 bytes (%FF%FE, every char + %00)
Extra content at the end of the documentTrailing CRLF after the body → UTF-16LE reads 0D 0A as one non-whitespace char after </r>Delete the trailing newline; body must end on the last %00
String not started expecting ' or "Spaces encoded as + and sent literally → SYSTEM+"…Percent-encode spaces as %20, not +

Proof: the entity &x; resolves to the base64 of uploads.db and gets echoed straight back:

Base64 of uploads.db reflected in the result box

Decode it and read the upload table to recover our shell's UUID:

There it is — the UUID of the .phtml shell we uploaded in Section 3. uploads.db was denied over HTTP but wide open to a filesystem read via XXE. Now we know exactly where our shell lives, which is the last thing we needed before we can hit it for RCE.

Worth noting why we even need XXE here: PHP/libxml XXE can only read known paths — it can't list directories. So it can't find the randomly-named flag directly. But it doesn't need to: it reads uploads.db (a known path), which hands us the UUID, which makes the shell reachable. The shell does the directory listing XXE can't. XXE is the pivot, not the finisher.

Chapter 3: Reading the Flag 🚩

With the shell's UUID recovered, we can hit its URL directly:

Output:

whoami confirms RCE

Yes — RCE confirmed.

Now list the root directory:

Listing the root directory

The flag is right there 😍 — flag_dff347a6f2124a7184303ea2a6c89045.txt.

The long-awaited moment:

Reading the flag

The chain is complete, and the flag is ours.

Mitigations

Prevent SQL Injection

  • Use parameterized queries (prepared statements) instead of concatenating user input into SQL. For example:

instead of:

Protect JWT Secrets

  • Never hardcode JWT secrets in source or in the database.
  • Store secrets in environment variables or a dedicated secrets manager.
  • Rotate signing keys periodically and immediately after any suspected compromise.

Secure File Uploads

  • Use a whitelist of allowed extensions instead of a blacklist.
  • Validate the file's MIME type and magic bytes.
  • Store uploaded files outside the web root whenever possible.
  • Disable script execution in the upload directory.

Prevent XXE Attacks

  • Disable external entity resolution in the XML parser.
  • Use secure parser configurations instead of custom validation logic.
  • Validate XML against a trusted schema when appropriate.

In conclusion, thanks to Canelo for creating such an enjoyable challenge, and to Mont5ab El2hwa for organizing an amazing CTF. See you in the finals! 🫡


Pro Tip

Think the write-up is over? Not quite.

How would we know the available tables and their columns if they weren't handed to us in the source? Here's the hard way to get the database's structure yourself.

Every database has table(s) called system catalogs, and their location differs between engines. In MySQL, system catalogs live in a separate database called information_schema, which contains tables like columns and tables. In PostgreSQL, they live in a schema called pg_catalog. Don't worry — we'll explain everything.

What is a system catalog?

A system catalog is a set of tables (and sometimes views) that store metadata about the database itself — its tables, columns, indexes, views, users, and other objects. In short: tables that describe other database objects.

What is a schema?

Schema isn't a constant definition across all databases:

  • In MySQL, schema and database mean the same thing — there's no distinction.
  • In PostgreSQL, a schema is a logical grouping of tables — think of it like a folder. There's a schema called public for user-created tables, and another called pg_catalog for the system catalogs.

System catalogs per database

MySQL: a database called information_schema containing tables like columns and tables. These have useful columns such as table_name, table_type, and table_schema.

PostgreSQL: system catalogs live under the pg_catalog schema — for example, pg_catalog.pg_tables and pg_catalog.pg_database. They contain columns like table_name and schema_name.

SQLite: one table holds information about the database's tables, called sqlite_master. Its columns are type, name, tbl_name, rootpage, and sql — the most important being sql, since it contains the exact statement the table was created with.

For example:

We get the statement showing how each table was created:

What is a view?

A view isn't an actual table — it's a virtual one. It doesn't store real data; it stores a query. When someone queries a view, it fetches its data from the actual system catalog behind the scenes.

For example, in PostgreSQL we can run SELECT * FROM information_schema.tables, although this isn't PostgreSQL's real system catalog — it's a view. Under the hood, it runs a query against the real catalog tables in pg_catalog (like pg_class and pg_namespace) and returns the result. MySQL works the same way — information_schema.tables is also a view over the server's internal metadata, not a raw table.

information_schema sits on top of pg_catalog

The purple arrow is the key thing to notice: information_schema sits on top and reads from pg_catalog underneath — it's not a second, competing source of truth, just a portable wrapper around the real metadata.

0xAdham & 0xMero

Edit on GitHub
Last updated: --