Resumen
WordCamp.org vende las entradas de sus eventos con CampTix, y el complemento campt-indian-payment-gateway añade varias pasarelas de pago. La de Instamojo tenía un fallo de base: nunca guardaba qué pago de Instamojo correspondía a qué pedido de CampTix. No almacenaba el payment_request_id ni el importe esperado, así que no tenía con qué comparar cuando Instamojo (o alguien haciéndose pasar por Instamojo) avisaba de un pago.
El código afectado está en:
public_html/wp-content/plugins/campt-indian-payment-gateway/inc/instamojo/class-camptix-payment-method-instamojo.php
en las funciones payment_checkout(), payment_return() y payment_notify(). De esa única causa salían tres caminos para cambiar el estado de un asistente sin un pago que lo respaldase:
- Vuelta del usuario (
payment_return): si el asistente estaba en borrador, bastaba eltix_payment_tokendel pedido para pasarlo aPAYMENT_STATUS_PENDING, sin consultar a la API de Instamojo si había pago. - Webhook con firma inválida (
payment_notify): el código fallaba "en abierto". Si la MAC no coincidía, en lugar de rechazar la notificación, marcaba igualmente el pedido como pendiente. - Webhook con firma válida y estado Credit: el pedido a completar se elegía con
tix_payment_tokenleído de la query string de la URL del webhook, que no entra en la MAC (la firma solo cubre los campos del cuerpo POST). Como tampoco se comprobaba elpayment_request_idni el importe, una notificación auténtica de un pago barato del mismo comercio podía usarse para completar un pedido distinto y más caro, que pasaba apublish.
El investigador lo comparó con la pasarela hermana de Razorpay, en el mismo complemento, que sí guarda _razorpay_order_id y _razorpay_amount, los compara con hash_equals y corta con wp_die sin tocar el estado si algo no cuadra. Según el reporte, SSLCommerz, SurjoPay y Stripe siguen ese mismo patrón; Instamojo se había quedado atrás.
HackerOne lo clasificó como Improper Access Control - Generic, severidad alta. El programa (WordPress) lo dio por resuelto y pagó recompensa, de importe no público. Se envió el 15 de agosto de 2026 y se divulgó el 5 de noviembre de 2026.
El código vulnerable
En payment_return(), un asistente en borrador pasaba a pendiente sin verificar nada:
// payment_return()
if ( 'draft' == $attendee->post_status ) {
return $this->payment_result( $payment_token, CampTix_Plugin::PAYMENT_STATUS_PENDING, $payment_data );
}
En payment_notify(), las dos ramas de la comprobación de la MAC acababan cambiando el estado, y ambas usaban el token de la query string:
// payment_notify()
$mac_calculated = hash_hmac( "sha1", implode( "|", $data ), $instamojo_salt );
if ( $mac_provided == $mac_calculated ) {
return $this->payment_result(
$_REQUEST['tix_payment_token'],
CampTix_Plugin::PAYMENT_STATUS_COMPLETED,
/* ... */
);
} else {
// FAIL-OPEN: invalid MAC still marks PENDING
return $this->payment_result( $_REQUEST['tix_payment_token'], CampTix_Plugin::PAYMENT_STATUS_PENDING, $payment_data );
}
Y en el checkout el token se metía en la URL del webhook, sin guardar el identificador de la solicitud de pago:
$notify_url = add_query_arg( array(
'tix_action' => 'payment_notify',
'tix_payment_token' => $payment_token,
'tix_payment_method' => 'instamojo',
), $this->get_tickets_url() );
// payload includes 'webhook' => $notify_url
// no update_post_meta( ..., 'payment_request_id', ... ) (unlike Razorpay)
Frente a esto, la versión de Razorpay que el reporte pone como referencia:
$expected_order_id = (string) get_post_meta( $attendee->ID, '_razorpay_order_id', true );
if ( $expected_order_id && ! hash_equals( $expected_order_id, $razorpay_order_id ) ) {
wp_die( /* refuse — do not change status */ );
}
if ( null === $status ) { // unpaid / unverified
wp_die( /* refuse — do not mark pending */ );
}
Pasos de reproducción
El investigador lo demostró revisando el código fuente público del repositorio de WordPress/wordcamp.org (por ejemplo, la rama de producción), sin explotarlo contra eventos reales:
- Abrir
public_html/wp-content/plugins/campt-indian-payment-gateway/inc/instamojo/class-camptix-payment-method-instamojo.php. - En
payment_return(), localizar la rama que se ejecuta cuando elpost_statusdel asistente esdrafty comprobar que llama apayment_result( $payment_token, PAYMENT_STATUS_PENDING, … )sin ninguna llamada a la API de Instamojo que confirme el pago de ese token. - En
payment_notify(), localizar la comprobación conhash_hmac/$mac_providedy fijarse en la ramaelse: si la MAC no coincide, se llama igualmente apayment_result( $_REQUEST['tix_payment_token'], PAYMENT_STATUS_PENDING, … ). - En la rama con MAC válida, ver que el estado
COMPLETED(pago Credit) se aplica al pedido indicado por$_REQUEST['tix_payment_token'], y confirmar tres cosas:
- la MAC se calcula solo sobre los campos del cuerpo POST, después de quitar
mac; - el checkout fija
'webhook' => $notify_url, contix_payment_tokendentro de la query string; - el checkout no guarda con
update_post_metaningúnpayment_request_idni importe en el asistente.
- Como control, abrir
…/razorpay/class-camptix-payment-method-razorpay.phpy comprobar que guarda_razorpay_order_idy_razorpay_amountal pagar, usahash_equalsa la vuelta y hacewp_diesin tocar el estado si el pedido no coincide o no está pagado. - Para medir el impacto, ir a
payment_result()encamptix/camptix.phpy comprobar quePAYMENT_STATUS_PENDINGponepost_status = pending, quePAYMENT_STATUS_COMPLETEDponepost_status = publish, y queget_purchased_tickets_count(), el envío de entradas por correo y los flujos de acceso de asistentes tratanpendingypublishcomo estados de entrada comprada.
Impacto
- Entradas completas pagando menos: el camino más serio. Un webhook Credit auténtico de un pago barato en el mismo comercio podía completar otro pedido más caro, que quedaba en
publishcomo pagado. - Pedidos pendientes sin pago: con el token de un pedido en borrador, tanto la vuelta del usuario como un webhook con firma falsa lo pasaban a
pending. - Consumo del aforo:
get_purchased_tickets_count()cuenta los asistentespendingypublish, así que estos pedidos fantasma ocupaban plazas. - Envío de entradas: el paso desde borrador dispara
email_tickets(), que manda la entrada por correo. - Acceso al evento: los flujos de acceso de asistentes que aceptan el estado
pendingtambién se veían afectados.
Remediación
El reporte figura como resuelto; el original no detalla el parche aplicado. Lo que proponía el investigador era copiar el patrón de Razorpay:
- En el checkout de Instamojo, guardar en el asistente el
payment_request_idy el importe esperado, igual que_razorpay_order_idy_razorpay_amount. - En
payment_return, llamar apayment_resultsolo después de que la API de Instamojo confirme la solicitud y esta coincida con lo guardado; si no, rechazar sin cambiar el estado. - En
payment_notify, tratar una MAC inválida como rechazo (registrarla o marcarFAILED), nunca comoPENDING, y comparar la MAC conhash_equals. - En los pagos Credit, localizar el pedido a partir del
payment_request_idvinculado (o exigir que el token de la query coincida con él) y comprobar que el importe es el total del pedido antes de marcarloCOMPLETED. - Añadir pruebas de regresión (una MAC inválida no marca pendiente; un Credit no completa un pedido que no es dueño de ese
payment_request_ido importe) y revisar las demás pasarelas de CampTix por si tienen el mismo hueco de "token en la query sin firmar".
Qué aprender de este caso
- Cuando una integración de pagos tiene varias pasarelas, compararlas entre sí es una forma rápida de encontrar fallos: si una guarda el identificador del pedido externo y el importe y otra no, la que no lo hace es la candidata.
- En un webhook, mira qué parte de la petición cubre la firma. Aquí la HMAC firmaba el cuerpo, pero el pedido a completar salía de la query string; cualquier dato que decida a quién se aplica el pago tiene que ir firmado o, mejor, salir de lo que el servidor guardó en el checkout.
- La rama
elsede una comprobación de firma tiene que rechazar y no cambiar nada. Un "si falla, lo dejo en pendiente" convierte una autenticación fallida en un cambio de estado que el resto de la aplicación puede tratar como compra. - Antes de marcar un pedido como pagado, compara el importe cobrado con el total de ese pedido. Sin esa comprobación, cualquier pago legítimo del mismo comercio sirve como comodín.