
Hace unas semanas reporté a un programa privado de bug bounty una vulnerabilidad que me pareció bastante curiosa, y que creo que merece un post por dos motivos, por un lado el bug en sí es un ejemplo muy claro de por qué cualquier dato que viaja en un QR debería estar firmado o cifrado, y por otro, por la respuesta que recibí del programa en mi opinión fue insuficiente y que hablaremos de ella más adelante.
Scope
El target era una tienda online que permite recogida de pedidos en tienda física (lo que normalmente se conoce como Click & Collect). El flujo es el típico, compras online, eliges «recoger en tienda», pagas (o indicas que pagarás en el momento de la recogida) y la aplicación te envía por email un código QR que presentas en el mostrador para que te entreguen el pedido.
Hasta aquí nada raro. El problema estaba en qué contenía ese QR.
Código QR
Al decodificar el QR que me llegaba al correo, me encontré con una cadena como esta:
ORD#id=a7f2bc10d84e4f329b1c5e8a0d6f7c42|number=OR-78451209|orderTime=2026-08-14T16:43:43+02:00|paymentId:3
Para poner en contexto que es cada campo:
ORD#id=<uuid_pedido>|number=<numero_pedido>|orderTime=<timestamp>|paymentId:<id_metodo_pago>
Sin firma. Sin HMAC. Sin cifrado. Texto plano, estructura totalmente predecible.
El campo que me llamó la atención fue paymentId (Identificador del método de pago). Haciendo dos pedidos de prueba descubrí que:
- paymentId:3 -> Pago en efectivo al recoger (pedido no pagado en el momento de emitir el QR)
- paymentId:21 -> PayPal (pedido ya pagado en el momento de emitir el QR)
Es decir, el propio QR que lleva el cliente a la tienda estaba declarando si el pedido ya estaba pagado o no.
Explotación
El PoC es tan sencillo que cualquiera podría hacerlo, únicamente hay que fijarse:
- Hago un pedido real pagando con PayPal, me llega el QR. Lo decodifico y confirmo que lleva paymentId:21.
- Hago un segundo pedido, esta vez eligiendo pago en efectivo al recoger. Me llega otro QR con paymentId:3.
- Decodifico el segundo QR, cambio paymentId:3 por paymentId:21 dejando todo lo demás igual:
ORD#id=a7f2bc10d84e4f329b1c5e8a0d6f7c42|number=OR-78451209|orderTime=2026-08-14T16:43:43+02:00|paymentId:21
Regenero el QR con cualquier herramienta online. Visualmente es indistinguible del original.
Si el escáner de la tienda confía en el campo paymentId para marcar el pedido como pagado, me llevo el producto sin haber pagado un céntimo. Y la pérdida solo la detectarían posteriormente, no en el momento.
Por qué un QR nunca debería ser texto plano
Esto es lo que más me llama la atención del caso, y por lo que creo que vale la pena publicarlo. Un QR no es un canal seguro. Es un medio de transporte visual, perfectamente legible y modificable por cualquiera con un móvil y treinta segundos de tiempo. Tratarlo como si fuera un token seguro puede ser muy peligroso.
Las opciones correctas son básicamente dos:
- Hacer que el QR sea un identificador opaco (un UUID aleatorio que el servidor resuelve contra la base de datos en el momento de la lectura). El QR no lleva estado, solo una referencia. El estado se consulta en el servidor.
- Firmar el payload con un HMAC usando un secreto del lado servidor. Así, si alguien altera cualquier bit del contenido, la firma deja de ser válida y el escáner lo detecta.
En este caso lo lógico es la opción 1: el QR debería llevar solo el ID del pedido y el escáner consultar al backend qué estado tiene ese pedido (pagado, pendiente, cancelado, ya recogido…)
La respuesta del programa (y mi opinión)
Después de dos meses en revisión, el programa cerró el reporte como Informativo, con esta justificación:
Hola,
gracias por tu feedback.
El payment ID no se utiliza para ninguna operación de backend. Se proporciona únicamente como dato informativo para el cliente, y no se realizan cambios en el backend basados en este valor.
El pago siempre se verifica. Nuestro principio de seguridad sigue siendo el mismo: 'Never trust the user'. Cualquier entrada controlada por el usuario se considera por tanto no confiable y debe ser debidamente validada y verificada antes de ser procesada.
Un saludo.
Personalmente, no me lo creo del todo, y aquí es donde quiero reflexionar un poco.
El problema es que como hunter no tengo ninguna forma de verificar lo que me están diciendo. No tengo acceso al código del escáner de la tienda, no tengo acceso al backend, tampoco puedo ir físicamente al establecimiento a probar el QR modificado (no voy a ir hasta Alemania a comprobarlo, no tiene tiendas en España). Me tengo que fiar de su palabra.
Conclusión
Si auditáis un flujo con QRs, decodificadlos siempre. Un QR es texto, igual que cualquier parámetro HTTP. Si lleva información de estado sin firmar, es un bug.
Y por supuesto, si alguien del lado defensivo lee esto, nunca metáis estado en un QR sin firmarlo. Es exactamente el mismo error que meter el precio del producto en un campo oculto del formulario de checkout, solo que con píxeles.
