Summary
Vvveb guards outbound URL fetches with validateUrl(), which resolves a host through gethostbynamel() and rejects private or reserved addresses. gethostbynamel() returns IPv4 A records only, so any host that lacks an A record makes it return false, a check loop never runs, and a URL passes as safe. An IPv6 literal such as http://[::1]/ or http://[::ffff:169.254.169.254]/, or a domain that carries only an AAAA record, slips straight through.
Editor exposes a reflected proxy, oEmbedProxy(), that fetches an attacker-supplied URL server side and returns a response body to a caller. An authenticated admin-panel user (default role site_admin or higher) reads internal-only services and a cloud metadata endpoint, including IAM credentials, since that action is a GET request with no CSRF requirement. A fetch step also re-resolves a hostname with no IP pinning, so DNS rebinding works as a second path.
Technical Details: Root Cause
IPv4-only resolution skips a private-range check for IPv6
validateUrl() at system/functions.php:1775 allows http and https, resolves a host, then rejects private or reserved IPs:
// system/functions.php:1775-1786
function validateUrl($url) {
$p = parse_url($url);
if (! $p || ! in_array($p['scheme'] ?? '', ['http', 'https'], true) || empty($p['host'])) {
return '';
}
foreach (gethostbynamel($p['host']) ?: [] as $ip) { // IPv4 only
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return '';
}
}
return $url;
}gethostbynamel() returns IPv4 A records only. A host with no A record makes it return false, expression false ?: [] yields an empty list, a loop body never executes, and a URL returns as safe. Verified with a real shipped function:
http://127.0.0.1/ blocked
http://[::1]/ allowed
http://169.254.169.254/ blocked
http://[::ffff:169.254.169.254]/ allowedLoopback over [::1] and v4-mapped metadata over [::ffff:169.254.169.254] both pass, and any AAAA-only hostname does too.
Fetch never pins a validated address
getUrl() gates on validateUrl() then hands a hostname, not a vetted IP, to curl, which re-resolves on its own:
// system/functions.php:1793-1806 (getUrl)
$url = validateUrl($url);
if (! $url) { return; }
...
$ch = curl_init($url); // re-resolves; no CURLOPT_RESOLVE pinA function comment claims a fetch pins a validated address, yet code doesn't, so a domain that answers a public A during validation and a loopback at fetch time bypasses a check through DNS rebinding, on top of an IPv6 gap above.
Reflected proxy returns an internal body to a caller
oEmbedProxy() takes a URL from a request and echoes a fetched body:
// admin/controller/editor/editor.php:60-69
function oEmbedProxy() {
$url = $this->request->get['url'];
if (! $url) { return; }
$result = getUrl($url, false);
$this->response->setType('json');
$this->response->output($result); // internal response reflected
}That action is a GET request, so a CSRF check (which applies to POST only, admin/controller/base.php:594) doesn't fire, and default role site_admin carries allow editor/*. A live run confirmed it: a service bound only to [::1]:9932 returned its secret body through this proxy, while a 127.0.0.1 attempt stayed blocked. For cloud metadata, an attacker uses http://[::ffff:169.254.169.254]/latest/meta-data/... where a host routes a v4-mapped address, or points an attacker domain AAAA record at a metadata address.
