Research/prestashop-reset-token-predict
0-dayNo CVECriticalPublic

PrestaShop predictable password reset token, unauthenticated ATO

PrestaShop 9.1.5 FO password recovery stamps sha1(time() . id_customer . '-' . secure_key). Order confirmation key= is that secure_key. Forgot POST writes the token even if mail fails. No mailbox. No CVE yet.

Name
PrestaShop predictable password reset token, unauthenticated ATO
Type
0-day analysis
CVE
n/a
CVE Risk
critical
Disclosure Status
public
Vendor
PrestaShop
Affected
PrestaShop FO password recovery through 9.1.5; unpublished. Guest-only checkout is not this bug.
Published
26 Sept 2026
Updated
26 Sept 2026
Tags
0-day, prestashop, password-reset, cwe-330, cwe-640, unauthenticated, ato

sha1(time()) is the token

I am @abraxas_null. The proof of concept is on GitHub: abraxas/prestashop-reset-token-predict (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, seed.sh. Authorized lab only. It talks to loopback.

This is PrestaShop 9.1.5, front-office password recovery. No CVE yet. CWE-330 / CWE-640. 9.1 Critical (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). Unauthenticated POST /password-recovery. Not a shell. Guest-only checkout is not this bug.

Customer::stampResetPasswordToken is the whole secret:

PHP
public function stampResetPasswordToken()
{
    $salt = $this->id . '-' . $this->secure_key;
    $this->reset_password_token = sha1(time() . $salt);
    $validity = (int) Configuration::get('PS_PASSWD_RESET_VALIDITY') ?: 1440;
    $this->reset_password_validity = date('Y-m-d H:i:s', strtotime('+' . $validity . ' minutes'));
}

That is not random_bytes. It is Unix time concatenated with a customer id and a value the shop already puts on the order-confirmation URL as key=. Validity defaults to 1440 minutes. PR #31725 tried to replace this with CSPRNG in March 2023. Draft. Never landed. 9.1.5 still hashes the clock.

The confirmation page is supposed to keep you from reading someone else's order. OrderConfirmationController::init takes key= and compares it to $this->order->secure_key. Cart::__construct copies customer.secure_key onto the cart when the cart key is empty. The order inherits it. The "anti-IDOR" secret is the same secret the reset form uses as token=.

I am not printing a brute of time() you can paste at someone else's /password-recovery. The formula and the confirmation leak are the useful part. Isolated lab. The stack is in the GitHub repo so you can run it at home.

What an attacker can do

Need id_customer (sequential) and key= from the return URL, the confirmation email, an invoice, a referrer log, or a screenshot. Unauthenticated POST /password-recovery. Predict sha1(time() . salt). Reset. Session. /my-account has the victim email and Sign out. Old password is dead.

Not a shell. Guest-only checkout is not this bug: getByEmail skips guests. Registered FO account only.

The lab (run this at home)

Source of truth is lab/. Image prestashop/prestashop:9.1.5-apache. Compose also runs mysql:8.0. The Dockerfile is the image pin. Compose uses that image directly. Port 18105. Bind it to loopback.

Dockerfile
# Loopback lab image pin for prestashop-reset-token-predict. Full stack: docker-compose.yml
FROM prestashop/prestashop:9.1.5-apache
YAML
name: prestashop-reset-token-predict

services:
  mysql:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    environment:
      MYSQL_ROOT_PASSWORD: prestashop
      MYSQL_DATABASE: prestashop
      MYSQL_USER: prestashop
      MYSQL_PASSWORD: prestashop
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uprestashop", "-pprestashop"]
      interval: 5s
      timeout: 5s
      retries: 40
      start_period: 20s

  prestashop:
    image: prestashop/prestashop:9.1.5-apache
    ports:
      - "127.0.0.1:18105:80"
    environment:
      PS_INSTALL_AUTO: "1"
      PS_ERASE_DB: "0"
      DB_SERVER: mysql
      DB_USER: prestashop
      DB_PASSWD: prestashop
      DB_NAME: prestashop
      DB_PREFIX: ps_
      PS_DOMAIN: 127.0.0.1:18105
      PS_LANGUAGE: en
      PS_COUNTRY: us
      PS_FOLDER_ADMIN: admin-dev
      PS_FOLDER_INSTALL: install-dev
      ADMIN_MAIL: lab@localhost.invalid
      ADMIN_PASSWD: LabPass123!
      PS_DEV_MODE: "1"
      PS_ENABLE_SSL: "0"
      PS_HANDLE_DYNAMIC_DOMAIN: "0"
    depends_on:
      mysql:
        condition: service_healthy

The official image plants demo customer pub@prestashop.com. seed.sh only ages last_passwd_gen. PS_PASSWD_TIME_FRONT defaults to 360 minutes. That rate limit is not the bug. A customer who has not rotated a password recently is the realistic case.

Plain text
# Age last_passwd_gen so PS_PASSWD_TIME_FRONT (360 min) is not in the way.
# That rate limit is not the bug. A customer who has not changed password
# recently is the realistic case. secure_key is the order-confirmation key=.
set -euo pipefail
MYSQL_CID="${1:-prestashop-reset-token-predict-mysql-1}"
docker exec "$MYSQL_CID" mysql -uprestashop -pprestashop prestashop -e \
  "UPDATE ps_customer SET last_passwd_gen='2026-01-01 00:00:00' WHERE email='pub@prestashop.com' AND active=1;"
Plain text
git clone https://github.com/abraxas/prestashop-reset-token-predict
cd prestashop-reset-token-predict/lab
docker compose up --force-recreate

run.sh waits for FO HTTP 200, seeds, then runs the client. The proof of concept is written for 127.0.0.1:18105. Do not publish the port off loopback.

What the tree actually stamps

PasswordController has $php_self = 'password' and $auth = false. Pretty URLs on 9.1.5 serve it at /password-recovery. Function names in the write-up are PHP methods. The HTTP path is the pretty one.

Forgot-password is POST with email=. sendRenewPasswordLink loads the customer, checks the 360-minute regen window, then:

PHP
if (!$customer->hasRecentResetPasswordToken()) {
    $customer->stampResetPasswordToken();
    $customer->update();
}

$mailParams = [
    '{url}' => $this->context->link->getPageLink(
        'password',
        null,
        null,
        'token=' . $customer->secure_key
            . '&id_customer=' . (int) $customer->id
            . '&reset_token=' . $customer->reset_password_token
    ),
];

The row is written before Mail::Send. Lab mailer is not configured. The FO still returns "An error occurred while sending the email." The token is already in ps_customer.reset_password_token. You do not need the mailbox. You do not need the mail to leave the box.

Reset is the same controller. token plus id_customer plus reset_token plus the new password. token is not the sha1. token is secure_key:

PHP
$email = Db::getInstance()->getValue(
    'SELECT `email` FROM ' . _DB_PREFIX_ . 'customer c
     WHERE c.`secure_key` = \'' . pSQL($token) . '\'
     AND c.id_customer = ' . $id_customer
);

Then getValidResetPasswordToken() is a string compare against the sha1, plus the 1440-minute window. No FO CSRF on this form. Guest rows are ignored by Customer::getByEmail ($ignoreGuest = true). Registered checkout only.

id_customer is sequential. The confirmation page also presents the customer. Anyone who saw the return URL, the confirmation email, an invoice, a referrer log, or a screenshot has key=. That is the salt.

PR #31725 wanted CSPRNG for customer and employee reset tokens. The first patch also touched secure_key generation (md5(uniqid(mt_rand)) on Customer::add). Reviewer note: carts and orders validate that key in a lot of places. ObjectModel still isMd5s it. The PR went back to draft. Three years later the reset token is still sha1(time() . $id . '-' . $secure_key).

The 200 that is an email error

The first client that looks at this will GET /password, see a theme page, and call it a day. Tens of kilobytes of HTML. Zero witness.

A few other ways to lose without learning anything:

  • Hitting index.php?controller=password on a shop that only routes /password-recovery.
  • Forgot POST inside the 360-minute PS_PASSWD_TIME_FRONT window. Seed ages that. It is not the bug.
  • Treating the mailer error as failure. The stamp already happened.
  • Guest checkout. getByEmail skips guests.
  • Assuming reset_token is random_bytes. It is not.
  • A reverse shell. Theatre. The witness is FO /my-account with the victim email and Sign out.
  • Login with the old password after a successful reset. That password is dead.

The tell on the forgot POST is still a 200 with the email-send error in the body, then a DB row whose sha1 matches time() on the PHP container. Lab brute delta=0.

What I actually did

Treat the on-disk product as the spec. I read stampResetPasswordToken, then the confirmation key= check, then sendRenewPasswordLink writing the row before mail. Proof of concept: prestashop-reset-token-predict-Abraxas-Labs.py.

Leak, stamp, match, reset, login. Demo customer id_customer=2 pub@prestashop.com. Confirmation key= is that row's secure_key. Unauthenticated forgot POST. Predicted token matched the DB. Reset. Session. /my-account has the email and Sign out. I am not reprinting lab secure_key or reset_token values.

Last lab run, trimmed:

Plain text
IOC leaked id_customer=2 email=pub@prestashop.com
IOC password-get status=200
IOC forgot-post status=200 snippet='error occurred while sending the email'
IOC brute t=1790454950 delta=0
IOC predicted reset_token matched db
IOC reset-post status=200
IOC login status=200
IOC my-account status=200
IOC logged=True email-in-page=True
SUCCESS PrestaShop predictable password reset token

The client that produced it is on GitHub.

Wrong turns already recorded: hitting index.php?controller=password on a shop that only routes /password-recovery; forgot POST inside the 360-minute PS_PASSWD_TIME_FRONT window (seed ages that - it is not the bug); treating the mailer error as failure (the stamp already happened); guest checkout; assuming reset_token is random_bytes; a reverse shell. Theatre. Login with the old password after a successful reset (that password is dead). Lab brute delta=0.

What this is not

It is not "PrestaShop emails a reset link." The link is a hash of the clock plus a value the confirmation URL already carries. It is not guest-only checkout. It is not RCE. It is customer account takeover on a registered FO account, given key= and id_customer, without the mailbox.

The fix

Use random_bytes (or equivalent) for reset_password_token, stop putting customer.secure_key on confirmation URLs, and do not stamp the row if mail never sent. Re-run the loopback client against a patched build: the predicted sha1 must not match, and /my-account must not come back logged in as the victim.

I am not going to print a form-urlencoded body you can paste at someone else's /password-recovery. The sha1(time()) plus key= collision is the useful part. If you own the box, run the proof of concept against loopback.

References