top of page

Hunt Bricksforge: the GIF that turns into PHP

Writer: Inception Security
Inception Security
10 minutes ago
8 min read

Picture an agency developer poking around a client's WordPress server and finding, in a folder that should only hold photos, a file named like an admin login page. The extension is PHP, it lives under wp-content/uploads, and nobody on the team put it there.


On 8 October 2026, Patchstack published active exploitation attempts against Bricksforge, a commercial add-on for Bricks Builder, tracked as CVE-2026-85097. It first saw the traffic at 21:47:17 UTC on 7 October (2:47 PM PT). The attempts are unauthenticated. No actor is named, and Patchstack has not published a count of sites that were entered.


Why this one is different


Bricksforge adds Pro Forms, animations, and backend customization to Bricks. It is sold from bricksforge.io and is not listed on WordPress.org, so scanners and auto-update setups that lean on the WordPress.org repo may never see it.


The upload check does its job. The flaw is that the plugin then trusts the form submit to say where that validated file should be saved, and Bricksforge's 4.0.1 changelog entry, which names no CVE, says the upload issue it fixes affects every site with Pro Forms on, even forms with no upload field.


How a Bricksforge form upload becomes code execution


A nonce anyone can ask for


Patchstack describes an unauthenticated AJAX action, bricksforge_regenerate_nonce, that returns a usable nonce. On its own that call does no damage, and real form traffic requests nonces too, so a hit alone tells you little. Patchstack's detection list says to look for the nonce call followed by an upload and a form submit. We would group that by source and expect it to play out within minutes.


The polyglot passes the MIME check


The upload is a file that is both a valid image and carries PHP. The MIME check sees an image, so it lands in the Bricksforge temporary folder. We are not reprinting that file or any request body. The check approved an image, and the next step told Bricksforge to save those same bytes somewhere they would run as PHP.


temporaryFileUploads says .gif, the url says .php


On form submit, the server-side path in temporaryFileUploads still points at the image, while the url ends in a PHP-family extension, and the plugin writes the file contents there. All but one observed request hit POST /wp-json/bricksforge/v1/form_submit; the other went to /wp-admin/admin-ajax.php with the bricksforge_form_submit action. In 95 percent of captured requests the server path ended .gif or .png, and most of those paired it with a PHP-related url. Most targets sat in /wp-content/uploads/bricksforge/tmp/, and some used login_admin_*.php names in regular uploads folders such as /wp-content/uploads/2026/10/.


What Patchstack saw: two clusters and a lot of extension fuzzing


Patchstack recorded 63 unique source IPs. The six most active (~46 percent of requests) were 177.75.57[.]20, 23.97.62[.]146, 84.247.60[.]125, 38.154.185[.]97, 150.109.16[.]166, and 153.75.90[.]146. On their own they are weak signals. The busiest, 177.75.57[.]20, is a Brazilian fixed-line ISP address with almost no abuse history, and 23.97.62[.]146 sits on Azure, so blocking on sight is a judgment call. Two clusters stand out: 44 IPs sent 56 identical requests within seconds, and three IPs tested 25 extension and encoding variations against the same temporary file. Across all the traffic, Patchstack saw alternative extensions, case flips, and single and double encodings such as %2ephp and .%70hp, and Patchstack reads both clusters as automation, not a single named actor.


What Patchstack and Bricksforge published, and the version mismatch


Patchstack's 8 October post states affected versions through 3.1.8.9, fixed in 3.1.8.10. The CVE record, published by Wordfence as CNA, agrees on the affected range, up to and including 3.1.8.9, but names no fixed version.


Bricksforge's changelog, checked again on 11 October, has no 3.1.8.10 entry. After 3.1.8.9 (28 August 2026) it shows 4.0.0 (17 September) and 4.0.1 (8 October), titled "Security fix for the Pro Forms file upload." The CVE range stops at 3.1.8.9 and never mentions 4.x, yet the vendor shipped its fix in 4.0.1, so no source says outright whether 4.0.0 is affected.


Our reading is that you should take the newest build your line allows, which means 4.0.1 on the vendor line or 3.1.8.10 on 3.x per Patchstack, and anyone on 4.0.0 should still move to 4.0.1. An update will not delete a PHP file that was already dropped under uploads, and Patchstack separately tells anyone who ran a vulnerable version without protection to review the host and its logs.


What to change this week


  1. Inventory every WordPress host you manage for Bricksforge and Pro Forms, without assuming your usual plugin report covers it.

  2. Update Bricksforge. Prefer the vendor's 4.0.1 line, or 3.1.8.10 on 3.x per Patchstack.

  3. If you cannot update yet, put a WAF or virtual-patch rule on the form submit and nonce endpoints. Cover extension and encoding tricks, not only a bare .php string.

  4. Deny PHP execution under wp-content/uploads. That is standard WordPress hardening rather than Patchstack advice.

  5. Sweep uploads for PHP-family files and login_admin_*.php, especially under bricksforge/tmp/ and the current month folder. Some plugins leave empty index.php stubs in uploads, so open anything you find before you call it. If a file is unexplained, we would treat the site as entered, rotate WordPress, database, and hosting credentials, and rebuild from known-good content that predates the earliest suspicious hit.


Four hunts to run (test first)


These are our own hunts, built from artifacts Patchstack published, and each one needs a test run in your environment. WordPress usually sends admin-ajax actions in the POST body, and these queries only look at the URL, so add a predicate for your body or WAF rule field if you log one. Bodies are rarely logged, so the file hunts often carry more weight.


Hunt in Splunk: Bricksforge form submits and PHP under uploads (test first)


index=YOUR_WEB_INDEX
| eval path=lower(coalesce(uri_path, uri, url, http_uri, ""))
| eval qs=lower(coalesce(uri_query, query, qs, ""))
| eval client=coalesce(src_ip, src, clientip, client_ip)
| eval status=tonumber(coalesce(status, http_status, status_code))
| eval signals=mvappend(
    if(match(path, "/wp-json/bricksforge/v1/form_submit") OR match(qs, "rest_route=/bricksforge/v1/form_submit"), "bricksforge_rest_submit", null()),
    if(match(path, "/wp-admin/admin-ajax\\.php") AND match(qs, "bricksforge_form_submit"), "bricksforge_ajax_submit", null()),
    if(match(path, "/wp-admin/admin-ajax\\.php") AND match(qs, "bricksforge_regenerate_nonce"), "bricksforge_nonce", null()),
    if(match(path, "/wp-content/uploads/") AND match(path, "(\\.|%2e)(php|php5|php7|php8|phtml|pht|phtm|phps)([?#]|$)"), "php_family_under_uploads", null()),
    if(in(client, "177.75.57.20","23.97.62.146","84.247.60.125","38.154.185.97","150.109.16.166","153.75.90.146"), "patchstack_top_ip", null()))
| where isnotnull(signals)
| stats min(_time) as first_seen, max(_time) as last_seen, values(signals) as signals, values(eval(path." status=".coalesce(status, "none"))) as path_status by client, host
| convert ctime(first_seen) ctime(last_seen)
| sort 0 first_seen

Field mapping: web, WAF, or reverse-proxy logs; replace YOUR_WEB_INDEX with yours, and behind a CDN make sure client is the real visitor. A 200 on PHP under uploads means you go look at the file, though a rewrite rule can return 200 too. Real form traffic will match as well, and if nothing comes back, check that WordPress actually logs into this index.


Hunt in Kibana: form_submit, uploads PHP, and login_admin files (test first)


Search 1: Bricksforge submits and PHP-family requests under uploads


(
  url.path: "/wp-json/bricksforge/v1/form_submit"
  OR url.query: *bricksforge/v1/form_submit*
  OR (url.path: "/wp-admin/admin-ajax.php"
      AND (url.query: *bricksforge_form_submit* OR url.query: *bricksforge_regenerate_nonce*))
)
OR (
  url.path: /wp-content/uploads/*
  AND url.extension: (php OR php5 OR php7 OR php8 OR phtml OR pht OR phtm OR phps
                      OR PHP OR Php OR pHp OR PHTML)
)

Search 2: PHP-family and login_admin files under uploads on the host


file.path: */wp-content/uploads/*
AND event.type: (creation OR change)
AND (file.extension: (php OR php5 OR php7 OR php8 OR phtml OR pht OR phtm OR phps OR PHP OR Php OR pHp OR PHTML)
     OR file.name: login_admin_*)

Field mapping: Search 1 uses ECS HTTP fields; where bodies are logged, filter for temporaryFileUploads on form_submit. The extension fields are case-sensitive keywords, so Patchstack's case variants are listed by hand and other casings will slip through; a lowercase normalizer in your pipeline fixes that. Search 2 needs Elastic Defend or FIM on the web hosts. Migration scripts and plugin index.php stubs can also match, and if HTTP never reaches Elasticsearch, Search 2 is what you have.


Hunt in Falcon CQL: new PHP under uploads and web workers spawning shells (test first)


event_platform=Lin #event_simpleName=/^(NewScriptWritten|NewExecutableWritten|ProcessRollup2)$/
| case {
    #event_simpleName=/^(NewScriptWritten|NewExecutableWritten)$/ TargetFileName=/\/wp-content\/uploads\/.*\.(php[0-9]?|pht|phtm|phtml|phps)$/i | signal := "php_family_written_under_uploads" ;
    #event_simpleName=ProcessRollup2 UserName=/^(www-data|apache|nginx|php-fpm)$/i ImageFileName=/\/(sh|bash|dash|curl|wget)$/i | signal := "web_user_spawned_shell_or_fetch" ;
    #event_simpleName=ProcessRollup2 ParentBaseFileName=/^(apache2|httpd|nginx|php-fpm[0-9.]*|php[0-9.]*-fpm|php-cgi[0-9.]*)$/i ImageFileName=/\/(sh|bash|dash|curl|wget)$/i | signal := "web_parent_spawned_shell_or_fetch" ;
    * | signal := "" ;
  }
| signal != ""
| groupBy([aid, ComputerName, signal], function=[min(@timestamp, as=first_seen), max(@timestamp, as=last_seen), collect([TargetFileName, ImageFileName, ParentBaseFileName, CommandLine, UserName], limit=10), count()])
| sort(first_seen, order=asc, limit=max)

Field mapping: Falcon events in Next-Gen SIEM on Linux web hosts. Whether the sensor logs a .php write depends on version and policy, so drop a harmless test file in uploads first and fall back to FIM if nothing shows. The two process branches are our addition for a generic webshell follow-on; Patchstack did not report that step. UserName can be empty on Linux, so switch to UID if needed. Deploy hooks calling curl or wget will match too, and an empty result means nothing until you confirm the sensor is live on those hosts.


Hunt in KQL: Bricksforge URIs and PHP created under uploads (test first)


let Lookback = 30d;
let PatchstackIps = dynamic(["177.75.57.20","23.97.62.146","84.247.60.125","38.154.185.97","150.109.16.166","153.75.90.146"]);
let Web = CommonSecurityLog
| where TimeGenerated > ago(Lookback)
| where RequestURL contains "bricksforge/v1/form_submit"
    or RequestURL contains "bricksforge_form_submit"
    or RequestURL contains "bricksforge_regenerate_nonce"
    or RequestURL matches regex @"(?i)/wp-content/uploads/.*\.(php\d?|pht(ml|m)?|phps)(\?|$)"
    or SourceIP in (PatchstackIps)
| project TimeGenerated, DeviceName = DestinationHostName, Signal = "web_bricksforge_or_uploads_php", Detail = strcat(RequestMethod, " ", RequestURL, " ", SourceIP, " ", DeviceAction);
let Files = DeviceFileEvents
| where Timestamp > ago(Lookback)
| where ActionType in ("FileCreated", "FileRenamed", "FileModified")
| where FolderPath matches regex @"/wp-content/uploads(/|$)"
| where FileName matches regex @"(?i)\.(php\d?|pht(ml|m)?|phps)$"
| project TimeGenerated = Timestamp, DeviceName, Signal = "uploads_php_file", Detail = strcat(ActionType, " ", FolderPath, " ", FileName);
union Web, Files
| summarize FirstSeen = min(TimeGenerated), LastSeen = max(TimeGenerated), Signals = make_set(Signal), Details = make_set(Detail, 10) by DeviceName
| order by FirstSeen asc

Field mapping: CommonSecurityLog covers WAFs and proxies that forward CEF; Application Gateway logs land in AGWAccessLogs (OriginalRequestUriWithArgs) or AzureDiagnostics, so swap the Web leg if yours go there. Run it in Sentinel with DeviceFileEvents streamed through the Defender XDR connector, or run each leg on its own; web host names and endpoint names may not line up, so read the groups with that in mind. Backup agents and plugin index.php stubs can also match, and a blank result only counts once you know the logs and hosts are there.


Interpret before you escalate


Finding

Benign read

Escalate when

POST to bricksforge form_submit or regenerate_nonce

Real Pro Forms traffic

Same source follows the nonce with an upload and a submit, or repeats extension variants

temporaryFileUploads in a logged body

Normal form upload metadata

Image path paired with a PHP-family url, or url encodings of PHP extensions

GET or POST to PHP under wp-content/uploads

A plugin stub such as index.php

Any 200 to a file you cannot explain, especially in bricksforge/tmp or login_admin_*.php

New login_admin_*.php under uploads

None we know of

Any hit; confirm the file, then rotate credentials and rebuild

Web user or apache/php-fpm spawning sh, bash, curl, or wget

Deploy or health hook you can name

No planned change you can name, or it lines up with a new uploads PHP file

Hit from a Patchstack top IP

Shared cloud or proxy space

Combined with a form_submit or uploads PHP hit

Empty results

Nothing matched

No web logs or sensor on the WordPress hosts, so fix coverage first


Sources



What This Means for Your Team


Bricksforge will not show up in a WordPress.org plugin audit, so start with an install list, not a CVE feed. Get each site onto the newest build you can justify given the version mismatch, stop PHP from running in uploads, and then run the four hunts on every Bricks host you manage. If a PHP file is already sitting next to the images, patching will not remove it, and you still have to clean the site and work out what else changed.


Inception Protection


Inception Protection is our managed detection and response service, and it works with the Microsoft licenses you already have. A chain like this lands on WordPress hosts many SOCs barely see, so we pull Defender and web-log signals from those sites into the same investigation as the rest of your environment and act on the host quickly. You still make the containment and remediation calls.


Inception Foresight


If you want to see how well your Microsoft environment would surface activity like this, try the free Inception Foresight M365 Assessment: https://www.inceptionsecurity.com/m365assessment. You can also follow Inception Security on LinkedIn and @inceptionsec on X.


bg-map-white.png

INCEPTION SECURITY™

A cybersecurity company with in depth knowledge of the threat landscape and security controls.

NAVIGATION

GET IN TOUCH

© 2025 All Rights Reserved by INCEPTION SECURITY™ .

bottom of page