Drupal, CKEditor 5 image upload skips FileIsImage
SA-CORE-2026-009 added FileIsImage on JSON:API and REST image fields. CKEditor 5 upload still checks filename, size, and dimensions. A format user can store non-image bytes as a public .png. Labbed 11.4.8. No CVE yet.
- Name
- Drupal, CKEditor 5 image upload skips FileIsImage
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- medium
- Disclosure Status
- public
- Vendor
- Drupal
- Affected
- Drupal core 11.4.8 (61044508). Authenticated user of a text format with CKEditor 5 image upload. Standard basic_html and full_html. JSON:API and REST ImageItem already reject this.
- Published
- 12 Oct 2026
- Updated
- 12 Oct 2026
- Tags
- 0-day, drupal, ckeditor5, unrestricted-upload, stored-xss, cwe-434, cwe-79, authenticated
the dimensions validator shrugged
I am @abraxas_null. The proof of concept is on GitHub: abraxas/drupal-ckeditor-fileisimage (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Drupal core 11.4.8 (61044508071749629aaf3380aa85971e94df9e84). No CVE yet. CWE-434 / CWE-79. 5.4 Medium (CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:L/I:L/A:N). Remote unrestricted upload. Authenticated format user. Not a PHP shell on the default image-extension allowlist.
SA-CORE-2026-009 taught JSON:API and REST to add FileIsImage when the field is an ImageItem. CKEditor 5's own upload controller never got that line. FileImageDimensions looks at a file, notices it is not an image, and returns without a violation. Filename extension is what is left. Name the bytes x.png and Drupal stores them under sites/default/files/inline-images/. Name them x.php and you get 422. The leftover is the public .png that is not a PNG.
Same pin: oEmbed second-hop SSRF.
Why Drupal
I had a CMS wave open. Drupal core is a large PHP tree with a security team that actually ships SA-CORE notes, which is both a gift and a trap. The gift is a skip list with hashes. The trap is assuming the note covered every caller.
I only wanted Critical, High, or Medium. Unauthenticated or authenticated without administer site configuration. Admin Composer RCE is the product. I was not going to file that.
Drupal.org is a mirror of git.drupal.org. GitHub tags are usable. The pin is the tag object people actually install.
Pinning 11.4.8
Latest GA on the day I cloned was 11.4.8. Drupal::VERSION = '11.4.8'. Commit 61044508071749629aaf3380aa85971e94df9e84. I had already walked 11.4.7. The 11.4.8 tree was a re-verify plus five parallel hunts: XSS/HTMX, file/MIME/oEmbed, JSON:API/unserialize/SQLi, auth/CSRF/Host, REST/media/Views.
Hub drupal:11.4 is not a pin. It floated to 11.4.9 while I was writing the lab. Composer-exact drupal/recommended-project:11.4.8 on php:8.4-apache-trixie is.
11.4.0 dropped Article from the Standard profile. /node/add/article is how you get CKEditor 5 in front of a living editor without inventing a format. The image applies core/recipes/article_content_type after install. That is not a security finding. It is why the lab is not an empty Standard homepage.
The skip list is half the hunt
Before you look for leftovers, you write down what already has a press release.
- SA-CORE-2026-004 PostgreSQL Entity Query
INkeys.array_values()is in. - SA-CORE-2026-005 JSON:API PHP object injection. Relationship-meta plus
checkForSerializedStrings. - SA-CORE-2026-006 gadget chain. Kernel tests present.
- SA-CORE-2026-007
rebuild.phpHost. Lines 35-41. - SA-CORE-2026-008 oEmbed discovery document host. First hop gated. Second hop is a sibling leftover, not this post.
- SA-CORE-2026-009 JSON:API/REST image MIME. Those two paths only. This post is the CKEditor caller they missed.
- SA-CORE-2026-010 image style non-
private://. Patched. - SA-CORE-2026-011 HTMX attrs in
Xss::attributes().Xss.phponly.AttributeXssis a different leftover. - SA-CORE-2026-012 Layout Builder current block-label XSS. Current row uses
@label. Siblings in Move Block are another leftover. - SA-CORE-2026-013 CKEditor 5 / es-toolkit XSS. 11.4.7+.
Known public, not unpublished: POST /user/password?_format=json still skips the HTML-form flood. Drupal.org history around #1681832. Mail-bomb, not ATO. install.php still never calls setupTrustedHosts. Phishing "View your site." Does not take over an installed site. Low. I left it.
Surfaces that returned nothing
JSON:API access checks held. EntityQuery IN keys stayed patched. Serialized-field POI stayed patched. rebuild.php Host stayed patched. REST image upload on an ImageItem field already injects FileIsImage. That was the control that made the CKEditor path interesting: same product, same constraint class, different controller.
I did not get an unauthenticated RCE, SQLi, or LPE on 11.4.8. Admin-only Composer is expected. I am not filing that.
The leftover
return [
'FileExtension' => [
'extensions' => implode(' ', $allowed_extensions),
],
'FileSizeLimit' => [
'fileLimit' => $max_filesize,
],
'FileImageDimensions' => [
'maxDimensions' => $max_dimensions,
],
];No FileIsImage. FileImageDimensionsConstraintValidator does this when the bytes are not an image:
$image = $this->imageFactory->get($file->getFileUri());
if (!$image->isValid()) {
return;
}JSON:API already has the missing line (core/modules/jsonapi/src/Controller/FileUpload.php 175-176). REST FileUploadResource.php 147-148 does the same. Media Library image create still builds a generic FileItem in FileUploadForm::createFileItem(), which is the same class of miss and not this lab.
The route is POST /ckeditor5/upload-image/{editor} with multipart upload. Access is _entity_access: editor.use plus CSRF. On Standard, basic_html and full_html both have image_upload.status true.
What an attacker can do
Needs an account that can use a format whose editor has image upload. A complete stranger with no account cannot hit this route.
Class: Unrestricted upload / stored XSS. Reach: Remote.
| Privilege | Method / sink | Kind | Lab-proven or inferred |
|---|---|---|---|
Write non-image bytes to a public .png under sites/default/files/inline-images/ |
POST /ckeditor5/upload-image/{editor} multipart upload |
integrity | Lab-proven. x.png returned 201 and url=/sites/default/files/inline-images/x.png |
| Read those bytes back at the public files URL | GET of the JSON url |
recon | Lab-proven. 200, body contained DRUPAL-CKEDITOR-FILEISIMAGE-WITNESS |
| Stored XSS in a visitor if the web server or browser sniffs the file as HTML/JS | Same public URL, Content-Type not image | integrity (visitor origin) | Inferred. This lab did not change Apache MIME. Drupal stored the bytes. |
| Upload a real image through the same route | Control control.png 1x1 PNG |
intended | Lab-proven. 201 |
Closed: PHP/RCE via .php (lab-proven 422 FileExtension); unauthenticated upload; JSON:API/REST ImageItem; LPE; a shell on the default image-extension allowlist.
If the web server later serves the stored .png with a sniffed HTML type, visitors execute that as stored XSS. That is the integrity follow-through. It is not automatic on stock Apache image/png mapping. I did not claim it was.
The lab
Live Drupal 11.4.8, Standard, SQLite, loopback 127.0.0.1:19500. Control 1x1 PNG returned 201. Witness bytes as x.png returned 201 and GET /sites/default/files/inline-images/x.png contained DRUPAL-CKEDITOR-FILEISIMAGE-WITNESS. Same bytes as x.php returned 422. Editor in the SUCCESS line is basic_html.
SUCCESS drupal-ckeditor-fileisimage png-http=201 php-http=422 control-png-http=201 editor=basic_html stored-http=200 stored-has-witness=yes url=/sites/default/files/inline-images/x.png DRUPAL-CKEDITOR-FILEISIMAGE-WITNESSpython3 drupal-ckeditor-fileisimage-Abraxas-Labs.pyor cd lab && ./run.sh. Loopback only.
Wrong turns, in order
I treated FileImageDimensions as a MIME check. It is dimensions on a valid image. On garbage bytes it is a no-op. That cost a reread of the validator, not a reread of the SA.
I used hub drupal:11.4 once. 11.4.9 came up. The leftover is on 11.4.8. Composer-exact after that.
I looked for Article on stock Standard and did not find it. 11.4.0 dropped it. Recipe after install. Not a bug.
I almost wrote "PHP RCE" because the file is public. .php is 422. The allowlist is image extensions. Do not inflate File write to RCE.
Theatre: a reverse shell. The oracle is 201 on fake x.png plus 422 on the same bytes named x.php plus the stored GET of the witness.
The fix
Add 'FileIsImage' => [] to CKEditor5ImageController::getImageUploadValidators. Keep FileExtension so .php stays 422. Image media types in Media Library should use ImageItem rather than a generic FileItem so they inherit the same constraint JSON:API already applies.
Pack
- GitHub: abraxas/drupal-ckeditor-fileisimage
- Loopback client: drupal-ckeditor-fileisimage-Abraxas-Labs.py
- Lab: lab/
Abraxas Labs · abraxaslabs.tech · github.com/abraxas · @abraxas_null · abraxas.null@proton.me