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:
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.
# Loopback lab image pin for prestashop-reset-token-predict. Full stack: docker-compose.yml
FROM prestashop/prestashop:9.1.5-apachename: 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_healthyThe 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.
# 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;"git clone https://github.com/abraxas/prestashop-reset-token-predict
cd prestashop-reset-token-predict/lab
docker compose up --force-recreaterun.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:
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:
$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=passwordon a shop that only routes/password-recovery. - Forgot POST inside the 360-minute
PS_PASSWD_TIME_FRONTwindow. Seed ages that. It is not the bug. - Treating the mailer error as failure. The stamp already happened.
- Guest checkout.
getByEmailskips guests. - Assuming
reset_tokenisrandom_bytes. It is not. - A reverse shell. Theatre. The witness is FO
/my-accountwith 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:
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 tokenThe 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
- Proof of concept: abraxas/prestashop-reset-token-predict · prestashop-reset-token-predict-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · seed.sh - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-330 · CWE-640
- 9.1.5:
Customer.php·PasswordController.php·OrderConfirmationController.php·Cart.php - PR #31725 (CSPRNG, never landed) · tag 9.1.5
- prestashop-project.org/security
- Product: PrestaShop · password recovery