ATTT PTIT 2023: writeups
ATTT PTIT 2023 was an information security contest run at PTIT in 2023, with flags in the form ATTT{...}. This post collects my notes for the challenges I kept from it, split into Forensics, Crypto and Rev. Two of the challenges use a FLAG{} prefix instead. One Forensics challenge is still unsolved and a couple of flags are marked as taken from my notes.
Forensics - Ez4Ence
Files: ez4ence.zip
The download is a single RAR file. Inside there is a folder that contains many sub-folders, each of those contains more folders, and at the bottom of every branch there are 10 txt files. Opening them by hand is not realistic.
I used the Find function of WinRAR to search the content of all the files in the archive at once, and the flag showed up in the result list.
Flag: ATTT{https://www.youtube.com/watch?v=4qNALNWoGmI}
Forensics - List
Files: list.zip
The archive List.zip contains 28 empty files placed inside single-letter folders. The file names are the numbers 1 to 28, and the folder names are single characters. So the content is in the tree itself. If you sort the files by their number and read the name of the folder that holds each one, you get a 28 character string:
1
QVRUVHtHc2NjYXdYcXjXd2RXd2R9
This is Base64. Decoding it gives the bytes below.
1
2
00000000: 4154 5454 7b47 7363 6361 7758 7178 d777 ATTT{GsccawXqx.w
00000010: 6457 7764 7d dWwd}
The ATTT{ prefix and the closing brace are clean, but the inside is not readable text and contains the non-ASCII byte 0xd7. The flag.txt I saved at the time has the same garbled content. I tried a single byte XOR over all 256 keys and a Caesar shift over the letters, and neither gave readable text.
Status: unsolved. I can recover the Base64 string from the zip and decode it, but the inner part of the flag stays garbled and I do not have a clean value to give.
Forensics - A Legend
Files: a-legend.zip
The challenge gives A_Legend.zip, which contains a packet capture a-legend.pcapng and a second archive pass.zip. The zip comment of pass.zip is the hint Letters in the sun, and pass.txt inside it is encrypted with AES (compression method 99).
The capture is an FTP session. The client logs in as anonymous, lists the directory (one file, pantheon.jpg), switches to binary mode and runs RETR pantheon.jpg. The data connection on port 20 carries a 5544612 byte JPEG of the Pantheon in Rome. The picture is the same as the alegend.jpg that sits next to the challenge files.
The hint points at the lettering in the photo, which is the inscription on the front of the building. I tried several spellings of the inscription as the password of pass.zip, but none of them opened it, so I did not re-derive the content of pass.txt myself. The flag below is the one I recorded for this challenge.
Flag: ATTT{Tien_tri_Panga}
Status: flag taken from my recorded flag.txt. I confirmed the FTP transfer and the hint, but I did not recover the zip password during this write-up.
Crypto - Crypto1
Files: crypto1.zip
The challenge gives enc.cpp and bases.txt. The source shows that the flag is printed four characters at a time, each one in a different base.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <iostream>
#include <string>
using namespace std;
#define EL printf("\n")
string flag = "ATTT{fake_flag}";
void bases(string &s) {
for (int i = 0; i < s.size(); i += 4)
printf("%o %u %x %u ", (unsigned char) s[i], (unsigned char) s[i + 1], (unsigned char) s[i + 2], (unsigned char) s[i + 3]);
EL;
}
int main() {
freopen("bases.txt", "w", stdout);
bases(flag);
return 0;
}
So the first character is printed in octal, the second in decimal, the third in hex and the fourth in decimal again. Running it on the fake flag gives this.
1
101 84 54 84 173 102 61 107 145 95 66 108 141 103 7d 0
The values in the real bases.txt follow the same pattern.
1
101 84 54 84 173 77 65 111 167 95 6d 101 157 119 5f 109 145 111 77 95 155 101 6f 119 137 116 72 97 137 108 61 105 137 116 61 109 137 116 72 105 137 116 6f 105 137 100 61 121 175
I wrote a Python script that parses each group of four numbers with the matching base (octal, decimal, hex, decimal). The leftover last value is octal.
1
2
3
4
5
6
7
8
9
10
11
12
s = ["101","84","0x54","84","173","77","0x65","111","167","95","0x6d","101","157","119","0x5f","109","145","111","0x77","95","155","101","0x6f","119","137","116","0x72","97","137","108","0x61","105","137","116","0x61","109","137","116","0x72","105","137","116","0x6f","105","137","100","0x61","121","175"]
result = ""
for i in range(0, min(len(s), len(s)//4*4), 4):
octV = int(s[i], 8)
decV1 = int(s[i+1], 10)
hexV = int(s[i+2], 16)
decV2 = int(s[i+3], 10)
asciiV = chr(octV) + chr(decV1) + chr(hexV) + chr(decV2)
result += asciiV
result += chr(int(s[len(s)-1], 8))
print(result)
1
ATTT{Meow_meow_meow_meow_tra_lai_tam_tri_toi_day}
Flag: ATTT{Meow_meow_meow_meow_tra_lai_tam_tri_toi_day}
Crypto - Crypto2
Files: crypto2.zip
The statement says “But today is not the day for substitution cipher. Today I’m using AES encryption to protect my secret. Can you break it?”. The encryption script is short.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from os import urandom
from Crypto.Cipher import AES
f = open("plain.txt", "r")
plaintext = f.read()
assert all([x.isupper() or x in '.,-_{ }' for x in plaintext])
class Cipher:
def __init__(self):
self.salt = urandom(15)
key = urandom(16)
self.cipher = AES.new(key, AES.MODE_ECB)
def encrypt(self, message):
return [self.cipher.encrypt(c.encode() + self.salt) for c in message]
Every plaintext character is encrypted on its own as one AES-ECB block made of the character plus the same 15 byte salt. The key and the salt never change, so the same character always gives the same 16 byte block. That means the whole thing is a substitution cipher in disguise, and frequency analysis works.
I wrote a small tool that counts how many times each ciphertext block appears and compares the frequency to the usual English letter order.
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
import pandas as pd
a = []
b = []
f = open("d:/svatttptit2023/crypto2/x.txt", "r")
for line in f:
line = line.strip() # remove leading/trailing whitespace
if line not in a:
a.append(line)
b.append(1)
else:
b[a.index(line)] += 1
sum = 0
for i in b:
sum += i
print(sum)
d = ["R", "T", "E", "A", "O", "L", "N", "S", "I", "C", "U", "D", "P", "M", "H", "G", "B", "F", "Y", "W", "K", "V", "X", "Z", "J", "Q", "_", " ", "-", "{", "}", "."] # Add 4 more elements to match the length of a, b, and c
c = []
for i in b:
c.append(i / sum * 100)
df = pd.DataFrame({'Ciphertext' : a, 'Appear' : b, 'Frequency': c})
df = df.sort_values('Appear', ascending=[False])
df['Alphabet'] = d
print(df)
The file has 1080 blocks and 32 distinct values. The most common block shows up 216 times (20 percent), which is the space or the letter R in my ordering, followed by blocks with 10.4 and 8.7 percent.
1
2
3
4
5
6
Ciphertext Appear Frequency Alphabet
3 821b5788942bc3c602d59d08841f7639 216 20.000000 R
2 79baa5dd638b9dd358e0ebd73a2f04d5 112 10.370370 T
8 e170db7f88034def96d72730bb471a49 94 8.703704 E
12 c7a8b2b718344a552973e2194f54316e 69 6.388889 A
...
The flag has the form ATTT{...}, so I opened the ciphertext in VS Code and looked for the block that repeats three times in a row after the first A (the three T characters). The brace blocks { and } each appear only once, so they mark the exact range of the flag.
From there I mapped the known blocks to letters. After doing that for the whole range, a few blocks were still unknown, mostly at the end.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
c7a8b2b718344a552973e2194f54316e A
79baa5dd638b9dd358e0ebd73a2f04d5 T
79baa5dd638b9dd358e0ebd73a2f04d5 T
79baa5dd638b9dd358e0ebd73a2f04d5 T
110da0235675bc52e3d3a20b65ee2d69 {
92de53110cc6b7997b23db17334349b0
8135c0c9ab41c1e2feb5dc7c7725084c O
79baa5dd638b9dd358e0ebd73a2f04d5 T
7334d85f5c104c2b0f8318d371f94819 _
c7a8b2b718344a552973e2194f54316e A
7334d85f5c104c2b0f8318d371f94819 _
39fed56a74ee6bdeedae3327a4ef3a87 S
873192d689147d39d203e2123d9760e4 U
8e5fa4a16eb745968a04f328ca859ca8 B
39fed56a74ee6bdeedae3327a4ef3a87 S
79baa5dd638b9dd358e0ebd73a2f04d5 T
fcd6a5a5f5a0a5b0eea7645e96aa73f3 I
79baa5dd638b9dd358e0ebd73a2f04d5 T
873192d689147d39d203e2123d9760e4
...
Going back to the statement, the flag is clearly about a substitution cipher. That makes the remaining letters easy to guess, and the final text is NOT_A_SUBSTITUTION_CIPHER, which fits every known block.
Flag: ATTT{NOT_A_SUBSTITUTION_CIPHER}
Crypto - cry2 (ElGamal-style)
Files: cry2.zip
The challenge gives chall.py and out.txt. The encryption looks like ElGamal, but the first part of the ciphertext is not computed correctly.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def gen_params():
p = getPrime(1024)
g = random.randint(2, p - 2)
x = random.randint(2, p - 2)
h = pow(g, x, p)
return (p, g, h), x
def encrypt(pubkey):
p, g, h = pubkey
m = bytes_to_long(FLAG)
y = random.randint(2, p - 2)
s = pow(h, y, p)
return (g * y % p, m * s % p)
In real ElGamal, c1 is g^y mod p, which hides y behind a discrete log. Here c1 = g * y mod p, a plain multiplication. Since p is prime and g is not zero modulo p, g has an inverse, so y = c1 * g^-1 mod p. Once y is known, the shared value is s = h^y mod p and the message is m = c2 * s^-1 mod p. The private key x is never needed.
I let z3 solve the two modular equations for me. The three big numbers c1, g, p and h, c2 are copied from out.txt and are shortened here.
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 Crypto.Util.number import *
c1 = ... # from out.txt
g = ... # from out.txt
p = ... # from out.txt
solver = Solver()
y = Int('y')
solver.add(c1 == g * y % p)
solver.add(y > 0)
if solver.check() == sat:
model = solver.model()
y_value = model[y].as_long()
print("y = ", y_value)
else:
print("ko co")
solver = Solver()
h = ... # from out.txt
s = pow(h, y_value, p)
c2 = ... # from out.txt
m = Int('m')
solver.add(c2 == m * s % p)
solver.add(m > 0)
if solver.check() == sat:
model = solver.model()
m_value = model[m].as_long()
print(long_to_bytes(m_value))
else:
print("ko co")
1
b'FLAG{s0me_m4th_1s_3asy_1f_y0u_kn0w_4b0ut_m0dular_4r1thm3t1c}'
This flag uses the FLAG{} prefix that is also in chall.py, not ATTT{}.
Flag: FLAG{s0me_m4th_1s_3asy_1f_y0u_kn0w_4b0ut_m0dular_4r1thm3t1c}
Rev - Re03 (ComplimentaryChallenge.exe)
Files: re3.zip
ComplimentaryChallenge.exe is a PE32 console executable for 32-bit Windows. I opened it in IDA. The main function builds a 16 byte string from four integer constants, calls xor_strings on it with the key ISPw, and then prints it inside ATTT{%s}.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int __cdecl main(int argc, const char **argv, const char **envp)
{
FILE *v3; // eax
FILE *v5; // eax
FILE *v6; // eax
DWORD Mode; // [esp+18h] [ebp-20h] BYREF
char Str[4]; // [esp+1Ch] [ebp-1Ch] BYREF
int v9; // [esp+20h] [ebp-18h]
int v10; // [esp+24h] [ebp-14h]
int v11; // [esp+28h] [ebp-10h]
HANDLE hConsoleHandle; // [esp+2Ch] [ebp-Ch]
__main();
*(_DWORD *)Str = 523448849;
v9 = 406598155;
v10 = 557725189;
v11 = 3741480;
xor_strings(Str, "ISPw");
hConsoleHandle = GetStdHandle(0xFFFFFFF5);
...
printf("Flag: ATTT{%s}\x1B[2K\x1B[1GWhat are you waiting for?", Str);
The program prints the flag, but then it sends the ANSI escape codes ESC[2K and ESC[1G, which erase the line, so the flag is wiped from the console right away. The rest of the code only enables the ANSI mode of the console.
The function that matters is xor_strings, which XORs each byte of the string with the key, repeating the key.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
unsigned int __cdecl xor_strings(char *Str, char *a2)
{
unsigned int result; // eax
size_t v3; // [esp+14h] [ebp-14h]
size_t v4; // [esp+18h] [ebp-10h]
unsigned int i; // [esp+1Ch] [ebp-Ch]
v4 = strlen(Str);
v3 = strlen(a2);
for ( i = 0; ; ++i )
{
result = i;
if ( i >= v4 )
break;
Str[i] ^= a2[i % v3];
}
return result;
}
I did not compute it by hand. I ran the program in the IDA debugger, set a breakpoint on the ret of xor_strings, and read the decoded string from the stack.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Stack[00004EC0]:0061FEAC db 58h ; X
Stack[00004EC0]:0061FEAD db 61h ; a
Stack[00004EC0]:0061FEAE db 63h ; c
Stack[00004EC0]:0061FEAF db 68h ; h
Stack[00004EC0]:0061FEB0 db 42h ; B
Stack[00004EC0]:0061FEB1 db 61h ; a
Stack[00004EC0]:0061FEB2 db 6Ch ; l
Stack[00004EC0]:0061FEB3 db 6Fh ; o
Stack[00004EC0]:0061FEB4 db 4Ch ; L
Stack[00004EC0]:0061FEB5 db 65h ; e
Stack[00004EC0]:0061FEB6 db 6Eh ; n
Stack[00004EC0]:0061FEB7 db 56h ; V
Stack[00004EC0]:0061FEB8 db 61h ; a
Stack[00004EC0]:0061FEB9 db 44h ; D
Stack[00004EC0]:0061FEBA db 69h ; i
Stack[00004EC0]:0061FEBB db 0
Flag: ATTT{XachBaloLenVaDi}
Rev - re1 (Miine.exe)
Files: re1.zip
Miine.exe is a 32-bit Windows console Minesweeper game. The flag is not printed as plain text. The binary keeps an encrypted string in its .data section (LcKUC8JBInrfjVzdeb8qCHE8ozXSMt) and decodes it in two steps when the game is won.
The first step XORs the string in place with a 28 byte array that the function builds on the stack, starting at the first byte. The values are in str.cpp.
1
2
3
4
5
6
7
8
9
10
11
12
13
v2[0] = 0;
v2[1] = 13;
v2[2] = 51;
v2[3] = 28;
v2[4] = 16;
v2[5] = 98;
v2[6] = 120;
...
v2[26] = 50;
v2[27] = 28;
v0 = strlen(Str);
for ( i = 0; i < v0; ++i )
Str[i] ^= v2[i];
The second step is an affine cipher over letters, called with the multiplier 5 and the shift 8. Digits and other characters are left alone. To decode it, the program needs the modular inverse of 5 modulo 26, which check finds by brute force (it is 21). Each letter is then decoded as inverse * (letter - 8) mod 26, separately for upper and lower case.
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
int check(int a1, int a2){
int v4 = a1 % a2;
for (int i = 1; i < a2; ++i )
{
if ( i * v4 % a2 == 1 )
return i;
}
return -1;
}
int main(){
char s[] = "LnxISZ2OSsVBFtBx9sXOOJaueqjOMt";
int a2 = 5;
char flag[100];
int a3 = 8;
int cnt = strlen(s) + 1;
int x = 26;
int v3 = check(a2, x);
for( int i = 0; i < cnt; i++){
int v7 = s[i];
if ( v7 < 65 || v7 > 90 )
{
if ( v7 >= 97 && v7 <= 122 )
v7 = v3 * ((v7 - 97 - a3 + 26) % 26) % 26 + 97;
}
else
{
v7 = v3 * ((v7 - 65 - a3 + 26) % 26) % 26 + 65;
}
flag[i] = v7;
}
cout << flag;
return 0;
}
Here s is the output of the XOR step. The XOR script prints LnxISZ2OSsVBFtBx9sXOOJaueqjOMt for it.
Flag: ATTT{H4ppy_M1n35w33p3r_64m3}
Status: the flag is the one from my recorded flag.txt. When I re-ran both stages above with the constants from the binary, I got a different 30 character string, so I could not reproduce the recorded flag statically. I did not run the game, so I cannot say which step of my reading of the binary differs.
Rev - re2 (easyRE.exe)
Files: re2.zip
The challenge is a zip file that is protected with the password ATTT. Inside is easyRE.exe, a console program that asks for the flag. Instead of only saying right or wrong, it prints an array of 29 numbers after each try, one number per character of the input. The number is 0 for a position where the character is correct.
That leaks the flag one position at a time, so I did not need to reverse the checking code. The script runs the program for every printable character from 32 to 127, each time sending that character 29 times, and saves the printed array. Every position that comes back as 0 for a given guess has that guess as its flag character.
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
import subprocess
import re
flag =b"xxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
f = [None] * 29
file = open("save.txt", "w")
for i in range(32,128): # brute force (if array[i] == 0 ---> array[i] = 32-128)
payload = flag.replace(b"x", chr(i).encode("ascii"))
process = subprocess.Popen(["easyRE.exe"], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) # open easyRE
process.stdout.read(36) # read 36 bytes of stdout
process.stdin.write(payload + b"\r\n") # write the flag guess to stdin
process.stdin.flush()
process.stdout.read(7) # read 7 bytes of stdout to remove the color code "\x1b[35m"
char = process.stdout.read(1) # read 1 byte (array check number)
while(char!=b"]"): # compare with "]" to remove "\x1b[0m"
file.write(char.decode("ascii")) # write the array to the txt file
char = process.stdout.read(1)
file.write("\n")
file.close()
with open("save.txt", "r") as file: # read the saved file
cnt = 32
for line in file:
lines = line.strip().split() # split the elements
i = 0
for word in lines:
if(int(word) == 0): # element == 0 --> save to the array
f[i] = cnt
i+=1
cnt+=1
real_flag = ""
for i in f:
real_flag += chr(i)
print(f'ATTT{{{real_flag}}}')
The first line of save.txt (all guesses with a space) is all ones, and the next lines show the numbers changing with the guess, which is the side channel the script uses. After the full loop, every position has been filled.
Flag: ATTT{3X3_NHUN6_M4_14_6014N6_H1UH1U}
Status: I wrote this section from the solve script and the saved output. I did not run easyRE.exe here because it is a Windows binary.
Rev - cry1 (easygame.exe)
Files: cry1.zip
easygame.exe is a 32-bit Windows console program that prints Input flag: and then answers Correct! or Try again. The strings in the binary include 24062023 and NO_PAIN_NO_GAIN, and the PDB path says it is a project called xorxor. I solved it statically from the disassembly with objdump.
The main function does these steps on the input string.
- The first pass walks the input two characters at a time using the key
24062023, with the position taken modulo 8. For an even index the stored byte isinput - key + 0x30, and for an odd index it isinput - 0x30 + key. - The result is stretched by inserting the 15 character string
NO_PAIN_NO_GAINbefore every 9 characters of the data, at positions 0, 24, 48 and so on. - The final string is compared with a table of 143 integers stored in the binary. The check depends on the index modulo 4. For indexes 1 and 3 the integer is the character itself. For indexes 0 and 2 the integer is the octal digits of the character read as a decimal number, so the character
N(octal 116) is stored as 116.
The table starts with 116, 79, 137, 80, 101, 73, 116, 95, ..., which decodes to NO_PAIN_NO_GAIN, so the layout is easy to confirm. To solve it I decoded the table, removed the inserted NO_PAIN_NO_GAIN blocks to get 53 characters, and then undid the first pass.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ch = []
for i, x in enumerate(v): # v = the 143 integers from the binary
ch.append(int(str(x), 8) if i % 2 == 0 else x)
data = [c for i, c in enumerate(ch) if i % 24 >= 15] # drop the inserted NO_PAIN_NO_GAIN
key = b"24062023"
out = []
for i, t in enumerate(data):
if i % 2 == 0:
o = (t + key[i % 8] - 0x30) & 255
else:
o = (t + 0x30 - key[i % 8]) & 255
out.append(o)
print(bytes(out))
1
b'FLAG{r3v3r53_3n6in33rin6_15_n3ss3c4ry_70_kn0w_crypt0}'
The output starts with FLAG{ and ends with }, which confirms that the two steps are undone correctly. I did not run the program, since it is a Windows executable and I only used static tools.
Flag: FLAG{r3v3r53_3n6in33rin6_15_n3ss3c4ry_70_kn0w_crypt0}
Status: solved statically. I did not have a Windows machine to confirm that the program prints Correct! for this input.







