Reports
ALTA[LÓGICA DE NEGOCIO]#3941626

WordCamp.org: la pasarela Instamojo de CampTix daba entradas por pagadas sin comprobar de qué pedido era el pago

El complemento Instamojo de CampTix no ligaba cada pago a su pedido: una firma de webhook inválida dejaba la entrada como pendiente y un pago barato real podía completar otro pedido más caro.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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:

  1. Vuelta del usuario (payment_return): si el asistente estaba en borrador, bastaba el tix_payment_token del pedido para pasarlo a PAYMENT_STATUS_PENDING, sin consultar a la API de Instamojo si había pago.
  2. 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.
  3. Webhook con firma válida y estado Credit: el pedido a completar se elegía con tix_payment_token leí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 el payment_request_id ni 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 a publish.

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:

  1. Abrir public_html/wp-content/plugins/campt-indian-payment-gateway/inc/instamojo/class-camptix-payment-method-instamojo.php.
  2. En payment_return(), localizar la rama que se ejecuta cuando el post_status del asistente es draft y comprobar que llama a payment_result( $payment_token, PAYMENT_STATUS_PENDING, … ) sin ninguna llamada a la API de Instamojo que confirme el pago de ese token.
  3. En payment_notify(), localizar la comprobación con hash_hmac / $mac_provided y fijarse en la rama else: si la MAC no coincide, se llama igualmente a payment_result( $_REQUEST['tix_payment_token'], PAYMENT_STATUS_PENDING, … ).
  4. 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, con tix_payment_token dentro de la query string;
  • el checkout no guarda con update_post_meta ningún payment_request_id ni importe en el asistente.
  1. Como control, abrir …/razorpay/class-camptix-payment-method-razorpay.php y comprobar que guarda _razorpay_order_id y _razorpay_amount al pagar, usa hash_equals a la vuelta y hace wp_die sin tocar el estado si el pedido no coincide o no está pagado.
  2. Para medir el impacto, ir a payment_result() en camptix/camptix.php y comprobar que PAYMENT_STATUS_PENDING pone post_status = pending, que PAYMENT_STATUS_COMPLETED pone post_status = publish, y que get_purchased_tickets_count(), el envío de entradas por correo y los flujos de acceso de asistentes tratan pending y publish como 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 publish como 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 asistentes pending y publish, 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 pending tambié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_id y el importe esperado, igual que _razorpay_order_id y _razorpay_amount.
  • En payment_return, llamar a payment_result solo 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 marcar FAILED), nunca como PENDING, y comparar la MAC con hash_equals.
  • En los pagos Credit, localizar el pedido a partir del payment_request_id vinculado (o exigir que el token de la query coincida con él) y comprobar que el importe es el total del pedido antes de marcarlo COMPLETED.
  • 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_id o 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 else de 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.