Sanniv Khare 10 min read

Kirki RCE: finding copies bundled inside WordPress themes

Kirki RCE: finding bundled copies inside WordPress themes

Kirki 6.3.1 and older has an unauthenticated arbitrary code execution bug (CVE-2026-103065, CVSS 8.2, published by Patchstack on 1 Oct 2026, fixed in 6.3.2). The plugins screen only tells you half the story, because Kirki has spent a decade being copied into theme folders. The takeaway: run the 30-line scanner below across wp-content, which finds every kirki.php by its KIRKI_VERSION constant and every vendored kirki-framework/ package directory in well under a second per site. On 33 current wordpress.org themes it found four carrying their own Kirki (agama 1.7.2 with Kirki 4.0.22, flash 1.5.0 with 4.2.0, and sydney 2.73 plus agama again with vendored composer packages). We also diffed 6.3.1 against 6.3.2: every file the patch touches lives in the 6.x page-builder code, and none of those files exists in the 3.x, 4.x or 5.0 framework that themes bundle. So the bundled copies are not reachable through this particular hole, but they are unmaintained code running on your site, and the plugin itself, if installed, needs 6.3.2 today. Tested on 28 Oct 2026 in a Linux container with bash 5.2.21.

Why we did this: ideaxecution.com runs on our own Paperkite theme, which replaced the Customizer fields that frameworks like Kirki provide, but the client WordPress sites we host on CloudPanel run themes we did not write, and a plugin list says nothing about what those themes carry inside. This is the check we wrote to run across them. The uploads hardening from the Bricksforge write-up stops a dropped PHP file from executing; this post is about finding the code that could drop it.

What the 6.3.2 patch actually changes

Patchstack lists the bug as unauthenticated arbitrary code execution and gives no technical detail, and the 6.3.2 changelog in readme.txt says nothing about security at all; it lists a contrast-ratio option for the colour picker, multiselect support and a note that the Role Manager now “depends on WordPress capabilities”. So we downloaded both zips from wordpress.org and diffed them.

curl -sSO https://downloads.wordpress.org/plugin/kirki.6.3.1.zip
curl -sSO https://downloads.wordpress.org/plugin/kirki.6.3.2.zip
unzip -q kirki.6.3.1.zip -d 6.3.1 && unzip -q kirki.6.3.2.zip -d 6.3.2
diff -rq 6.3.1/kirki 6.3.2/kirki | grep -c '\.php'   # 38 PHP files changed or added

Three threads run through the diff, and all of them are in the builder half of the plugin, not the Customizer framework:

The remote file fetch in app/Supports/FileHandler.php moves from Kirki’s own HTTP wrapper to wp_safe_remote_get(), checks for a 200 response and an empty body before writing anything, and only disables TLS verification in a local environment instead of for any host that looks like localhost. wp_safe_remote_get() is the WordPress function that refuses to fetch private and loopback addresses, which is the standard fix when a URL a user controls ends up being downloaded onto the server.

Permission callbacks on the REST controllers in includes/API/ get stricter and more explicit: the media upload endpoint’s post_items_permissions_check() now calls a new user_has_builder_access() helper, two new middleware classes (AdminAccessMiddleware and BuilderAccessMiddleware) appear under app/Http/Middlewares/, and app/Supports/Role.php and app/Wordpress/User.php are rewritten around WordPress capabilities. That is what the “Role Manager now depends on WordPress capabilities” changelog line is really about.

The public form handler in includes/API/Frontend/Controllers/FormController.php starts passing every non-array field value through Utils::sanitize_plain_text_input() before it is stored.

We are not going to speculate about which of these is the CVE and which are the maintainers tidying up around it. The practical point is that the whole patch sits inside app/, includes/API/ and ComponentLibrary/, the directories that make Kirki 6 a page builder.

Why a bundled Kirki 4.x is a different problem

Kirki 6 describes itself in its own plugin header as “an all-in-one no-code builder”. That is a 2025 rewrite. The Kirki that theme authors copied into inc/kirki/ for years is the Customizer framework, versions 3.0 through 4.2, and the layout is nothing alike. We pulled 3.0.45, 4.0.24, 4.2.0, 5.0.0 and 5.2.0 from wordpress.org and checked for the three files at the centre of the patch:

3.0.45  controls core field lib modules ...        no app/, no includes/API/
4.0.24  inc lib packages ...                       no app/, no includes/API/
4.2.0   inc lib kirki-composer kirki-packages ...  no app/, no includes/API/
5.0.0   inc lib pro-src ...                        no app/, no includes/API/
5.2.0   customizer vendor ...                      no app/, no includes/API/
6.3.1   app ComponentLibrary includes routes ...   FileHandler.php, Media.php, FormController.php present

None of the pre-6 releases register a single REST route (grep -rl register_rest_route returns nothing for 4.2.0 and seven files for 6.3.1). The vendor’s advisory range of “6.3.1 and earlier” is technically true of a bundled 4.0.22, but the code the patch changes is not there to be exploited. Treat the bundled copies as what they are: a frozen snapshot of a framework whose maintainers moved on, loaded on every request by a theme whose author has to ship a theme update for you to get anything newer. That is reason enough to know where they are.

One more detail worth knowing. Both the 6.x plugin and the bundled 4.x copies guard themselves with if ( class_exists( 'Kirki' ) ) return; and if ( ! defined( 'KIRKI_VERSION' ) ), and 6.3.x defines KIRKI_VERSION early “to prevent bundled Kirki versions from loading”. So on a site with the plugin active, the theme’s copy usually steps aside and the plugin’s code runs. Deactivate the plugin and the theme’s 4.x copy silently takes over. Which copy is live depends on load order, not on what the plugins screen shows.

The scanner

The reliable fingerprint is the constant. Every Kirki release since 3.0 defines KIRKI_VERSION in its kirki.php, including the copies inside themes, so one grep with a regex that tolerates the spacing differences between versions finds them all, and a second grep pulls the version string out. The script then checks whether the 6.x builder file app/Supports/FileHandler.php sits next to it, which separates the plugin that needs patching from a framework snapshot that does not. A find for kirki-framework/ directories catches themes like sydney that vendor individual composer packages without a kirki.php, and a last grep flags themes that only recommend Kirki through TGM Plugin Activation.

Excerpt of kirki-scan.sh showing the KIRKI_VERSION grep, the FileHandler.php check and the kirki-framework find

The two checks that find real copies; the TGM check is a hint about which themes expect the plugin, not a finding.

#!/usr/bin/env bash
# kirki-scan.sh: find Kirki copies (plugin or bundled in themes) under one or more wp-content dirs.
# Usage: kirki-scan.sh /home/*/htdocs/*/wp-content   (CloudPanel layout)  or  kirki-scan.sh ./wp-content
set -u
printf '%-28s %-52s %-8s %-9s %s\n' "SITE" "WHERE" "KIND" "VERSION" "6.x-BUILDER-CODE"
for wc in "$@"; do
  site=$(basename "$(dirname "$wc")")
  # 1. kirki.php files that define KIRKI_VERSION (the entry point of every Kirki release since 3.x)
  grep -rl --include='*.php' -E "define\(\s*'KIRKI_VERSION'" "$wc" 2>/dev/null | while read -r f; do
    ver=$(grep -oE "KIRKI_VERSION'\s*,\s*'[0-9.]+'" "$f" | grep -oE "[0-9]+(\.[0-9]+)+" | head -1)
    dir=$(dirname "$f")
    case "$f" in
      */plugins/*) kind=plugin ;;
      */themes/*)  kind=theme ;;
      *)           kind=other ;;
    esac
    builder=no; [ -f "$dir/app/Supports/FileHandler.php" ] && builder=YES
    printf '%-28s %-52s %-8s %-9s %s\n' "$site" "${f#"$wc"/}" "$kind" "${ver:-?}" "$builder"
  done
  # 2. themes that vendor individual kirki-framework composer packages (no kirki.php, no version constant)
  find "$wc/themes" -type d -path '*kirki-framework*' -maxdepth 6 2>/dev/null | awk -F'/kirki-framework' '!seen[$1]++ {print $1"/kirki-framework"}' | while read -r d; do
    n=$(find "$d" -mindepth 1 -maxdepth 1 -type d | wc -l)
    printf '%-28s %-52s %-8s %-9s %s\n' "$site" "${d#"$wc"/} ($n packages)" "vendored" "-" "no"
  done
  # 3. themes that only *recommend* Kirki through TGM Plugin Activation
  grep -rl --include='*.php' -E "'slug'\s*=>\s*'kirki'" "$wc/themes" 2>/dev/null | while read -r f; do
    printf '%-28s %-52s %-8s %-9s %s\n' "$site" "${f#"$wc"/}" "tgm-dep" "-" "-"
  done
done

On CloudPanel every site lives under /home/<user>/htdocs/<domain>/, so one glob covers the box:

sudo ./kirki-scan.sh /home/*/htdocs/*/wp-content

If you prefer WP-CLI, wp theme list --field=name and wp plugin list --fields=name,version,status tell you what is installed and active, but neither looks inside a theme folder, which is the whole problem. The file scan is the check that matters.

What it found on 33 real themes

To test it against something more varied than our own servers, we downloaded the current release of 33 popular free themes from downloads.wordpress.org/theme/<slug>.zip (astra, kadence, neve, oceanwp, generatepress, blocksy, storefront, hestia, sydney, customizr, hueman, colormag, zakra, agama, flash, shapely, illdy, activello, sparkling, and others), unpacked them into a fake wp-content/themes/, dropped Kirki 6.3.1 into wp-content/plugins/ as the control, and ran the script.

Terminal output of kirki-scan.sh: agama 4.0.22 and flash 4.2.0 bundled as themes, the 6.3.1 plugin flagged YES for builder code, and vendored kirki-framework packages in sydney and agama

The full run over 33 themes and one plugin took 0.13 s. Only the plugin row carries the 6.x builder code.

SITE   WHERE                                                   KIND      VERSION  6.x-BUILDER-CODE
demo   themes/agama/framework/admin/kirki/kirki.php            theme     4.0.22   no
demo   themes/flash/inc/kirki/kirki.php                        theme     4.2.0    no
demo   plugins/kirki/kirki.php                                 plugin    6.3.1    YES
demo   themes/sydney/inc/vendor/kirki-framework (5 packages)   vendored  -        no
demo   themes/agama/.../packages/kirki-framework (46 packages) vendored  -        no
Stat card: 4 of 33 themes carry a bundled Kirki

Four of 33 current themes ship Kirki code of their own; none of the 33 recommended it through TGM.

agama loads its copy with get_template_part( 'framework/admin/kirki/kirki' ) from its admin setup class, flash with a plain require in functions.php, and sydney requires five individual kirki-framework/* classes (control-base, control-generic, control-slider, control-sortable, url-getter) behind class_exists() checks. A scan that only looked for kirki.php would have missed sydney entirely, which is why the second check is there. Storefront mentions Kirki only in a copyright comment, and the grep correctly ignores it.

What to do with the results

If the plugin row shows plugin 6.3.x YES with a version below 6.3.2, update it now; that is the CVE. If it shows theme 4.x no or vendored, you have an unmaintained Customizer framework in your theme. Our order of preference: move the theme’s options to theme.json and blocks and delete the copy (the route the Paperkite rebuild took for ideaxecution.com), or install the current Kirki plugin so the theme’s class_exists guard defers to maintained code, or at minimum pin the theme and watch its changelog. Whatever you pick, make sure wp-content/uploads cannot execute PHP, because that is the one control that holds regardless of which Kirki is live.

One honest limit: the results table above comes from public themes in a container, not from our CloudPanel boxes; that run happens next and its findings stay internal. The script is the same in both places, and the CloudPanel glob above is the exact invocation.

Need this built? Let’s talk.

We build and run apps for Mac, TV, mobile and the web, and write about what we learn.