SSRF inside Provider feature
For PoC, check my github : https://github.com/hadhub/CVE-2026-49345-Mercator-SSRF
The advisory : https://github.com/sourcentis/mercator/security/advisories/GHSA-6q97-4q5r-96j6
Description
The “CVE” tab on the Mercator configuration page exposes a “Test Provider” button.
This button is intended to check that a CVE provider URL answers correctly on its /api/dbInfo endpoint.
The value of the provider field is passed as is to curl_init() and executed by curl_exec().
No validation is applied on the scheme, on the host or on the destination IP address.
An authenticated low-privilege attacker can therefore force the Mercator server to emit arbitrary network requests. The partial output of the target’s response is returned to the page through the redirect flash message. The resulting primitive enables internal port scanning and out-of-band interaction with unauthenticated internal services.
Prerequisites
To reach the vulnerable function the attacker needs:
- An authenticated account holding the
configurepermission (id 262 in the seeder). - This permission is granted by default to the
Admin,UserandCartographerroles. - Only the
Auditorrole lacks this permission. - The
lowuseraccount (User role) is enough to exploit the bug. This proves that the feature is not restricted to administrators.
No other server-side condition is required to trigger the base SSRF.
Vulnerable fields
The vulnerable field is unique:
- HTTP parameter:
provider. - Endpoint:
POST /admin/config/parameterswith_method=PUTspoofing. - Required hidden fields:
active_tab=cveandaction=test_provider.
The provider value is concatenated with the literal string /api/dbInfo then passed to curl_init.
The # character cancels the concatenation by turning the suffix into a URL fragment.
Workflows
Attack sequence
The diagram below describes the complete SSRF flow. The red zone isolates the phase where the Mercator server emits an arbitrary request toward an internal target chosen by the attacker. The partial response of that target is sent back through the redirect flash message. This forms the exploitable return channel.
sequenceDiagram
actor Attacker as Attacker (User role)
participant Server as Mercator Server
participant Internal as Internal Service (127.0.0.1, Redis, etc.)
Note over Attacker,Internal: Phase 1, Attacker authentication
Attacker->>Server: POST /login (lowuser, Lowuser123!)
Server-->>Attacker: 302 to /admin (authenticated session)
Note over Attacker,Internal: Phase 2, CSRF token harvest
Attacker->>Server: GET /admin/config/parameters?tab=cve
Server-->>Attacker: HTML form + CSRF token
Note over Attacker,Internal: Phase 3, SSRF trigger (SOURCE)
Attacker->>Server: POST /admin/config/parameters
_method=PUT, active_tab=cve, action=test_provider
provider=http://127.0.0.1:6379/path#
Server->>Server: handleCve('test_provider', $request)
testProvider($provider)
rect rgba(242, 78, 78, 0.2)
Note over Server,Internal: Outbound request (SINK)
Server->>Internal: curl_init($provider . '/api/dbInfo')
curl_exec()
Internal-->>Server: Response body or connection error
end
Note over Attacker,Internal: Phase 4, Information disclosure
Server-->>Attacker: 302 to /admin/config/parameters?tab=cve
flash message contains target output
Attacker->>Attacker: Read flash message
(open port, banner, JSON fields)
Note over Attacker,Internal: Phase 5, Pivot scenarios
Attacker->>Server: provider=telnet://127.0.0.1:3306#
(port scan)
Attacker->>Server: provider=gopher://127.0.0.1:6379/_...#
(Redis RCE, conditional)
With this primitive the attacker can:
- Scan internal IP ports by measuring the curl response delay.
- Reach
127.0.0.1andlocalhost, which often host unauthenticated services. - Pivot to a service such as Redis or Memcached to escalate to a conditional RCE.
Application file flow (source to sink)
The diagram below maps the vulnerability across the Mercator code base.
It follows the user input from the route, traverses the saveConfig method then handleCve, and lands in testProvider where the raw string is passed to curl_init.
stateDiagram-v2
direction TB
state "routes/web.php" as routes
state "ConfigurationController::saveConfig" as save
state "ConfigurationController::handleCve" as handleCve
state "ConfigurationController::testProvider" as testProvider
state "curl_exec, outbound request" as curl
note right of routes
POST /admin/config/parameters
web.protected middleware (auth + gates)
gates defines but does not enforce
end note
note right of save
abort_if Gate denies configure
Admin, User, Cartographer pass
match tab cve calls handleCve
end note
note right of handleCve
action test_provider
provider value taken raw
no URL scheme check
no host validation
end note
note right of testProvider
curl_init provider concat /api/dbInfo
suffix bypassable with hash sign
no scheme allowlist
no private IP block
end note
note right of curl
curl_exec hits attacker target
response body returned to flash
information disclosure channel
end note
[*] --> routes : POST /admin/config/parameters
routes --> save : saveConfig
save --> handleCve : match tab cve
handleCve --> testProvider : action test_provider
testProvider --> curl : curl_init then curl_exec
curl --> [*] : flash success or error
Initial vulnerable code:
// app/Http/Controllers/Admin/ConfigurationController.php
if ($action === 'test_provider') {
return $this->testProvider($request->input('provider'));
}
private function testProvider(string $provider): array
{
$client = curl_init($provider . '/api/dbInfo');
curl_setopt($client, CURLOPT_RETURNTRANSFER, true);
curl_setopt($client, CURLOPT_TIMEOUT, 10);
$response = curl_exec($client);
curl_close($client);
// ...
}
First Remediation
Commit 2e6f761a “fix Server-Side Request Forgery” on the dev branch introduces two controls inside ConfigurationController.
The first control is validateProviderUrl() which filters the URL string:
private function validateProviderUrl(string $url): string
{
if (preg_match('/[?#@]/', $url)) {
throw new \InvalidArgumentException('Invalid provider URL.');
}
$parsed = parse_url($url);
if (!$parsed || !in_array($parsed['scheme'] ?? '', ['http', 'https'], true)) {
throw new \InvalidArgumentException('Invalid provider URL scheme.');
}
return $url;
}
The second control is rejectPrivateHost() which resolves the hostname and rejects private addresses:
private function rejectPrivateHost(string $url): void
{
$host = parse_url($url, PHP_URL_HOST);
$ip = gethostbyname($host);
if (filter_var($ip, FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
throw new \InvalidArgumentException('Provider host resolves to a private or reserved address.');
}
}
The curl call is also hardened with a protocol restriction:
curl_setopt($client, CURLOPT_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
curl_setopt($client, CURLOPT_REDIR_PROTOCOLS, CURLPROTO_HTTP | CURLPROTO_HTTPS);
These two measures effectively close the multi-protocol scan and the RCE chain through gopher://.
Partial bypass identified
The rejectPrivateHost() control validates a DNS resolution that differs from the one used by curl.
This divergence leaves two attack paths open.
Flaw number 1, DNS rebinding. The address validated by gethostbyname() is not the address curl later connects to.
There are two separate resolutions in the code. This forms a time-of-check to time-of-use, race condition (CWE-367).
The attacker supplies a hostname under their control.
That domain is served by a DNS that returns a public address on the first resolution and an internal address on the second.
The control sees the public address and lets the request through.
curl then sees the internal address and connects to it.
Flaw number 2, A versus AAAA split. The gethostbyname() function only reads the IPv4 A record.
The attacker publishes a hostname with an A record pointing to a public address.
The same name exposes an AAAA record pointing to ::1 or to an internal IPv6 address.
The control only inspects the A record and lets the request through.
curl uses getaddrinfo which reads the AAAA record and connects to the internal IPv6 address.
This flaw does not depend on a timing race. It is deterministic.
After bypass, the residual primitive is an SSRF restricted to HTTP and HTTPS schemes.
The RCE through gopher:// is closed by the protocol restriction.
HTTP reconnaissance toward the internal network remains possible.
Adjust Remedies
To structurally close both DNS divergence flaws, here are the minimal fixes:
- Resolve the host only once then pin the validated IP into curl with
curl_setopt($client, CURLOPT_RESOLVE, ["{$host}:{$port}:{$validatedIp}"]). curl no longer performs a second resolution. There is only one resolution left and it is the one that was validated. - Replace
gethostbyname()withdns_get_record($host, DNS_A | DNS_AAAA). Reject the request if any record is a private or reserved address. This closes the A versus AAAA split bypass. - Use an explicit CIDR blocklist instead of the
filter_varflags. Include ranges that the flags do not cover:100.64.0.0/10for CGNAT,::1for IPv6 loopback,fc00::/7for ULA andfe80::/10for IPv6 link-local. Also include IPv4-mapped IPv6 (::ffff:0:0/96) and normalize before the test. - Explicitly set
CURLOPT_FOLLOWLOCATIONtofalse. This prevents a future change from reintroducing a redirect-based bypass. - As defense in depth, restrict the provider URL to an allowlist of legitimate provider domains. The feature never needs to reach anything other than the configured provider.
The first two recommendations form the minimal mandatory fix. They close the race condition (CWE-367) along with the A versus AAAA split bypass. Thank you to him for the various exchanges we had by email. This application addresses many IT issues from the perspective of managing and understanding a modern and complex infrastructure.
Thank you for reading this article :D