h3llh0und

CVE-2026-109162: a form setting read sixteen lines before it was set

TYPO3 v14's form framework read an upload field's destination folder while the field was still being created, before the form's own configuration had been applied. Every form therefore saved its uploads to the default public folder, whatever storage it had been configured to use. The rule that explains the bug had been written into the codebase twice, in February and again in July 2026, before the last property it applied to was moved in October.

Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Every form in it was built and submitted inside throwaway containers on my own machine, using the project’s own functional-test framework. The issue was reported privately by email to the TYPO3 Security Team. Another researcher had reported it first, and both of us are credited on the security bulletin and the CVE record. It is written up here after the bulletin, the fixed release and the CVE record were public.

A TYPO3 form with a file-upload field can say where submitted files are saved, through a property called saveToFileMount. An integrator building a form that collects CVs or scanned IDs points it at a storage that is not public. On TYPO3 v14, up to and including 14.3.8, the form ignored that setting and saved every upload to the default 1:/user_upload/, a folder in the public fileadmin storage. It raised no error and logged no warning. The upload simply landed somewhere else.

The reason is written down in TYPO3’s own source, in a docblock added to PropertyMappingConfiguration in February 2026:

“At this point, form definition properties (from YAML) are fully available, unlike in initializeFormElement() which runs before YAML properties are set.”

That sentence describes the bug exactly. The bug sat in the method the sentence names, one file over in the same extension, and it stayed there until this morning.

Sixteen lines

A TYPO3 form is a YAML definition. ArrayFormFactory turns it into objects by walking the tree, and each field goes through two steps in a fixed order:

// ArrayFormFactory::addNestedRenderable(), v14.3.8
$renderable = $parentRenderable->createElement($identifier, $type);   // line 122
// ...
$renderable->setOptions($nestedRenderableConfiguration);             // line 138: the form's own settings

createElement() lives in AbstractSection. It applies the defaults for the field type, the prototype, and then gives the element a chance to set itself up:

// AbstractSection::createElement()
$element->setOptions($typeDefinition);   // prototype defaults only
$element->initializeFormElement();       // runs now, before line 138 above

So while initializeFormElement() runs, the element holds the prototype’s values and nothing from the form. The form’s own values arrive sixteen lines later, back in the factory. The prototype for both upload field types sets saveToFileMount: '1:/user_upload/', and the file-upload element read its destination folder in initializeFormElement():

// FileUpload::initializeFormElement(), v14.3.8
//   If the property "saveToFileMount" exist for this element it will be used.
// ...
$saveToFileMountIdentifier = $this->getProperties()['saveToFileMount'] ?? '';

The comment above that line is true, but only of the prototype. Whatever the form says arrives after the read, so the default always wins. ImageUpload uses the same implementation class, which puts both upload fields behind this one read.

That is the whole mechanism. The rest of this post is how it got there, and how long the explanation sat in the codebase before the fix did.

October 2025: the hook that ran last is removed

In TYPO3 13.4 the upload settings were read in a hook called afterBuildingFinished. Its name says when it ran: after the form was built, when every property was in place. 13.4 still reads saveToFileMount there, and it works.

Commit cacb1b497b, ”[!!!][FEATURE] Add PSR-14 Event after form is built”, removed that hook in October 2025 as a breaking change for v14. Its upload-mapping block, 119 lines out of PropertyMappingConfiguration, moved into FileUpload::initializeFormElement(). The code itself was unchanged, but it now ran at the other end of the build. Two properties in that block depended on timing: allowedMimeTypes, which restricts what an upload may be, and saveToFileMount, which decides where it goes. Both started reading the prototype. The change shipped in v14.0.0 in November 2025.

February 2026: the rule is written down

The multiple-file-upload feature (8357227e46) added two new upload settings, a seed for the per-session subfolder and an allowRemoval flag. It did not put them in initializeFormElement(). It added a runtime method, adjustPropertyMappingForFileUploadsAtRuntime(), back in PropertyMappingConfiguration, and gave it the docblock quoted near the start of this post. The new properties went where the YAML values exist.

The two properties already sitting in initializeFormElement() stayed where they were. From then on the codebase held a written statement that the method could not see form properties, and that same method went on reading two of them. The feature shipped in 14.2.0 in March 2026.

July 2026: the rule is written down again, for one property

allowedMimeTypes was the first to be noticed. A form configured to accept only PDFs accepted anything on the server, because the validator was built from the prototype’s empty list. It was fixed as CVE-2026-15305 in commit cfda210503, and the commit message explains the root cause better than I could:

“FileUpload::initializeFormElement() registered the MimeTypeValidator based on the “allowedMimeTypes” property. However, the method initializeFormElement() is called from AbstractSection::createElement() *before* the concrete form definition properties are applied via the second setOptions() call in ArrayFormFactory::addNestedRenderable().”

It moved the validator into the runtime method and noted that “the same class already sets the upload seed and removal configuration there for the exact same reason.” That made the rule’s second written appearance, this time in a security fix, and it was applied to the property that fix was about. saveToFileMount, further down the same method, stayed. That release was 14.3.5, in July 2026.

September 2026: the last read

That was the state I found on 8 September, the day 14.3.7 came out. By then the arithmetic was short. The file-upload element was the only element in the extension whose initializeFormElement() read properties at all, and of the four upload settings, three already lived in the runtime method: two were born there and one had been moved there by a CVE. Only one was left.

A missing value proves nothing on its own: maybe the setting was never parsed, maybe the test form was wrong. So the report was built around controls, all run on the same 14.3.7 build with TYPO3’s own functional-test harness:

With those four passing, the destination works, the property is parsed, and properties reach runtime code, so the only thing left to explain the default is the timing. The last control was the strongest: the identical form definition built on 13.4.9, the supported LTS, produced 1:/protected_uploads/. The vendor’s own previous release served as the referee.

I also spelled out what the report was not. No request parameter chooses the folder, and an attacker cannot steer a file anywhere. The defect is that the operator’s choice is thrown away in favour of a default. Read as path traversal or arbitrary write, it would have been read wrong. The exposure is that files meant for a restricted storage end up in a world-readable one. The subfolder name is an HMAC of the form session, so a stored file is not easy to find from outside without its URL.

October 2026: moved

The fix, f536f75c6b, “[SECURITY] Respect saveToFileMount of form uploads”, removes 56 lines from FileUpload and adds a resolver to the runtime method:

private function resolveUploadFolder(FileUpload $renderable): ?string
{
    // Backend users previewing a form may lack access to the upload storage
    if ($renderable->getRootForm()->getRenderingOptions()['previewMode'] ?? false) {
        return null;
    }
    $saveToFileMountIdentifier = (string)($renderable->getProperties()['saveToFileMount'] ?? '');
    if ($this->isAccessibleFolder($saveToFileMountIdentifier)) {
        return $saveToFileMountIdentifier;
    }
    // ... falls back to the folder of the form definition, then to the converter's default
}

The comment in initializeFormElement() now reads “The upload folder is resolved in Mvc\Property\PropertyMappingConfiguration, once the element’s own properties are set.” After this release, no element in the extension reads a property in initializeFormElement(). The docblock from February is now true of the whole extension.

I re-ran the original probe this morning, unchanged, against the last affected release and the fixed one:

  14.3.8 14.3.9
the four controls pass pass
upload folder in the built configuration 1:/user_upload/ 1:/protected_uploads/
storage the submitted file lands in (configured: 2, non-public) 1, public 2
probe tests asserting the bug 5 of 5 pass the two bug assertions fail

On 14.3.9 the probe fails exactly where it asserted the bug, and the controls are unchanged. The harness has no web server, so it cannot show how a misplaced file is then delivered. On that point I rely on the bulletin, which says files stored this way “are served directly, without the access checks applied to non-public storages.”

Two reporters

On 9 September the Security Team answered that the topic had already been reported by other researchers. It had: the bulletin and the record credit Bejo ning first and me second, both as reporter, with Josua Vogel as the remediation developer. They scored it AT:P rather than the AT:N I had proposed, which gives 6.3 Medium. My report had offered that alternative itself, because the bug only matters on sites that configured the property at all. The CNA’s number stands.


Bulletin TYPO3-CORE-SA-2026-024 · CVE CVE-2026-109162 · Fix f536f75c6b · Affected 14.0.0–14.3.8, fixed in 14.3.9 LTS; 13.4 not affected

Reported by email 8 September · answered 9 September as already reported · fix, release 14.3.9, bulletin and CVE record published 13 October, crediting both reporters.

Eurico Nicacio — @h3llh0und

← all posts Eurico Nicacio · h3llh0und