CVE-2026-41688: DNS Rebinding TOCTOU in Wallos Webhook Validation
Wallos ≤4.8.4 validates webhook URLs with gethostbyname() but passes the original hostname to cURL without CURLOPT_RESOLVE pinning, creating an exploitable DNS rebinding window across 10 of 11 HTTP endpoints.
Wallos is a free app that helps people track their subscriptions—like Netflix, Spotify, and gym memberships—all in one place. If you host it yourself on your computer rather than using someone else's server, you get extra privacy. But researchers just found a serious security hole that could let hackers break in.
Here's what's happening: Wallos has a feature where it can send alerts to other services through something called a webhook—think of it like leaving a note for another app to pick up. Before sending that note, Wallos checks to make sure the address is legitimate, like a bouncer verifying a guest list. But the check happens in two stages, and there's a dangerous gap between them.
An attacker can exploit this gap through something called DNS rebinding. Imagine if someone changed an address on the guest list between the time the bouncer read it and the time they actually let someone in. The bouncer verified one address, but a different person shows up. That's essentially what's happening here with internet addresses.
This matters because hackers could use this vulnerability to reach parts of your computer or network that should be completely hidden from the internet. They could potentially take control of your Wallos server or use it to attack other systems on your network.
If you're running Wallos, you should update immediately to the latest version when it becomes available. Check your Wallos installation for version number 4.8.5 or higher. Don't wait—this is the kind of vulnerability that could enable serious attacks. If you're not sure how to update, contact whoever helped you set up Wallos initially or check their official documentation. Finally, only allow trusted people to configure webhooks in your Wallos settings.
Want the full technical analysis? Click "Technical" above.
CVE-2026-41688 is a DNS rebinding TOCTOU (time-of-check/time-of-use) vulnerability in Wallos, an open-source self-hostable subscription tracker. The incomplete SSRF mitigation introduced in response to prior reports validates the destination IP at URL-parse time via gethostbyname(), then hands the original hostname string to libcurl for actual connection establishment. Because DNS is re-resolved at connection time, an attacker-controlled nameserver can serve a benign public IP during validation and a private/loopback IP during the cURL call — the classic DNS rebinding split-horizon attack. The window affects 10 of 11 outbound HTTP endpoints.
CVSS 7.7 (HIGH) — Network / Low Complexity / No Privileges / No User Interaction / Changed Scope / High Confidentiality.
Root cause: Wallos resolves the webhook hostname to validate it against a blocklist inside validateWebhookUrl(), but passes the raw hostname string to cURL without pinning the previously-resolved IP via CURLOPT_RESOLVE, allowing a second DNS resolution at connection time to return an arbitrary address.
Affected Component
All outbound HTTP helpers in Wallos ≤ 4.8.4 that share the same validation/dispatch pattern. The lone unaffected endpoint validates inline and never proceeds to a cURL call on failure. Affected call sites include webhook dispatch for subscription reminders, payment notifications, test-ping routes, and currency-update callbacks — essentially every feature that fires an external HTTP request.
Language: PHP
Transport layer: libcurl via PHP's curl_setopt() wrappers
Versions affected: ≤ 4.8.4
Patch status: No public patch at time of publication
Root Cause Analysis
The prior SSRF fix added a validation helper. Stripped to its logic:
// endpoint/includes/validateWebhookUrl.php (pseudocode reconstruction)
// Called before every outbound cURL dispatch.
bool validateWebhookUrl(const char *url) {
char hostname[512];
extract_host(url, hostname, sizeof(hostname)); // parse hostname from URL
// CHECK TIME: resolve once to validate
struct hostent *he = gethostbyname(hostname); // DNS lookup #1
if (he == NULL) return false;
uint32_t ip = *(uint32_t *)he->h_addr_list[0];
// Blocklist: RFC-1918, loopback, link-local
if (is_private_ip(ip) || is_loopback(ip) || is_linklocal(ip))
return false; // SSRF guard — looks correct here
return true;
// BUG: resolved IP is discarded after this return.
// cURL will perform DNS lookup #2 at USE TIME with no pinning.
}
void dispatchWebhook(const char *url, const char *payload) {
if (!validateWebhookUrl(url)) {
log_error("Blocked SSRF attempt");
return;
}
CURL *ch = curl_easy_init();
curl_easy_setopt(ch, CURLOPT_URL, url); // raw hostname re-enters cURL
// BUG: CURLOPT_RESOLVE never set — cURL performs independent DNS lookup #2
// BUG: CURLOPT_INTERFACE not restricted — any local address reachable
curl_easy_setopt(ch, CURLOPT_TIMEOUT, 10L);
curl_easy_perform(ch); // USE TIME: attacker's DNS now answers 127.0.0.1
curl_easy_cleanup(ch);
}
The is_private_ip() result from lookup #1 is never propagated to the cURL session. libcurl performs its own getaddrinfo() call internally, entirely independent of what PHP's gethostbyname() returned. The validation and the connection are decoupled by design in PHP's cURL bindings.
Exploitation Mechanics
Exploitation requires control of a DNS server for an attacker-owned domain and the ability to submit a webhook URL to a Wallos instance — a capability available to any authenticated user (and on many self-hosted installs, registration is open).
EXPLOIT CHAIN:
1. Register attacker domain: evil.attacker.com → authoritative NS under attacker control.
2. Configure split-horizon DNS:
- First query for evil.attacker.com → 198.51.100.1 (public, passes blocklist)
TTL = 1 second (minimise rebinding window)
- Subsequent queries → 127.0.0.1 (loopback, target of SSRF)
3. Submit webhook URL https://evil.attacker.com/cb to Wallos via any of the
10 vulnerable settings endpoints (e.g. POST /api/webhooks/save).
4. Wallos calls validateWebhookUrl("https://evil.attacker.com/cb"):
gethostbyname("evil.attacker.com") → 198.51.100.1 [DNS lookup #1]
is_private_ip(198.51.100.1) == false → PASSES validation.
Resolved IP discarded.
5. Wallos immediately calls dispatchWebhook():
curl_easy_setopt(ch, CURLOPT_URL, "https://evil.attacker.com/cb")
curl_easy_perform(ch):
getaddrinfo("evil.attacker.com") → 127.0.0.1 [DNS lookup #2]
cURL connects to 127.0.0.1:443 — loopback SSRF achieved.
6. cURL issues HTTPS request to 127.0.0.1 (or any internal service on any port
reachable from the Wallos container).
7. Attacker-controlled path /cb can be crafted to probe internal APIs:
- Docker socket at unix:///var/run/docker.sock (via redirect)
- Local metadata service at 169.254.169.254 (cloud envs)
- Internal Wallos DB admin panel
- Redis/Memcached without auth
8. Response body reflected in Wallos error logs or via webhook response
storage depending on configuration → exfiltration of internal service data.
9. For RCE: target internal service with known deserialization or command
injection endpoint (e.g. local phpMyAdmin, Adminer, or exposed Docker API).
CVSS scope changes from "Changed" to full compromise at this step.
The rebinding window between step 4 and step 5 is bounded by the PHP execution path between validateWebhookUrl() returning and curl_easy_perform() being called — typically under 5ms of CPU time, easily within a 1-second TTL. The attacker's nameserver can additionally implement response-count logic: answer the first query legitimately and all subsequent queries with the internal IP, eliminating timing dependence entirely.
Memory Layout
While this is not a memory corruption bug, the DNS resolver state mismatch is worth mapping explicitly as a "logical race" between two independent OS-level resolution contexts:
PHP PROCESS TIMELINE:
t=0ms validateWebhookUrl() called
│
t=1ms gethostbyname("evil.attacker.com")
│ → OS resolver cache miss → query to attacker NS
│ ← response: A 198.51.100.1, TTL=1
│ → resolved_ip = 0xC6336401 (198.51.100.1)
│ → is_private_ip() = false → return true
│ → resolved_ip falls out of scope, FREED
│
t=2ms dispatchWebhook() called
│
t=3ms curl_easy_perform() → libcurl getaddrinfo("evil.attacker.com")
│ → OS resolver cache: TTL=1 EXPIRED (or never cached in curl's ctx)
│ → query to attacker NS (second query, response count = 2)
│ ← response: A 127.0.0.1, TTL=1
│ → libcurl connects to 127.0.0.1:443
│
t=4ms TCP SYN → loopback → internal service
VALIDATION CONTEXT: USE CONTEXT:
gethostbyname() getaddrinfo()
result: 198.51.100.1 ≠ result: 127.0.0.1
[DISCARDED] [USED FOR CONNECTION]
↑
TOCTOU gap — no shared resolver state between PHP and libcurl
Patch Analysis
The correct fix is to bind the resolved IP from validation time into the cURL session, eliminating the second DNS lookup. CURLOPT_RESOLVE injects a synthetic DNS cache entry that cURL uses instead of querying the OS resolver. Alternatively, CURLOPT_CONNECT_TO can redirect the connection at the socket layer.
// BEFORE (vulnerable — Wallos ≤ 4.8.4):
bool validateWebhookUrl(const char *url) {
char hostname[512];
extract_host(url, hostname, sizeof(hostname));
struct hostent *he = gethostbyname(hostname);
if (!he) return false;
uint32_t ip = *(uint32_t *)he->h_addr_list[0];
if (is_private_ip(ip)) return false;
return true;
// resolved IP never returned to caller
}
void dispatchWebhook(const char *url, const char *payload) {
if (!validateWebhookUrl(url)) return;
CURL *ch = curl_easy_init();
curl_easy_setopt(ch, CURLOPT_URL, url);
// MISSING: CURLOPT_RESOLVE pinning
curl_easy_perform(ch);
}
// AFTER (correct fix — recommended patch):
char *validateAndResolveWebhookUrl(const char *url) {
char hostname[512];
int port;
extract_host_port(url, hostname, sizeof(hostname), &port);
struct hostent *he = gethostbyname(hostname); // lookup #1 — only lookup
if (!he) return NULL;
uint32_t ip = *(uint32_t *)he->h_addr_list[0];
if (is_private_ip(ip) || is_loopback(ip) || is_linklocal(ip))
return NULL;
// Return resolved IP string to caller for pinning
char *resolved_ip = inet_ntoa(*(struct in_addr *)he->h_addr_list[0]);
return strdup(resolved_ip); // caller owns this
}
void dispatchWebhook(const char *url, const char *payload) {
char *resolved_ip = validateAndResolveWebhookUrl(url);
if (!resolved_ip) return;
char hostname[512];
int port;
extract_host_port(url, hostname, sizeof(hostname), &port);
// Pin the validated IP — cURL will NOT re-resolve
char resolve_entry[640];
snprintf(resolve_entry, sizeof(resolve_entry),
"%s:%d:%s", hostname, port, resolved_ip);
struct curl_slist *resolve_list = NULL;
resolve_list = curl_slist_append(resolve_list, resolve_entry);
CURL *ch = curl_easy_init();
curl_easy_setopt(ch, CURLOPT_URL, url);
curl_easy_setopt(ch, CURLOPT_RESOLVE, resolve_list); // FIXED: pins lookup #1 result
curl_easy_perform(ch);
curl_slist_free_all(resolve_list);
curl_easy_cleanup(ch);
free(resolved_ip);
}
A defence-in-depth addition: set CURLOPT_PROTOCOLS to CURLPROTO_HTTPS only, and CURLOPT_REDIR_PROTOCOLS to 0 to prevent open-redirect chains targeting file://, gopher://, or dict:// handlers.
Detection and Indicators
Because the exploit looks like a legitimate outbound webhook request from the server's perspective, network-layer detection requires DNS telemetry correlation:
DNS anomaly: Two A-record queries for the same hostname within <100ms returning different IPs — flag in DNS resolver logs.
Loopback egress: Outbound TCP connections from the Wallos PHP process to 127.0.0.0/8 or ::1 — impossible under normal webhook operation; detectable via eBPF tcp_connect kprobe or auditd SOCKADDR events.
RFC-1918 egress: cURL connections to 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 from the webhook dispatcher process.
Low-TTL domains in webhook config: Any stored webhook URL resolving with TTL ≤ 5 seconds should trigger a review alert.
Wallos error log pattern: Unexpected HTTP response codes (200 from a loopback service) logged against a webhook URL that previously 4xx'd on the public IP.
Pin DNS at dispatch time using CURLOPT_RESOLVE as shown above — this is the only complete fix. Validation-only approaches all share the TOCTOU deficiency.
Restrict outbound egress at the network layer: Deploy an egress firewall rule on the Wallos host/container dropping all outbound connections to 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, and 169.254.0.0/16. This is defence-in-depth, not a full fix.
Force DNS resolution through a validating resolver configured to return NXDOMAIN for RFC-1918 reverse-mapped queries — partially mitigating rebinding but bypassable via CNAMEs to public IPs that also resolve internally.
Disable webhook functionality entirely if not in active use by setting WALLOS_WEBHOOKS_ENABLED=false in the instance config.
Audit stored webhook URLs for domains with anomalously low TTLs or unknown registrars; rotate any secrets exposed to internal services reachable from the Wallos host.