tags: ctf,crypto


*CTF-Qualifier-2023/Crypto/gcccd writeup

本题某种意义上不完全是密码学,还有一些侧信道攻击。

是独立解的,无奈手太慢,比赛9点结束,12点才跑出来

1. challenge

Can you leak my secert using gcd?

nc 122.9.153.69 23333

#!/usr/bin/env python3

from secret import flag
import socketserver
import hashlib
import signal
import random
import string
import os

p=20973268502876719886012765513713011996343752519737224550553652605696573094756255499211333096502971357908939298357512380813773140436677393056575164230564778609423872301899323721040416852230597466288892977839300189625522429038289083381035647126860128821615664730513694930502000903655609105029016636999073477487851081722316115785141
enc=lambda x:pow(17,x,p)
m=int(flag.encode().hex(),16)

def gcd(a,b,f=enc):
    if b:
        return gcd(b,a%b,f)
    else:
        return f(a)

class Task(socketserver.BaseRequestHandler):
    def __init__(self, *args, **kargs):
        super().__init__(*args, **kargs)

    def timeout_handler(self, signum, frame):
        self.request.close()

    def proof_of_work(self):
        random.seed(os.urandom(8))
        proof = ''.join([random.choice(string.ascii_letters+string.digits) for _ in range(20)])
        _hexdigest = hashlib.sha256(proof.encode()).hexdigest()
        self.request.send(f"sha256(XXXX+{proof[4:]}) == {_hexdigest}\n".encode()+b'Give me XXXX: ')
        x = self.request.recv(1024).strip(b'\n')
        if len(x) != 4 or hashlib.sha256(x+proof[4:].encode()).hexdigest() != _hexdigest:
            return False
        return True

    def handle(self):
        signal.alarm(60)

        if not self.proof_of_work():
            return

        while True:
            try:
                self.request.send(b'type:')
                t=int(self.request.recv(1024).strip(b'\n'))
                self.request.send(b'a:')
                a=int(self.request.recv(1024).strip(b'\n'))
                self.request.send(b'b:')
                b=int(self.request.recv(1024).strip(b'\n'))
                assert a>0 and b>0
                if t==1:#enc test
                    self.request.send(b'%d\n'%gcd(a,b))
                elif t==2:#leak try1
                    self.request.send(b'%d\n'%gcd(a,m))
                elif t==3:#leak try2
                    self.request.send(b'%d\n'%gcd(a,b,f=lambda x:gcd(x,m)))
                elif t==4:#leak try3
                    self.request.send(b'%d\n'%gcd(a,m,f=lambda x:gcd(x,b)))
                else:
                    self.request.close()
                    break
            except BrokenPipeError:
                break
            except:
                self.request.send(b'Bad input!\n')


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

if __name__ == "__main__":
    HOST, PORT = '0.0.0.0', 23333
    server = ThreadedServer((HOST, PORT), Task)
    server.allow_reuse_address = True
    server.serve_forever()

2. 分析

允许选手控制a, b, 提供4个选项,返回

  • pow(17, gcd(a, b), p)
  • pow(17, gcd(a, m), p)
  • pow(17, gcd(gcd(a, b), m), p)
  • pow(17, gcd(gcd(a, m), b), p)

2.1 思路一:构造包含m的DLP

初步的思路是,构造包含m的DLP,例如,令a = 0, 则 gcd(a, m) = pow(17, m, p) 。 使问题转化为离散对数问题。

本题的GF(p)关于g元素的乘法阶是光滑的,易求离散对数问题

p=20973268502876719886012765513713011996343752519737224550553652605696573094756255499211333096502971357908939298357512380813773140436677393056575164230564778609423872301899323721040416852230597466288892977839300189625522429038289083381035647126860128821615664730513694930502000903655609105029016636999073477487851081722316115785141
F = GF(p)
g = F.multiplicative_generator()
order = g.multiplicative_order()
order == p-1, factor(order)
(False,
 5 * 7^2 * 11 * 13 * 29 * 31 * 37^4 * 41^2 * 43^3 * 61^2 * 67 * 71^3 * 73 * 79^2 * 83 * 89 * 97^3 * 101^4 * 103 * 107 * 109 * 113 * 127^2 * 131^2 * 137 * 139^5 * 151^2 * 157 * 163 * 167 * 173 * 179^2 * 181^3 * 191 * 193 * 197^3 * 199^2 * 211^2 * 223^3 * 227 * 229 * 239^2 * 251^2 * 257^3 * 263 * 269 * 271^5 * 281 * 283^2 * 293^2 * 307^2 * 311 * 317 * 331^3 * 337^3 * 347 * 353^2 * 359^5 * 367 * 389^2 * 397^3 * 409 * 421 * 431^3 * 433^2 * 439^2 * 443^2 * 457^2 * 461 * 467^2 * 479^4 * 487 * 499 * 503 * 509 * 521^3 * 541)

但本题限制了 a, b > 0 , 也未找到其他办法使 m 出现在方程中。

2.2 思路二:爆破m的素因子

考虑 gcd(a, m) 大部分情况都为 1, 在 a|m 时为 a, 可以根据这一特征对m因式分解。

但因为比赛时与远程的交互较慢,实际执行很困难。

实际仅爆破了第一个素因子 6079

2.3 提示:Why recursion?

赛程过半时,题目仍然0解,主办方公布了提示 “Why recursion?”

对啊,为什么gcd要使用递归呢?会导致什么问题?我考虑主要有:

  1. 时间复杂度
  2. 递归过多导致程序崩溃

2.4 思路三:gcd的时间复杂度问题

经过实验,我发现,两个数较为接近时,gcd的时间复杂度相对较高。

可以通过比较 gcd(a, b) 的时间复杂度和 gcd(gcd(a, b), m) 的时间复杂度的差,来计算 m 与某个数(gcd(a, b)的最大公约数,可控)的计算量,则可能可以恢复 m 。

但经过实验,发现主要的时间差异体现在网络波动上,无法体现计算量差异。

2.5 思路四:python的默认递归次数限制: 1000

使用递归的另一个缺点是,过多可能导致程序崩溃。

考虑,构造两个数a, b, 其计算 x = gcd(a, b) 的递归次数与 计算 gcd(x, m)的递归次数之和,刚好等于最大递归次数。于是得知,gcd(x, m) 需要递归的次数,这个特点可能恢复m

自己构造了一段时间,发现 $gcd(a, b),a,b < 2^1024$ 的递归次数一般在600左右徘徊,自己构造不出来1000左右的情况。

搜索了一下,发现 gcd 的最坏时间复杂度为 $log_2n$ , 在连续两个斐波那契数产生。

https://www.baeldung.com/cs/euclid-time-complexity

def gcd_count(a, b, count=0):
    if b:
        return gcd_count(b,a%b,count+1)
    else:
        return a, count
    
gcd_count(fibonacci(1000), fibonacci(1001))
(1, 1000)

实验还发现,两个连续的斐波那契数,他们的最大公约数为1, 递归次数为较小数在斐波那契数列中的位置。两个数同乘一个数,不影响gcd的递归次数

gcd_count(fibonacci(1000)*pow(2, 100), fibonacci(1001)*pow(2, 100))
(1267650600228229401496703205376, 1000)

而且,斐波那契数的长度很短,适合用于我的攻击思路

fibonacci(1000).nbits()
694

于是,可以:

  1. 控制: x = gcd(a, b) 的递归次数
  2. 通过程序崩溃时x = gcd(a, b) 的递归次数, 获得: gcd(x, m) 的递归次数

2.6 如何根据 gcd(x, m) 的递归次数 恢复m?

实验 gcd(prime, m) 的递归次数,过程中发现:

  1. gcd(prime, m) = 0 的递归次数,必然为3次
  2. gcd(prime, m) = 1 的递归次数,必然为4次
  3. 其余情况,可能存在重复,不同递归次数可能对应多种模方程

于是,可以考虑,遍历 gcd(prime, m) 的递归次数,根据上述特点,计算多组同余式,根据中国剩余定理求解m

m % 2 = 0: 2次递归

  • gcd(2, m)
  • 1 (m, 2)
  • 2 (2, 0)
  • -> 2

m % 2 = 1: 3次递归

  • gcd(2, m)
  • 1 (m, 2)
  • 2 (2, 1)
  • 3 (1, 0)
  • -> 1

m % 5 = 0: 2次递归

  • gcd(5, m)
  • 1 (m, 5)
  • 2 (5, 0)
  • -> 0

m % 5 = 1: 3次递归

  • gcd(5, m)
  • 1 (m, 5)
  • 2 (5, 1)
  • 3 (1, 0)
  • -> 1

m % 5 = 2: 4次递归

  • gcd(5, m)
  • 1 (m, 5)
  • 2 (5, 2)
  • 3 (2, 1)
  • 4 (1, 0)
  • -> 1

m % 5 = 3: 5次递归

  • gcd(5, m)
  • 1 (m, 5)
  • 2 (5, 3)
  • 3 (3, 2)
  • 4 (2, 1)
  • 5 (1, 0)
  • -> 1

m % 5 = 4: 4次递归

  • gcd(5, m)
  • 1 (m, 5)
  • 2 (5, 4)
  • 3 (4, 1)
  • 4 (1, 0)
  • -> 1

3. solve

确认使用思路四:python的默认递归次数限制: 1000

3.1 IO

服务器环境经常断连,断了之后可重新建立连接。

import itertools
from pwn import *
context.log_level = 'info'
r = None
local = False

def init_connect(local):
    if local:
        r = remote("127.0.0.1", 2333)
    else:
        r = remote("122.9.153.69", 23333)
    return r
    
def proof_of_work():
    print("pow doing.")

    def receive_proof_of_work():
        r.recvuntil(b"XXXX+")
        p = r.recvuntil(b")").rstrip(b")")
        r.recvuntil(b"== ")
        h = r.recvuntil(b'\n').rstrip(b'\n')
        return p.decode(), h.decode()

    def solve_proof_of_work(p, h):
        for c in itertools.product(string.ascii_letters+string.digits, repeat=4):
            proof = "".join(c) + p
            if hashlib.sha256(proof.encode()).hexdigest() == h:
                return "".join(c)
    p, h = receive_proof_of_work()
    proof = solve_proof_of_work(p, h)
    r.sendlineafter(b'Give me XXXX: ', proof.encode())
    print("pow done.")

def enc_io(t, a, b):
    global r
    if r is None:
        r = init_connect(local)
        if not local:
            proof_of_work()
    try:
        r.recvuntil(b"type:")
        r.sendline(str(t).encode())
        r.recvuntil(b"a:")
        r.sendline(str(a).encode())
        r.recvuntil(b"b:")
        r.sendline(str(b).encode())
        return int(r.recvuntil(b'\n').rstrip(b'\n'))
    except EOFError as err:
        r = None
        return enc_io(t, a, b)
    
def test(a, b):
    return enc_io(1, a, b)

def try1(a):
    """
    gcd(a,m)
    """
    return enc_io(2, a, 1) 

def try2(a, b):
    """
    gcd(a,b,f=lambda x:gcd(x,m))
    """
    return enc_io(3, a, b) 

def try3(a, b):
    """
    gcd(a,m,f=lambda x:gcd(x,b))
    """
    return enc_io(4, a, b) 

3.2 oracle: 触发递归崩溃

def oracle(t, a, b, debug=False, msg=''):    
    try:
        enc_io(t, a, b)
    except ValueError as err:
        if debug:
            print(err, msg)
        return True
    return False

oracle(1, fibonacci(1024), fibonacci(1024+1), True)
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
invalid literal for int() with base 10: b'Bad input!' 





True

3.3 获得递归次数

fibs = {}
def fib(i):
    if i not in fibs:
        fibs[i] = fibonacci(i) 
    return fibs[i]

def guess(a, base=900):
    """
    return remote gcd iter count
    """
    for i in range(base, 1000):
        if oracle(3, fib(i)*a, fib(i+1)*a):
            return i

count_cache = {}

def clean_cache():
    global count_cache
    count_cache = {}

def gcd_count_cache(a, b, count=0):
    """
    return gcd(a, b), itercount
    """
    if (a, b) in count_cache:
        g, c = count_cache[(a, b)]
        return g, count + c
    if b:
        return gcd_count(b,a%b,count+1)
    else:
        return a, count
    
def gcd_counts_remainders(b):
    """
    base = gcd(1, b)
    return {itercount - base: remainders}
    """
#     _, base = gcd_count_cache(b, 1)
    counts = {}
    for i in range(b):
        g, count = gcd_count_cache(b, b+i)
        count_cache[(b+i, b)] = (g, count)
#         count -= base
        if count not in counts:
            counts[count] = []
        counts[count].append(i)
    return counts        

gcd(1, m) + gcd(fib(985),fib(986)) 时崩溃, 作为计算递归次数的基准

remote_base = guess(1); remote_base
985

gcd_counts_remainders返回不同递归次数,对应的可能同余值。

counts_remainders3 = gcd_counts_remainders(3); counts_remainders3
{1: [0], 3: [1], 4: [2]}

gcd(3, m) 在服务器递归了 2(remote_base中相同步骤)+2 次,对应的可能同余值为 2

remote_count3 = guess(3, remote_base-max(counts_remainders3))
remote_count3 = remote_base - remote_count3 + 2; remote_count3
4
remote_remainder3 = counts_remainders3[remote_count3]; remote_remainder3
[2]

gcd(5, m) 在服务器递归了 3 次,对应的可能同余值为 1

counts_remainders5 = gcd_counts_remainders(5)
remote_count5 = guess(5, remote_base-max(counts_remainders5))
remote_count5 = remote_base - remote_count5 + 2
remote_remainder5 = counts_remainders5[remote_count5]

counts_remainders5, remote_count5, remote_remainder5
({1: [0], 3: [1], 4: [2, 4], 5: [3]}, 3, [1])

gcd(7, m) 在服务器递归了 4 次,对应的可能同余值为 2, 3, 6

counts_remainders7 = gcd_counts_remainders(7)
remote_count7 = guess(7, remote_base-max(counts_remainders7))
remote_count7 = remote_base - remote_count7 + 2
remote_remainder7 = counts_remainders7[remote_count7]

counts_remainders7, remote_count7, remote_remainder7
({1: [0], 3: [1], 4: [2, 3, 6], 5: [4, 5]}, 4, [2, 3, 6])

3.4 约束可能同余值

同余值不唯一,将导致使用中国剩余定理时要排列组合过多可能。

已知 gcd(2, m) = 1, 能否根据前面已知的同余方程,验证有效解?

考虑:

$m \equiv crt \pmod {\Pi p}$

gcd(pi, m):

  • gcd($\Pi p$ , m)
  • 1 (m, $\Pi p$)
  • 2 ($\Pi p$, crt)

有:

gcd(pi, m) = gcd(pi, crt) + 2

def check_valid(remainders, primes, debug=False):
    assert len(remainders) > 1 and len(primes) > 1
#     if len(remainders) > 20:
#         remainders = remainders[-20:]
#         primes = primes[-20:]
    c = crt(remainders, primes)
    pi = product(primes)
    _, local_count = gcd_count_cache(pi, c)
    remote_count = guess(pi, remote_base-local_count-2)
    remote_count = remote_base - remote_count + 2
    if debug:
        print(primes[-1], remainders[-1], remote_count, local_count, remote_count == local_count + 2)
    return remote_count == local_count + 2

for remainder in [2, 3, 6]:
    check_valid([1, 2, 1, remainder], [2, 3, 5, 7], True)
7 2 7 3 False
7 3 8 6 True
7 6 7 3 False

对素数的排列组合都进行这种验证,可以尽可能地约束同余式。

但是这里有一个问题:

参与计算的同余式数量不能太多,否则上面的结论不再成立。具体原因我也没弄明白,可能是 crt, 素数乘积不能太大,可能当这个数较大时,影响了gcd的递归次数?

另外,减少参与计算的同余式数量,也是减少计算复杂度的需要。在同余式过多时,这个函数的计算量将非常大。

def check_valid_brute(remainders, primes, debug=False):
    if len(remainders) > 5:
        remainders = remainders[:4] + remainders[-1:]
        primes = primes[:4] + primes[-1:]
    new_remainders = [remainders[-1]]
    new_primes = [primes[-1]]
    for remainder, prime in zip(remainders[:-1], primes[:-1]):
        new_remainders.append(remainder)
        new_primes.append(prime)
        if not check_valid(new_remainders, new_primes, debug):
            return False
    return True

for remainder in [2, 3, 6]:
    check_valid_brute([1, 2, 1, remainder], [2, 3, 5, 7], True)
2 1 5 4 False
2 1 5 3 True
3 2 5 3 True
5 1 8 6 True
2 1 5 2 False

3.5 遍历 m 的同余式

local=False
r.close()
r=init_connect(local)
clean_cache()
remote_base = guess(1)
[*] Closed connection to 122.9.153.69 port 23333
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
def generate_candidate_primes():
    candidate_primes = []
    pi = 1
    prime = 2
    while pi.nbits() < 250:
        candidate_primes.append(prime)
        prime = prime.next_prime()
        pi *= prime
    return candidate_primes
from tqdm.notebook import trange

pi = 1
prime = 2
remainders = []
primes = []

candidate_primes = generate_candidate_primes()
for _ in trange(len(candidate_primes)):
    if pi.nbits() > 250:
        break
    print("bits:", pi.nbits())
    counts_remainders = gcd_counts_remainders(prime)
    remote_count = guess(prime, remote_base-max(counts_remainders))
    remote_count = remote_base - remote_count + 2
    remote_remainders = counts_remainders[remote_count]
    valid_remainders = []
    if len(remote_remainders) == 1:
        valid_remainders = remote_remainders
    else:
        for remainder in remote_remainders:
            if check_valid_brute(remainders + [remainder], primes + [prime], False):
                valid_remainders.append(remainder)
    if len(valid_remainders) == 1:
        print("m %", prime, "=", valid_remainders[0])
        remainders.append(valid_remainders[0])
        primes.append(prime)
        pi *= prime
    else:
        print(prime, valid_remainders)
            
    prime = prime.next_prime() 

  0%|          | 0/43 [00:00<?, ?it/s]


bits: 1
m % 2 = 1
bits: 2
m % 3 = 2
bits: 3
m % 5 = 1
bits: 5
m % 7 = 3
bits: 8
m % 11 = 1
bits: 12
m % 13 = 4
bits: 15
m % 17 = 8
bits: 19
m % 19 = 6
bits: 24
m % 23 = 19
bits: 28
m % 29 = 23
bits: 33
m % 31 = 14
bits: 38
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 37 = 4
bits: 43
m % 41 = 39
bits: 49
m % 43 = 13
bits: 54
m % 47 = 33
bits: 60
m % 53 = 30
bits: 65
m % 59 = 54
bits: 71
m % 61 = 7
bits: 77
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 67 = 63
bits: 83
m % 71 = 38
bits: 89
m % 73 = 31
bits: 96
m % 79 = 6
bits: 102
m % 83 = 3
bits: 108
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 89 = 1
bits: 115
m % 97 = 5
bits: 121
m % 101 = 6
bits: 128
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 103 = 7
bits: 135
m % 107 = 2
bits: 141
m % 109 = 4
bits: 148
m % 113 = 32
bits: 155
m % 127 = 22
bits: 162
m % 131 = 57
bits: 169
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 137 = 54
bits: 176
m % 139 = 64
bits: 183
m % 149 = 101
bits: 190
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 151 = 71
bits: 198
m % 157 = 124
bits: 205
m % 163 = 119
bits: 212
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 167 = 7
bits: 220
m % 173 = 45
bits: 227
[x] Opening connection to 122.9.153.69 on port 23333
[x] Opening connection to 122.9.153.69 on port 23333: Trying 122.9.153.69
[+] Opening connection to 122.9.153.69 on port 23333: Done
pow doing.
pow done.
m % 179 = 85
bits: 235
m % 181 = 172
bits: 242
m % 191 = 20

3.6 中国剩余定理还原flag

from Crypto.Util.number import long_to_bytes

long_to_bytes(crt(remainders,primes))
b'*CTF{Y0U_ar3_gO.d_@_gCD_1EAk!!}'

4个选项只用到了第三个,不确定是不是我遗漏了什么