Recently, I was messing around with stuff, as one does. And what was supposed to be a small side-quest with maybe some low impact findings, turned out to be a rabbit hole going much deeper than I had initially suspected. While talking to one of my friends, he nudged me toward this weird Forum software, I never heard of it before, but I took a look, huh, seems like its not that unpopular after all?

The Bug
After some messing around and setting up a local test environment, I started playing around with
the software, trying basic XSS, those kinda things, after exhausting the basic vectors,
i started reading the code and quite quickly found a interesting looking function in the
UserEditor module, the exact piece of code that gets run when you update your profile. In there
I quickly found this function:
/**
* Updates user options.
*
* @param array<int, int|float|string> $userOptions
*/
public function updateUserOptions(array $userOptions = []): void
{
$updateSQL = '';
$statementParameters = [];
foreach ($userOptions as $optionID => $optionValue) {
if (!empty($updateSQL)) {
$updateSQL .= ',';
}
$updateSQL .= 'userOption' . $optionID . ' = ?';
$statementParameters[] = $optionValue;
}
$statementParameters[] = $this->userID;
if (!empty($updateSQL)) {
$sql = "UPDATE wcf1_user_option_value
SET " . $updateSQL . "
WHERE userID = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute($statementParameters);
}
}
This function is responsible for updating the Values in the DB that belong to our User,
inside the DB the data is structured as colums of userOption’s, so userOption1 maps to
your aboutMe field while userOption2 is your birthday field, as defined in UserOption.xml:
<?xml version="1.0" encoding="UTF-8"?>
<data xmlns="http://www.woltlab.com" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLoca>
<import>
<categories>
<category name="profile">
<showorder>1</showorder>
</category>
<!-- profile -->
<category name="profile.aboutMe">
<parent>profile</parent>
</category>
<category name="profile.personal">
<parent>profile</parent>
</category>
<category name="profile.contact">
<parent>profile</parent>
</category>
...
but there is a bug in the code, did you spot it yet? Well, you probably didn’t, after all,
the PHPDoc says our input is of type array<int, int|float|string>, and our Statement params arent
the Vulnerability, which only leaves the $optionID left as controlled input, and that is of
type int, so we can’t do much here. Or can we?
AJAX to the Rescue

While in most modern software, you’d expect a rest api, here we have a ajax proxy, which is
reachable under /ajax-proxy.php and its basically just a dynamic dispatcher for defined Actions:
protected function invoke()
{
... Bounds checks
// create object action instance
$this->objectAction = new $this->className($this->objectIDs, $this->actionName, $this->parameters);
// validate action
$this->objectAction->validateAction();
// execute action
$this->response = $this->objectAction->executeAction();
}
In our request to the ajax proxy we define which Action of which Class we want to hit, and it
will validate and later execute it assuming we have the correct priviledges. And to our luck,
there is a seemingly leftover unused Action in UserAction.class.php that trickles straight
into the vulnerable updateUserOptions():
public function update()
{
if (isset($this->parameters['data']) || isset($this->parameters['counters'])) {
parent::update();
if (isset($this->parameters['data']['languageID'])) {
foreach ($this->getObjects() as $object) {
if ($object->userID == WCF::getUser()->userID) {
if ($this->parameters['data']['languageID'] != WCF::getUser()->languageID) {
WCF::setLanguage($this->parameters['data']['languageID']);
}
break;
}
}
}
if (
isset($this->parameters['data'])
&& \array_key_exists('password', $this->parameters['data'])
&& $this->parameters['data']['password'] !== ''
) {
foreach ($this->getObjects() as $object) {
SessionHandler::getInstance()->deleteUserSessionsExcept(
$object->getDecoratedObject(),
SessionHandler::getInstance()->sessionID
);
}
}
} else {
if (empty($this->objects)) {
$this->readObjects();
}
}
$groupIDs = $this->parameters['groups'] ?? [];
$languageIDs = $this->parameters['languageIDs'] ?? [];
$removeGroups = $this->parameters['removeGroups'] ?? [];
$userOptions = $this->parameters['options'] ?? [];
if (!empty($groupIDs)) {
$action = new self($this->objects, 'addToGroups', [
'groups' => $groupIDs,
'addDefaultGroups' => false,
]);
$action->executeAction();
}
if (!empty($removeGroups)) {
$action = new self($this->objects, 'removeFromGroups', [
'groups' => $removeGroups,
]);
$action->executeAction();
}
foreach ($this->getObjects() as $userEditor) {
if (!empty($userOptions)) {
$userEditor->updateUserOptions($userOptions);
}
if (!empty($languageIDs)) {
$userEditor->addToLanguages($languageIDs);
}
}
// handle user rename
if (\count($this->objects) == 1 && !empty($this->parameters['data']['username'])) {
if ($this->objects[0]->username != $this->parameters['data']['username']) {
$userID = $this->objects[0]->userID;
$username = $this->parameters['data']['username'];
WCF::getDB()->beginTransaction();
// update article
$sql = "UPDATE wcf1_article
SET username = ?
WHERE userID = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute([$username, $userID]);
// update comments
$sql = "UPDATE wcf1_comment
SET username = ?
WHERE userID = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute([$username, $userID]);
// update comment responses
$sql = "UPDATE wcf1_comment_response
SET username = ?
WHERE userID = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute([$username, $userID]);
// update media
$sql = "UPDATE wcf1_media
SET username = ?
WHERE userID = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute([$username, $userID]);
// update modification log
$sql = "UPDATE wcf1_modification_log
SET username = ?
WHERE userID = ?";
$statement = WCF::getDB()->prepare($sql);
$statement->execute([$username, $userID]);
WCF::getDB()->commitTransaction();
// fire event to handle other database tables
EventHandler::getInstance()->fireAction($this, 'rename');
}
}
}
in this rather bulky piece of code, we can see how $userOptions is derived from our
attacker controlled input and then later feeds straight into our vulnerable updateUserOptions(),
which is breaking the initial assumption that updateUserOptions() would only be
called with array<int, int|float|string>, we could now provide an array<string, string>
instead and later have our malicious payload:
parameters[options][1 = ((SELECT DATABASE())), userOption2]=x
become interpretted as:
UPDATE wcf1_user_option_value
SET userOption1 = ((SELECT DATABASE())), userOption2 = ?
WHERE userID = ?
at which point we can exfiltrate database values by directly updating our own userOptions (which can be trivially retrieved by viewing your own profile).
The Endgame
That on its own, is pretty bad! We can exfiltrate all database values we could dream of, maybe crack some hashes, steal session tokens, but honestly, thats quite a lot of effort when we have a much easier way.
We’ve all been there, we want to log into our favourite Woltlab Forum, but inconveniently our dementia progressed far enough for us to forget our email AND password, luckily to us, we can still request a password reset off of our username alone! When we do this, a 20Byte Key gets stored in the DB, and we can use our arb DB read to exfiltrate it, afterwards its as simple as restoring the valid password reset url, and then we get to set a new password for the victim User.
If the Forum instance is self hosted, we can now overtake the admin account, and install a malicious php extension to get rce on the server its running on.
Thats the endgame.

Aftermath
After confirming impact, I reached out to the vendor and they quickly responded and provided a patch fixing this bug shortly after, its a crazy world we live in and you never know what critical bugs might be hiding in every day software we use, this bug in particular has been present since the earliest release, going back over 15 years! This finding also led me to continue research into this fascinating piece of software, and find another even more critical bug, of which i will write another article in the future.
MITRE assigned it CVE-2026-52630, making it my first CVE, yay :)