Skip to content

Hyperion Writeup

   
  • Author : Gynvael Coldwind
  • Language : C/C++
  • Upload : May 9th, 2018
  • Level : I will rate it 8/10
  • Platform : Server: Linux | Client: Windows/Linux etc.
  • Files : hyperion
  • Desc : It’s 2017, so even single-player games require Internet connection.

Hyperion was one of the pwn challenges from Google CTF 2017 Finals. One day I randomly asked Gynvael (who is the author of this challenge) to give me some CTF challenges. He gave me 3 Revs and 1 Pwn challenges. In the end I solved 2 Revs and 1 Pwn. I received these challenges in June 2019, I solved the 2 Revs (the 3rd Rev is still unsolved and I still have no clue XD) sometime in 2020 but this Pwn (Hyperion) was still remaining. Just recently I decided that I will take a look and I will solve it finally.

First Look

First look of the Challenge

Hyperion is a single player clone of Scorched Tanks, except that it requires internet connection even when playing againts a bot. We are given a server binary that runs on Linux and 2 versions of client, one for windows and one for linux. The client binaries requires SDL2 because the game uses SDL2 Library for graphics and controls.

The Goal of this challenge is to not only defeat the bot, but to also get a shell on the server. I immediately opened the server binary in IDA Pro.


main()

The challenge pops shell if its running in DEBUG environment with “HYPERION_ENV_TEST” environment variable set. Game also supports fork server mode and single run mode.

I used the single run mode so that I can interact through STDIN and STDOUT.

1socat TCP-LISTEN:1337,reuseaddr,fork exec:./hyperion_server

Now we can connect to the challenge server with localhost as IP.

1./hyperion_client localhost

Hyperion - Let’s Play by X3eRo0

Game Controls

The game has a map of 640x480 pixels or 38400 bytes. Dirt is represented by 1 and Air is represented by 0. We control the Green Tank and Red Tank is the enemy but Red Tank never misses. We have 3 Weapons as described in the above image.

  • 1 - 0x12 Damage, 10 Radius Explosion
  • 2 - 0x00 Damage, 15 Radius Dirt Ball
  • 3 - 0x08 Damage, 20 Radius Explosion

Here is a small and very bad gameplay.

main()

The Reverse Engineering Starts

Before starting reversing the code I wanted to look at the network traffic, for that I used wireshark and followed the TCP stream for the packet I found related to the challenge.

This is what the server was sending the client upon receiving the connection


main()

And this is what the client was send to the server after the player fires a bullet.


main()

So, from the above 2 images its clear that the server sends the whole MAP and TANK information to the client and the client renders the MAP and the 2 Tanks based on what information the server sent. The client then sends the bullet information to the server which contains information about weapon, angle and power that the player used to fire at the enemy tank. The server then calculates where this bullet should hit using some basic math and deducts health points and Dirt from the MAP due to the explosion.

So, now we can start reverse engineering the server binary and look for bugs that we can exploit.

First let’s take a look at some functions which the binary uses more frequently than others, these mainly include network functions to send and receive data.

BTW this challenge uses NetSock which is a C++ Network Library for both Windows and Linux.

The server binary has SendText, SendMap, SendTank, SendBullet and SendPacket functions, out these SendPacket Wraps the Data to be sent in a C++ string and sends it along with the 32 bit length of the data. So, it first sends the 4 byte header which contains the length of the data to be received by the other party.

SendText, SendMap, SendTank and SendBullet just sends their respective data structures to the client socket.

Now let’s take a look at the main handler function which handles everything.

 1__int64 __fastcall Game(Netsock *c, __int64 some_number, int a3, int a4, int a5, int a6)
 2{
 3    int v7; // ecx
 4    int v8; // er8
 5    int v9; // er9
 6    __int64 v10; // rdx
 7    const char *Message_; // rdx
 8    const char *Tank_1; // rax
 9    const char *Tank; // rax
10    const char *v14; // rax
11    char map[38400]; // [rsp+10h] [rbp-9608h] BYREF
12
13    if ( !SendText(c, "Welcome to Hyperion Tank Game!") )
14        return 1LL;
15    memset(map, 0, sizeof(map));
16    GenerateMap(map);
17    LABEL_4:
18    AdjustTankPosition(tanks, map);
19    AdjustTankPosition(&tanks[1], map);
20    if ( !SendMap(c, map) || !SendTank(c, tanks) )
21        exit(0);

Let’s disect this into multiple peices that we can go through one by one.

So, first we will look at line 11 we see that the map has been allocated on the stack with 38400 ((640 * 480) / 8) bytes of size. It first sends the welcome text then it generates the map using some random numbers and after that it Adjusts Tank coordinates onto the map, now it sends the map and the tank structure to the client. So, let’s take a look at the SendMap function.

Before we look at the IDA’s decompiled code, I want to mention the networking code of this game.

The game sends packets of this structure

1struct Packet {
2  std::string type;  // Use only 4-byte values.
3  std::vector<uint8_t> data;
4};

the type string contains the header for identification of what kind of data are we sending and receiving. Now let’s analyse the SendMap function, I have included comments from the source code.

 1_BOOL8 __fastcall SendMap(Netsock *socket_stuff, const void *map)
 2{
 3    void *v2; // rax
 4    _BOOL4 v3; // ebx
 5    /*Packet p; */
 6    Packet MapPacket; // [rsp+10h] [rbp-48h] BYREF
 7    __int64 v6; // [rsp+48h] [rbp-10h]
 8
 9    /* const size_t data_sz = (MAP_W * (MAP_H + 1)) / 8; */
10    v6 = 38480LL;
11    /*p.type = "GMAP";*/
12    cplusplus_string_init(&MapPacket);
13    std::__cxx11:: ... ::operator=(&MapPacket, "GMAP"); // c++ garbage redacted
14
15    /* p.data.resize(data_sz); */
16    resize(MapPacket.data, 0x9650uLL);
17    v_first_element = vector_access_operator(MapPacket.data, 0LL);
18    memcpy(v_first_element, map, 38480uLL);
19    v3 = SendPacket(socket_stuff, &MapPacket);
20    /* destructor */
21    func(&MapPacket);
22    return v3;
23}

The vulnerability here is that the data_sz is calculated incorrectly because the correct size should be (MAP_W * MAP_H) / 8. This function is just giving out all the sensitive pointers on the stack to all the clients connecting. The leak includes stack pointers, libc pointers, and binary pointers.

leaks

The data contained PIE leak, in fact it’s the return address of binary +0x2874, at this point we have all the information that we need for exploitation but we still haven’t found an exploitable vulnerability aside from this memory leak.

Let’s analyse next block of code.

28    if ( !RecvClientCommand(c, &command_received_by_client) )
29        return 2LL;
30    if ( cplusplus_string_compare(&command_received_by_client, "FIRE") )
31        return 3LL;
32    if ( tanks[0].hp <= 0 )
33    {
34        SendText(c, "Bye.");
35        return 0LL;
36    }
37
38    t = *array_access_operator(command_received_by_client.data, 0LL);
39    if ( t.Weapon != 1 && t.Weapon != 2 && t.Weapon != 3 )
40        return 4LL;
41    if ( t.Power < 10.0 || t.Power > 100.0 )
42        return 5LL;

Here it receives a packet from the client and checks if it’s a FIRE command, because this is the only input that we can send to the server as client. Whenever a client fires a bullet it sends a FIRE Packet and a Bullet Packet. Then it checks if Weapon is a valid Weapon value and if the Power and Angle are also valid or not.

Now we will look at the Game loop

43if ( t.Angle >= 0.0 && t.Angle <= 180.0 )
44{
45    for ( i = 0; ; ++i ) // Game loop
46    {
47        if ( i > 1 )
48            goto LABEL_4;
49        
50        /* ... */
51
52        if ( i || tanks[0].hp <= 0 )
53        {
54            if ( i != 1 || tanks[1].hp <= 0 )
55                continue;
56            expl = FireTankGunAndCheatHard(map, &bullet_event, &dword_0 + 1, v7, v8, v9);
57        }
58        else
59        {
60            *&expl.exploded = FireTankGun(map, &bullet_event, &t, v7, v8);
61            *&expl.y = v10;
62        }
63        if ( SendBullet(c, &bullet_event) != 1 )
64            goto LABEL_4;
65
66        /* Explosion related code redacted */
67    }
68}

In the above loop it is doing the calculation related to Firing of bullet. Here IDA’s decompiled code made it a bit hard to realise whats happening. The highlighted for-loop on line 45-47 iterates from 0-1 and that is because it is iterating over the two tanks for shooting, if i is 1 then FireTankGunAndCheatHard function is called because Red Tank is the enemy tank but if i is 0, then FireTankGun function is called.

Let’s see both of these functions, I am using the challenge source code here

1. FireTankGun

 1Explosion __fastcall FireTankGun(
 2	char *map,
 3	Bullet *bullet,
 4	TargettingData *Target,
 5	int x, int y
 6)
 7{
 8
 9	/* ... */
10
11	vx = 0.01 * Target->Power * cos(3.14159 * Target->Angle / 180.0);
12	vy = 0.01 * Target->Power * -sin(3.14159 * Target->Angle / 180.0);
13	bullet_event_clear(bullet);
14	x_1 = v6;
15	y_1 = v7 - 5.0;
16	explode = 0;
17	last_ix = min();
18	last_iy = min();
19	for ( i = 0; i <= 9999; ++i )
20	{
21		x_1 = x_1 + vx;
22		y_1 = y_1 + vy;
23		vy = vy + 0.0009807000000000001;
24		ix = x_1;
25		iy = y_1;
26		/* ... */
27	}
28	if ( explode )
29	{
30		a2.x = ix;
31		a2.y = iy;
32		a2.weapon = Target->Weapon;
33		bullet_event_emplace_back(bullet, &a2);
34		ApplyExplosionToMap(map, ix, iy, Target->Weapon);
35	}
36	LOBYTE(v5) = explode;
37	return v5 | (ix << 32);
38}

This function is responsible for projectile shooting of Green Tank. The trajectory of bullet is effected by Gravity (line 23), Angle and Power (line 11-12). After calculating the correct coordinate of the Explosion, the explosion effect is applied to the MAP (line 34).

2. FireTankGunAndCheatHard

 1Explosion __fastcall FireTankGunAndCheatHard(
 2	char *MAP,
 3	Bullet *bullet_event,
 4	unsigned int weapon,
 5	int a4, int a5, int a6
 6)
 7{
 8
 9	/* ... */
10	bullet_event_clear(bullet_event);
11	v17 = v7;
12	y_1 = v8 - 5.0;
13	explode = 0;
14	while ( y_1 > -20.0 )
15	{
16		x = v17;
17		y = y_1;
18		if ( y_1 >= 0 && GetPixel(MAP, x, y) )
19		{
20			explode = 1;
21			break;
22		}
23		v14.x = x;
24		v14.y = y;
25		v14.weapon = 0;
26		bullet_event_emplace_back(bullet_event, &v14);
27		y_1 = y_1 - 1.5;
28	}
29	/* ... */
30	if ( explode )
31	{
32		x_1.x = x;
33		x_1.y = y;
34		x_1.weapon = weapon;
35		bullet_event_emplace_back(bullet_event, &x_1);
36		ApplyExplosionToMap(MAP, x, y, weapon);
37	}
38	LOBYTE(v6) = explode;
39	*&v12.exploded = v6 | (x << 32);
40	*&v12.y = y | (weapon << 32);
41	return v12;
42}

This function is responsible for NON-PROJECTILE AIMBOT shooting of Red Tank. The trajectory of bullet shot by Red Tank is always fixed on Green Tank. The Bullet never misses because it falls straight on Green Tank. After calculating the correct coordinate of the Explosion, the explosion effect is applied to the MAP (line 36).

Looking at the above functions we know that the Red Tank is going to punish us if we miss any of our shots.

The Vulnerability

 1__int64 __fastcall ApplyExplosionToMap(
 2	char *map,
 3	int x,
 4	int y,
 5	int weapon
 6)
 7{
 8	bool effect; // [rsp+2Ch] [rbp-1Ch]
 9	int r; // [rsp+34h] [rbp-14h]
10	signed int j; // [rsp+38h] [rbp-10h]
11	signed int i; // [rsp+3Ch] [rbp-Ch]
12
13	r = WEAPON_EXPLOSION_RADIUS[weapon];
14	effect = weapon == 2;
15
16	for ( i = y - r; y + r >= i; ++i )
17	{
18		for ( j = x - r; x + r >= j; ++j )
19		{
20			if ( (i >= 0 && i <= 480) && // Vulnerability
21				 (j >= 0 && j <= 639) &&
22				 ((j - x) * (j - x) + (i - y) * (i - y) <= r * r )
23			   ){
24				PutPixel(map, j, i, effect);
25			}
26		}
27	}
28	return ApplyGravitationToMap(map, (x - r), (r + x));
29}
30
31/* Modifies Colors in MAP at bit level */
32__int64 __fastcall PutPixel(
33	char *MAP,
34	unsigned int x,
35	unsigned int y,
36	char color
37)
38{
39	__int64 result; // rax
40	unsigned int v5; // [rsp+20h] [rbp-4h]
41
42	if ( x <= 639 && y <= 480 )
43	{
44		v5 = 640 * y + x;
45		MAP[v5 >> 3] &= ~(1 << (v5 & 7));
46		result = MAP[v5 >> 3];
47		LOBYTE(result) = ((color & 1) << (v5 & 7)) | result;
48		MAP[v5 >> 3] = result;
49	}
50	return result;
51}

Did you spot the Vulnerability?

The vulnerability here is in the ApplyExplosionToMap(). If you see line 20 We can see that the boundary check for i is done incorrectly. The boundary for i should’ve been i >= 0 && i <= 479, instead it is i >= 0 && i <= 480.

What if an explosion happens at the bottom of the MAP?. The explosion will create an empty circle of Air in the MAP and half of this circle will lie inside the MAP and half of the circle will corrupt the values which lies after the MAP.

This vulnerability allows us to create explosion effect outside of the MAP, which means we can manipulate bits outside of the MAP. We know there are important pointers just after the MAP on the stack, if we can corrupt them we can redirect code execution.

I tried to destroy the ground until I reach the bottom of the MAP and now if we look at the GMAPP leak we will see a lot NULL bytes including the part where we got the return address from, which means we were able to modify the return address by just exploding the ground.

Leak after exploding ground

Triggering The Vulnerability

Okay, we know that we can manipulate the return address just by doing different kinds of explosions. Explosion with weapon 1 will remove the dirt in the radius of the explosion which basically means it will unset the bits in the range of its radius and if we fire a weapon 2 which is the dirt ball then we will set all the bits in the range of the weapon 2.

Using this primitive we can overwrite the return address with any arbitrary value just by exploding different weapons, but its not as easy as it sounds. If we start destroying the ground instead of shooting at the Red Tank then the Red Tank will immedieatly destroy us and we won’t be able to do anything. We need to figure out a way to defeat the Red Tank.

2D Projectile AIMBOT

Making an aimbot for hyperion was fairly easy because the tanks don’t move horizontally. It’s like solving a physics problem to find the correct initial velocity and angle to hit the target. Since the Tanks could be at different height level we can’t just use the basic projectile motion formulas, we need to take account of the height difference.

Projectile

Let’s call the difference of height between both the Tanks as H, the displacement between both the Tanks as D, initial velocity at which the projectile is launched or in our case the Power as u, Gravitaional acceleration as g and initial launch angle as θ (Theta) then the height of a projectile at any time (t) is given by,

equation of height

We want the projectile to hit the Red Tank which is at displacement D from the Green Tank. So, the time t when the projectile reaches the Red Tank will be given by,

Time at the end

Using both the equations we can derive the expression for initial velocity of launch or Power.

Derivation of initial Velocity

Using the above expression for the initial velocity we can try a bunch of angles in the range of 0-90 degrees for which we get initial velocity under 100 because that is our limit for Power.

 1from math import sqrt, sin, cos, radians as rd, degrees as dg
 2
 3def GetAim(d, h):
 4    g = 9.8
 5
 6    for angle in range(90, 0, -1):
 7        root = ((g * d * d)/2)
 8        root /= (d * abs(sin(rd(angle))) * cos(rd(angle)) - h * (cos(rd(angle)) ** 2))
 9        power = sqrt(root)
10        angle = 180 - angle
11        if power <= 100.0 and power >= 10.0:
12            return power, angle
13
14def CreateAimbotPacket(tanks):
15    xa = tanks.tank1.x
16    xb = tanks.tank2.x
17    ya = tanks.tank1.y
18    yb = tanks.tank2.y
19    d = abs(xa - xb)
20    h = abs(ya - yb)
21    power, angle = GetAim(d, h)
22    print("AIM at (%d, %d)" % (xb, yb))
23    print("angle: %f" % angle)
24    print("power: %f" % power)
25    return CreatePacket(1, power, angle)
26
27def FireAt(weapon, x, y, tanks, debug):
28    xa = tanks.tank1.x
29    xb = x
30    ya = tanks.tank1.y
31    yb = y
32    d  = abs(xa - xb)
33    h  = abs(ya - yb)
34    P,A= GetAim(d, h)
35    if debug:
36        print("AIM at (%d, %d)" % (x, y))
37        print("angle: %f" % A)
38        print("power: %f" % P)
39    return CreatePacket(weapon, P, A)

These functions were working fine but eventually we would miss our shot and the other tank hits a bullseye, in this situation we can never kill him because the MAP was generated in a way that when we hit him once, he falls in the hole created by the explosion and when we adjust our aim, our bullet collides with a mountain peak instead of colliding with the Red Tank. We don’t account for any obstruction on the trajectory of the bullet. Sometimes our bullet is obstructed by a mountain or maybe we cannot hit him because of our terrible spawn location, sometimes we spawn at a very steep slope so its not possible to aim at the Red Tank. He will keep hitting us and we will always die before him no matter what. So, if we miss even once, we loose the battle and we have to start from scratch and this is what makes this exploit a little unreliable.

The Exploit

The exploitation is easy now, we have to build a primitive to do manipulate bits arbitrarily on the stack. Using this primitive we can overwrite the return address and redirect code execution. We already have a call to system("/bin/sh”) in the executable itself and we also have the PIE base address. Using these two values we can get the address where will jump to get code execution.

If we take a look at the code where it checks if the Red Tank is alive or dead? we can see that if we defeat the Red Tank, then the MAP is ours to play with. We can do anything on the MAP.

100		tanks[j].hp = 0;
101		if ( j )
102		  Message_ = " Stay & practice :)";
103		else
104		  Message_ = " Game Over.";
105		if ( j )
106		  Tank_1 = "RED";
107		else
108		  Tank_1 = "GREEN";
109		sprintf(s, "%s tank destroyed.%s", Tank_1, Message_);
110		SendText(c, s);

Steps to Get the SHELL:

  1. Use the aimbot to kill Red Tank
  2. Clear the Ground (clear all the bits on the stack)
  3. Fire Dirt ball and bombs carefully so that we can write bits of the return address

But how do we write arbitrary bits with explosions? We can easily do that by carefully using the Dirtball and the bomb, for writing a bit 1 at (630, 479) we must fire the Dirtball at 630 - dirtball.radius and then we will fire a explosive bomb such that only 1 set bit remains at 630. We can skip writting bit 0 because the MAP is already cleared and the bits at that position are already 0.

$ cat flag.txt

The exploit is available here.