Fast Carmichael - HTB crypto challenge

Summary

Figure out what server.py is doing --> discover that it uses the miller-rabin primality test --> exploit said test with a carmichael number --> flag

This challenge comes from HacktheBox and is rated as very easy:

Challenge banner

Taking a look at server.py we can see some rather complex math being done to our input:

from secret import FLAG
from Crypto.Util.number import isPrime
import socketserver
import signal


class Handler(socketserver.BaseRequestHandler):

    def handle(self):
        signal.alarm(0)
        main(self.request)


class ReusableTCPServer(socketserver.ForkingMixIn, socketserver.TCPServer):
    pass


def sendMessage(s, msg):
    s.send(msg.encode())


def receiveMessage(s, msg):
    sendMessage(s, msg)
    return s.recv(4096).decode().strip()


def generate_basis(n):
    basis = [True] * n

    for i in range(3, int(n**0.5) + 1, 2):
        if basis[i]:
            basis[i * i::2 * i] = [False] * ((n - i * i - 1) // (2 * i) + 1)

    return [2] + [i for i in range(3, n, 2) if basis[i]]


def millerRabin(n, b):
    basis = generate_basis(300)
    if n == 2 or n == 3:
        return True

    if n % 2 == 0:
        return False

    r, s = 0, n - 1
    while s % 2 == 0:
        r += 1
        s //= 2
    for b in basis:
        x = pow(b, s, n)
        if x == 1 or x == n - 1:
            continue
        for _ in range(r - 1):
            x = pow(x, 2, n)
            if x == n - 1:
                break
        else:
            return False
    return True


def _isPrime(p):
    if p < 1:
        return False
    if not millerRabin(p, 300):
        return False

    return True


def main(s):
    p = receiveMessage(s, "Give p: ")

    try:
        p = int(p)
    except:
        sendMessage(s, "Error!")

    if _isPrime(p) and not isPrime(p):
        sendMessage(s, FLAG)
    else:
        sendMessage(s, "Conditions not satisfied!")


if __name__ == '__main__':
    socketserver.TCPServer.allow_reuse_address = True
    server = ReusableTCPServer(("0.0.0.0", 1337), Handler)
    server.serve_forever()

For starters, i’d rather not try and decode this cryptic source by merely looking at it. No, for this we need a more heuristic approach. Taking a look at the function names we can spot millerRabin, and the program also asks us to enter p, which is a common name used for an expression when working with modular arithmetic.

Solution

So as one would, I googled what this millerRabin could be, and as it turns out, it’s a primality test. It basically checks if numbers are prime, but the wikipedia article on it notes something interesting:

No composite number is a strong pseudoprime to all bases at the same time (contrary to the Fermat primality test for which Fermat pseudoprimes to all bases exist: the Carmichael numbers). However no simple way of finding a witness is known. A naïve solution is to try all possible bases, which yields an inefficient deterministic algorithm.

Here a light bulb went off when seeing carmichael, so i googled that next and found another wikipedia article:

Arnault gives a 397-digit Carmichael number N that is a strong pseudoprime to all prime bases less than 307:

N = p ⋅ ( 313 ( p − 1 ) + 1 ) ⋅ ( 353 ( p − 1 ) + 1 ) 

where

p = 29674495668685510550154174642905332730771991799853043350995075531276838753171770199594238596428121188033664754218345562493168782883

is a 131-digit prime. p is the smallest prime factor of N , so this Carmichael number is also a (not necessarily strong) pseudoprime to all bases less than p .

So far, what im getting from this is that we can bypass the primality test with a ridiculously high number derived from this formula. So lets try it:

from pwn import remote, sys, context, log, sleep
context.log_level = "info"

if len(sys.argv) == 1:
    print("Usage: solve.py HOST:PORT")
    exit(-1)

host, port = sys.argv[1].split(":")
c = remote(host, port)
c.recv()

p = 29674495668685510550154174642905332730771991799853043350995075531276838753171770199594238596428121188033664754218345562493168782883

N = str((p * (313 * (p - 1) + 1) * (353 * (p - 1) + 1))).encode()

log.info(f"Sending payload(??): {N}")

c.sendline(N)
sleep(1)
flag = c.recv().decode()
log.success(f"Flag returned: {flag}")

And it worked:

script being ran