home intel cve-2026-41688-wallos-dns-rebinding-ssrf-rce
CVE Analysis 2026-05-07 · 8 min read

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.

#ssrf#dns-rebinding#toctou#webhook#remote-code-execution
Technical mode — for security professionals
▶ Attack flow — CVE-2026-41688 · Remote Code Execution
ATTACKERRemote / unauthREMOTE CODE EXECCVE-2026-41688Cross-platform · HIGHCODE EXECArbitrary coderuns as targetCOMPROMISEFull accessNo confirmed exploits

Vulnerability Overview

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.

# eBPF one-liner (bpftrace) to catch Wallos connecting to RFC-1918 from PHP:
bpftrace -e '
  kprobe:tcp_connect /comm == "php"/ {
    $sk = (struct sock *)arg0;
    $daddr = $sk->__sk_common.skc_daddr;
    // flag if daddr in 127/8, 10/8, 172.16/12, 192.168/16
    if (($daddr & 0xFF000000) == 0x7F000000 ||
        ($daddr & 0xFF000000) == 0x0A000000 ||
        ($daddr & 0xFFF00000) == 0xAC100000 ||
        ($daddr & 0xFFFF0000) == 0xC0A80000) {
      printf("SSRF candidate: php → %s\n", ntop($daddr));
    }
  }
'

Remediation

Until an official patch is released:

  1. 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.
  2. 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.
  3. 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.
  4. Disable webhook functionality entirely if not in active use by setting WALLOS_WEBHOOKS_ENABLED=false in the instance config.
  5. 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.
CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →