sm-winter | 12/01/25
Description
Welcome to Super Memory World (Winter Edition)! Each level has 2 flags - one for the normies, and one for the l33t. Can you get them all? I hear there may even be another one, but I don't see any evidence of that...
To follow along with the challenge's full code, download the source here. Line numbers are set to match the source files.
Flag 1
Observations
To start, we're given a C source file for the first game level. To play the game, we can SSH into a CTF League server that only allows us to execute the compiled code. It's unlikely that we're supposed to break this limitation to, say, use GDB to jump to the flag printing code. Instead, let's take a look at the source code.
A few notes that will be helpful:
read_flag()is defined to fetch and print out the flag. Its implementation details are straightforward & irrelevant; there's nothing strange happening in it.- The game will continually loop, collecting input if any is given and updating the state
accordingly. The display will also be updated with the current state, using ASCII art
to provide a simple GUI. Details here (including
main(),buildLevel(), andrenderLevel()) are similarly irrelevant.
Revealing The Flag
On the other hand, there's some of interesting stuff happening in handleMovement().
There's a fair bit of noise I'll filter out again, but let's take a closer look at the
middle of the function:
101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 | |
This is the key! It looks like we need to find a way to maneuver our character over a
number 1 somewhere on the map.
Let's try playing the game to see where that might be... Here's the view from the end of the level:
++++++++++++++++++ +++++++++++++++++
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ _ + _
+ \_/ + \_/
+ |._ + |._
+ |'."-._.-""--.-"-.__.'/ + |'."-._.-""--.-"-.__.'/
+ | \ / + | \ .-. /
+ | | ( + | | (@.@) (
+ | | ) + | | '=.|m|.=' )
+ | / / + | / .='`"``=. /
+ |.' ( + |.' (
+ |.-"-.__.-""-.__.-"-.) + |.-"-.__.-""-.__.-"-.)
+ | + |
+ | + |
+ | + |
+ | + |
+ f p + 1
---------------------------------------------------------------------------------
There's the flag! So close, and yet so far... our character is the p and the + symbols
represent a solid wall. There's no way we can make the jump required at the top of the
screen, either .
Teleportation
There must be some weakness that we're missing in the code. Let's go back and take a closer look at the beginning of the function:
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 | |
This is interesting! It doesn't appear that there're any bounds checks occurring before players are moved, meaning the player could end up with negative coordinates. What does that mean? Well, let's take another look at how the player's location is stored:
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
Since xpos and ypos are both unsigned chars, they can only hold values
from 0 - 255. In the world of unsigned integers, \(0 - 1 = 255\). This means that if the
player moves left from the left edge of the screen (where x == 0), x will
suddenly become 255, teleporting them to the 256th column of the map!
The whole map looks like this:
_ ++++++++++++++++++++++
.+(` )`.
:( . ) +++++++++++++++++++++++++++
.-.,_`. ( ) )
.=( ) ( ) ) +++++++++++++++++++++++
( .- ( ` ( .--._ _
( ( ( ) ((...( . ' ')`.
`- _-( .=( ( . ( . ) ++++++++++++++++++
(( . (__ `. ( ) )
`- __.' `.. ._`-,) ) +++++
*
* * *
* *
* * .')
* (_ ) ++++++++++++++++++ +++++++++++++++++
* * * + +
* * + +
* + +
* * _ + +
* * * .+(` )`. + +
_ * :( . ) + +
(` `). _ * * .-- `. ( ) ) + +
- --== ( ).=-- .+(` ')`. * * .=( ) ` _` ) ) + +
) ( '`. :( . ) * * ( . ) ( ) ._ + +
.+(`( . ) .-- `. ( ) ) * * ( ( )) `-'.:(` ) + _ + _
(( (..__.:'---'..=( ) ` _` ) ) * * `- __.' :( )) + \_/ + \_/
`. `( ) ) ( . ) ( ) ._ * * * `( ) )) + |._ + |._
) ` __.:' ) ( ( )) `-'.:(` ) * ` __.:' + |'."-._.-""--.-"-.__.'/ + |'."-._.-""--.-"-.__.'/
) ) ( ) --' `- __.' :( )) * * * + | \ / + | \ .-. /
.-' (_.' .') * `( ) )) * * * * * * + | | ( + | | (@.@) (
+ (_ ) * ` __.:' * * * * * + | | ) + | | '=.|m|.=' )
+ * * * * + | / / + | / .='`"``=. /
+ * * * * + |.' ( + |.' (
+ p * * * \ o _|_ * + |.-"-.__.-""-.__.-"-.) + |.-"-.__.-""-.__.-"-.)
++++ __ )) }^{ /___\ + | + |
++++++ _|__|_ .-#-----. /|\ .---'-'---. .-#-----. + | + |
++++++++ (oo) ___ /_________\ //|\\ /___________\ /_________\ + | + |
++++++++++ ++ ++ >-( )-< /___\ |[] _ []| ///|\\\ | A /^\ A | |[] _ []| + | + |
++++++++++++ ++++ ++ ++++ + (......) |"#"| | |*| | ////|\\\\ | |"| | | |*| | + f + 1
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
It just happens to be 256 columns wide, meaning that stepping off the left boundary will teleport us to the right edge of the screen!
Exploit
While we can't immediately walk off the left boundary, after climbing a bit up the
snowflakes, we can jump off the upper left edge get warped around to the right side! Then,
we simply need to walk over to the 1 to complete the level and get the flag.
Flag 2
Observations
For this flag, we're given the source code for the game's second level. This code bears several key similarities to level 1:
readFlag()is still called when the player stands over the1character on the map- The game still loops, collects input, and renders using the same abstractions
handleMovement()still doesn't check bounds before moving the player
Let's also take a look at the whole map to start. Once again, we start in the bottom left corner:
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
+ +
+ +
+ +
+ +
+ +
+ _ +
+ .+(` ')`. +
+ :( . ) +
+ `. ( ) ) +
+ ` _` ) ) +
+ ( ) +
+ `-' +
+ ~~. +
+ (~ ) +
+ ( _.)_) (`~ +
+ (. _) +
+ +
+ | +
+ . / _ +
+ \ | .--._ _ _ .+(` ')`. _ _ +
+ / .-..=( . '` ')`. .+(` )`. .=:( . ) '` ')`. +
+ \ ,g88R_ .=( ( .:( . ) :( . ) ( .( ( :( . ) +
+ d888(` `). _ ( .( ( `. ( ) ) .-.,_`. ( ) ) ( ( ` `-'`. ( ) ) +
+ - --== 888( ).=-- .+(` ')`. ( ( `-.__..` _`-,) ) .=( ) ( ) ) `- __._;:__..` _`-,) ) +
+ ) Y8P( '`. :( . ) `- __.' ( .- ( ` ( .--._ _ * +
+ .+(`( . ) .-- `. ( ) ) * ( ( ( ) ((...( . '` ')`. _ * _ +
+ (( (..__.:'---'..=( ) ` _` ) ) * `- _-( .=( ( . ( . ) \e/ * \e/ +
+ `. `( ) ) ( . ) ( ) ._ * (( .( `. ( ) ) |._ * |._ +
+ ) ` __.:' ) ( ( )) `-'.:(` ) * `- __.'-.__..` _`-,) ) |'."-._.-""--.-"-.__.'/ |'."-._.-""--.-"-.__.'/ +
+ ) ) ( ) --' `- __.' * :( )) * * * | \ / * | \ .-. / +
+ .-' (_.' .') `( ) )) * * | | ( * | | (@.@) ( +
+ (_ ) * `-+-.:' * | | ) | | '=.|m|.=' ) +
+ * * + ------- + * | / / * * | / .='`"``=. / +
+ b $ + $$$ C d + ++++++ * * |.' ( |.' ( +
+ ----- --+--- ------------ + +++++++++ |.-"-.__.-""-.__.-"-.) * |.-"-.__.-""-.__.-"-.) +
+ + * ---+---- + +++++ ++++++ | | +
+ +++ ------------- + | | +
+a + + c+ + + ++++ +++++++++ | + | +
++ p + +++B+++ + ++ + ++++++ + | + | +
++$ + A + ++++ D +++ + f [ 1 +
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Unfortunately, it's immediately clear that we won't be able to exploit the lack of bounds checking this time since there're now walls all the way around the map. We'll need to find a new way to reach the l33t flag!
New Mechanics
There're some new mechanics in this level that may help with this. These are shown below the map in a new status printout:
---------------------------------
+ + + + +
+ + + + + $0
+ + + + +
---------------------------------
The grid on the left is an inventory. There are lowercase letters a, b, c, and d
scattered across the map. These represent keys that we can add to our inventory by walking
over them and pressing s. Once in our inventory, we can unlock and walk past
the corresponding gates - uppercase A, B, C, and D.
There's also a money display in the status printout. Scattered across the map are a
handful of $ that each increment our wallet by $1 when we walk across them!
Finally, near the far right of the map, between the false flag and the skull flag, there's another gate that looks like this:
+
+
[
We must pay a fee to unlock this gate and reach the skull flag, as can be seen in the
paid_unlock() function:
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | |
How do we get that much money?? There's only 5 $ available to pick up in the game!
Inventory Storage
There must be another way to get rich... Let's take a look at where the player's wallet data is stored and see if there's anything interesting around that:
13 14 15 16 17 18 19 20 | |
Nothing seems to be too special here, but the dollars attribute is defined directly
after the new inventory feature. Let's go take a look at how that inventory array is
used:
155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 | |
To understand this code snippet, it's helpful to know that collectible means that
the user is able to pick up an item from the space — in this level, only keys are
collectible. It's also helpful to know that current_level_location is a macro that
gets the character the player is standing on (e.g. a $ for money or an a for the A
key).
Something interesting is going on with this collection code! As the highlighted lines
show, the game doesn't perform bounds checking for the inventory array before or
after allowing the user to pick up an item. Keys also aren't removed after they're
collected because their disappears attribute is false. This means there is no limit
to the number of keys we can pick up!
Buffer Overflow
How is that useful? Well, the location the collectibles are stored at is incremented each
time one is picked up. This means that we can continue writing into memory past the end of
the array allocated for player.inventory[]. As we saw in the PlayerInfo
struct, this allows us to overwrite the previous value in player_info->dollars and
gain significantly more money!
Even better, we just saw that when keys are added to the (potentially overflowed)
inventory array, the value actually stored is the ASCII value for the character
shown on screen, e.g. an a for the A key. These ASCII values take 1 byte each, so we can
control how memory is overwritten on a per-byte basis.
Going back to paid_unlock(), we need to get $1650549605 in order to beat the level.
Let's take a look at some alternate representations of 1650549605 to figure out the order
we need to pick up extra keys in!
| Big-endian | Little-endian | |
|---|---|---|
| Hexadecimal | 6261 6365 |
6563 6162 |
| ASCII | b a c e |
e c a b |
We're on a little-endian system, meaning that we need to overwrite the
4 bytes in the uint player wallet with keys in the order e, c, a, b to get
$1650549605. Unfortunately, there's no key e!
Thankfully, we don't need one. The e is needed for the least significant byte of
the dollar amount, which it translates to $101 (the ASCII code for e is 101). The
ASCII code for d (a valid key!) is 100, which would give us $100 if we used it first
with the extra keys bug. We can then pick up a single $ to get the extra dollar needed
to unlock the system! This means our new sequence for picking up extra keys is d, c,
a, b, plus a $ that we pick up after starting to exploit this bug.
Exploit
Warning
Remember that you need to leave at least 1 $ behind to pick up after you start
exploiting the extra keys bug to gain extra money! The first time you exploit the bug
and pick up another d, your wallet will be set to $100, regardless of what was there
before.
Let's go back to the game and carry this out! We can jump over the $ next to key b,
saving it for after we've reached all the keys and can start overwriting the wallet. After
picking up a second d key, our wallet should jump to $100. After this, we can grab the
last $ at any time and pick up the remaining 3 keys in the correct order. With that, we
can finally make it past the toll booth to get the second flag!
Not seeing your wallet increase the first time you exploit the bug?
Something interesting is happening here... the first time (and second time!) you try
to exploit the bug, you probably won't see your wallet's value increase. This is
because of how the compiler lays out structs in memory. Even though uint dollars
immediately follows char inventory[4] in the struct definition, the compiler may
add additional space in a process known as struct padding. This helps speed up
operations that reference struct elements, and is even required on some architectures!
Look up struct padding and packing to learn more.
Flag 3
Observations
For this flag, we were given the executable file for level 2, so the flag must also be
buried somewhere in level 2. This is the compiled version of the C file we were just
looking at, so all of the same code is here, including the buffer overflow vulnerability
in the player's inventory. Quickly running file on the executable shows that ASLR is
disabled. This will make life a lot easier if we need to reference specific memory
addresses in our attack!
level2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=234ff2f183068255d1e960d7d9bb2381c3de8310, for GNU/Linux 3.2.0, with debug_info, not stripped
objdump Output
Since we were specifically given the executable for this flag, let's start by examining
its contents for anything unexpected using
objdump -d level2 --disassembler-color=on | less -R. Near the bottom, there is
indeed something unexpected that's not in the C file we looked at before:
Disassembly of section .hidden_in_plain_sight:
0000000063626161 <another_flag>:
63626161: f3 0f 1e fa endbr64
63626165: 55 push %rbp
63626166: 48 89 e5 mov %rsp,%rbp
63626169: 48 81 ec 90 00 00 00 sub $0x90,%rsp
63626170: 89 f8 mov %edi,%eax
63626172: 48 89 b5 70 ff ff ff mov %rsi,-0x90(%rbp)
63626179: 88 85 7c ff ff ff mov %al,-0x84(%rbp)
6362617f: 48 8d 05 2e ba dd 9c lea -0x632245d2(%rip),%rax # 401bb4 <_IO_stdin_used+0x4>
63626186: 48 89 c6 mov %rax,%rsi
63626189: 48 8d 05 27 ba dd 9c lea -0x632245d9(%rip),%rax # 401bb7 <_IO_stdin_used+0x7>
63626190: 48 89 c7 mov %rax,%rdi
63626193: e8 38 a8 dd 9c call 4009d0 <fopen@plt>
...
636261dd: 48 89 c7 mov %rax,%rdi
636261e0: e8 4b a7 dd 9c call 400930 <fread@plt>
636261e5: 48 89 45 f0 mov %rax,-0x10(%rbp)
636261e9: 48 8b 0d 90 de dd 9c mov -0x63222170(%rip),%rcx # 404080 <stdout@GLIBC_2.2.5>
636261f0: 48 8b 55 f0 mov -0x10(%rbp),%rdx
636261f4: 48 8d 45 80 lea -0x80(%rbp),%rax
636261f8: be 01 00 00 00 mov $0x1,%esi
636261fd: 48 89 c7 mov %rax,%rdi
63626200: e8 db a7 dd 9c call 4009e0 <fwrite@plt>
63626205: 48 8b 45 f8 mov -0x8(%rbp),%rax
63626209: 48 89 c7 mov %rax,%rdi
6362620c: e8 2f a7 dd 9c call 400940 <fclose@plt>
63626211: 48 8d 45 80 lea -0x80(%rbp),%rax
63626215: 48 89 c7 mov %rax,%rdi
63626218: e8 03 a7 dd 9c call 400920 <puts@plt>
6362621d: 48 8b 05 5c de dd 9c mov -0x632221a4(%rip),%rax # 404080 <stdout@GLIBC_2.2.5>
63626224: 48 89 c7 mov %rax,%rdi
63626227: e8 94 a7 dd 9c call 4009c0 <fflush@plt>
6362622c: 90 nop
6362622d: c9 leave
6362622e: c3 ret
This is awesome! It looks like an extra function another_flag() has been placed at
address 0x63626161 which, as we discovered with flag 2, is a
number whose bytes correspond with the ASCII string aabc (on a little-endian system). It
looks like we need to reuse the buffer overflow vulnerability from flag 2 to overwrite
some return address and cause another_flag() to be executed.
Vulnerable Return Address
There's just one issue, though: handleMovement() doesn't loop at all. Instead, the
game loop is handled in runGame().
237 238 239 240 241 242 243 244 245 246 | |
If player_info was a local variable in handleMovement(), we would be unable to
exploit flag 2's vulnerability to overwrite any return addresses because we need to
overwrite 4 bytes and the function would return after each byte is overwritten, probably
crashing after the first byte of the return address is overwritten and definitely
preventing us from overwriting any more.
Thankfully, player_info is passed by reference for efficiency, so it actually exists
in the stack frame of runGame()! As long as there's nothing crucial in memory
between player_info and runGame()'s return address, we can overflow the buffer
during the while ((input = getchar()) != 3 && input != 'q') { loop as much as
necessary without crashing the program. Then, after exiting the loop by pressing
Q, runGame() will return to whatever address we specify.
Checking if there's anything critical between player_info and runGame()'s return address
Before trying to exploit this vulnerability, we should check that there isn't anything
critical (i.e. that might make our attack fail if overwritten) in the section of
memory that we need to overwrite. In this case, that would be any other local
variables in runGame(). input is a crucial variable that
could have unexpected consequences for overwriting since it's used in the while
loop's condition. However, we can't overwrite it with a 3 or q, so we don't have to
worry about accidentally triggering a program exit. It's also passed by value to
handleMovement() (i.e. a copy of it is made for handleMovement()) before
it can be overwritten. This means we don't need to worry about unexpected behavior in
that function, either.
message_buffer could also be a risk since the address it holds is passed to
printf() to print a message before the end of the while loop ends. If this
address is overwritten with something that isn't mapped memory, the program will crash
when printf() dereferences it to print the string. Thankfully, though, we don't
have to worry about this either since in C's calling convention, parameters are pushed
to the stack before functions are called. As long as we don't keep overwriting memory
past runGame()'s return address, we shouldn't encounter this issue.
Finding the Return Address
All that's left is figuring out how far to overflow the player_info.inventory buffer
to overwrite runGame()'s return address. The stack frame offset for
player_info can be found from objdump -d by looking at the arguments
runGame() passes to handleMovement():
call 0x400970 <printf@plt>
movsbl -0x1(%rbp), %edx
leaq -0x20(%rbp), %rax
leaq 0x2743(%rip), %rcx # 0x4040a0 <level>
movq %rcx, %rsi
movq %rax, %rdi
callq 0x401061 <handleMovement>
-0x1(%rbp) is only 1 byte, which definitely isn't enough for a PlayerInfo
struct. 0x2743(%rip)'s annotation indicates that it holds level, not
player_info. By process of elimination, -0x20(%rbp) must be the offset for
player_info.
Reassembling runGame()'s full stack frame
While not strictly necessary, we can use a similar process of searching for
distinctive variable usage and deduction to figure out the exact stack frame of
runGame().
Alternatively, with a little math, gdb can be used to reconstruct the frame:
pwndbg> b runGame
pwndbg> r
pwndbg> info frame
Stack level 0, frame at 0x7fffdf0998f0:
rip = 0x4018bc in runGame (/level2/level2.c:223); saved rip = 0x401b94
called by frame at 0x7fffdf099910
source language c.
Arglist at 0x7fffdf0998e0, args: inventory_size=4, message_buffer=0x0
Locals at 0x7fffdf0998e0, Previous frame's sp is 0x7fffdf0998f0
Saved registers:
rbp at 0x7fffdf0998e0, rip at 0x7fffdf0998e8
pwndbg> p &message_buffer
$1 = (char **) 0x7fffdf0998b0
pwndbg> p &inventory_size
$2 = (int *) 0x7fffdf0998bc
pwndbg> p &player_info
$3 = (struct PlayerInfo *) 0x7fffdf0998c0
Either way, the stack frame should look like this (growing down, so the highest addresses and earliest elements pushed to the stack are on top):
| location | size | data |
|------------+------+---------------|
| |
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
| 0x10(%rbp) (0x4) inventory_size |
| 0x8(%rbp) (0x8) %rip |
| (%rbp) (0x8) %rbp |
| - - - - - - - - - - - - - - - - - |
| -0x1(%rbp) (0x1) input |
| -0xc(%rbp) (0xb) # padding |
| -0x20(%rbp) (0x14) player_info |
| -0x28(%rbp) (0x8) # padding |
| -0x30(%rbp) (0x8) message_buffer |
|~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~|
Since the struct is padded, we also need to use gdb to find the exact offset the
overflow starts at:
pwndbg> b level2.c:244
pwndbg> r
pwndbg> p &player_info
$1 = (struct PlayerInfo *) 0x7ffedd0a1df0
pwndbg> p &player_info.inventory
$2 = (char (*)[4]) 0x7ffedd0a1dfa
pwndbg> p sizeof player_info
$4 = 20
pwndbg> p (0x7ffedd0a1df0 + 20) - 0x7ffedd0a1dfa
$3 = 10
gdb not properly reaching runGame()?
The game we're hacking makes calls to system("/bin/stty cbreak"). This spawns a
child process each time which gdb will follow by default. To turn this off
and continue examining the actual program, run the following:
pwndbg> set detach-on-fork on
pwndbg> set follow-exec-mode same
pwndbg> set follow-fork-mode parent
This shows player_info.inventory starts 10 bytes before the end of player_info.
player_info.inventory is 4 bytes long, so the first overflow byte is 6 bytes before
the end of player_info. Those 6 bytes must hold 2 bytes of padding plus the 4 byte
integer player_info.dollars.
Adding this up, we can see that the stored %rip that we want to overwrite is 26 bytes
after the end of the player_info.inventory buffer (6 for padding & player_info.dollars
+ 12 for padding & input + 8 for stored %rbp).
Figuring this out more quickly
This takes a lot of work and several tools. It turns out there's an easier way to do
it, using just gdb!
pwndbg> b level2.c:244
pwndbg> r
pwndbg> p &player_info.inventory
$1 = (char (*)[4]) 0x7ffd8e21e24a
pwndbg> info frame
Stack level 0, frame at 0x7ffd8e21e270:
rip = 0x401a00 in runGame (/level2/level2.c:244); saved rip = 0x401b94
called by frame at 0x7ffd8e21e290
source language c.
Arglist at 0x7ffd8e21e260, args: inventory_size=4, message_buffer=0x401bc8 ""
Locals at 0x7ffd8e21e260, Previous frame's sp is 0x7ffd8e21e270
Saved registers:
rbp at 0x7ffd8e21e260, rip at 0x7ffd8e21e268
pwndbg> p 0x7ffd8e21e268 - 0x7ffd8e21e24a
$2 = 30
This calculation shows there are 30 bytes from the beginning of
player_info.inventory to runGame()'s return address.
player_info.inventory takes up 4 bytes itself for the 4 inventory slots, so the
first overflowed byte is 26 bytes from the return address.
Exploit
Pulling all of this together, we need to fill our inventory with keys, collect 26 more keys to reach the return address we want to overwrite, then collect specifically the keys:
aabc
This will overwrite the runGame()'s return address and cause another_flag() to
execute when the game ends. All we need to do now is press q to quit the game
and the flag will be printed!
Conclusion
While flags 2 and 3 introduced some new behavior with padded structs, tonight's challenge
was largely based on concepts already seen this term in friendly-work and ret2win. I
hope you enjoyed playing the game and got a better sense of how these vulnerabilities can
be exploited in real-world programs!