zer0pts CTF 2021 | signme

#zer0ptsCTF2021

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "signme.h"

void authenticated(void) {
  puts("Thank you for signing my message!");
  system("/bin/sh");
}

void fatal(const char *msg) {
  fputs(msg, stderr);
  exit(1);
}

/**
 * Pad message with PKCS1 v1.5
 */
char* pad_pkcs1_v15(char *msg, int s, int n) {
  mpz_t r;
  char *padded;
  int i, rlen = n - s - 3;

  /* Initialize */
  mpz_init(r);
  padded = (char*)malloc(n);
  memcpy(&padded[3 + rlen], msg, s);

  /* Generate PS */
  msg = (char*)realloc(msg, rlen);
  for(i = 0; i < rlen; i++) {
    do {
      mpz_urandomb(r, rstate, 8);
    } while(mpz_cmp_ui(r, 0) == 0);
    msg[i] = mpz_get_ui(r);
  }

  /* PKCS thing */
  padded[rlen + 2] = '\x00';
  padded[0] = '\x00';
  padded[1] = '\x02';
  memcpy(&padded[2], msg, rlen);

  mpz_clear(r);
  return padded;
}

/**
 * Read message
 */
void get_message(mpz_t m) {
  char *padded, *msg;
  size_t s;

  /* Read message */
  msg = (char*)malloc(SECURITY_PARAMETER / 8);
  
  printf("Message: ");
  if ((s = read(0, msg, SECURITY_PARAMETER / 8)) <= 0)
    fatal("I/O Error\n");
  if (msg[s-1] == '\n') msg[--s] = '\0';

  /* Pad message */
  padded = pad_pkcs1_v15(msg, s, SECURITY_PARAMETER / 8);

  /* Bytes to integer */
  mpz_import(m, 1, 1, SECURITY_PARAMETER / 8, 1, 0, padded);
  free(padded);
  free(msg);
}

/**
 * Entry Point
 */
int main(void) {
  mpz_t m, s;
  PrivateKey priv;
  PublicKey pub;

  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  alarm(60);

  /* Step 0. Key generation */
  generate_keypair(&pub, &priv);
  mpz_inits(s, m, NULL);

  /* Step 1. I'll sign your message */
  get_message(m);
  gmp_printf("m = %Zx\n", m);
  generate_signature(s, m, &priv);
  gmp_printf("pubkey = (%Zx, %Zx)\n", pub.n, pub.e);
  gmp_printf("signature = %Zx\n", s);

  /* Step 2. You'll sign my message */
  mpz_urandomb(m, rstate, SECURITY_PARAMETER);
  gmp_printf("Sign this message: %Zx\n", m);
  printf("Signature: ");
  if (gmp_scanf("%Zx", s) != 1) fatal("I/O Error\n");

  /* Step 3. I'll validate your signature */
  if (validate_signature(s, m, &pub) == 0) {
    authenticated();
  } else {
    puts("Nope.");
  }

  return 0;
}

gmpを用いてRSA-CRTによる署名と検証が実装されている

* mを送る

* pad(m)^d \mod n が送られてくる

* m'が送られてくる

* xを送る

* x^e \equiv m' \mod n、すなわち x \equiv m'^d \mod nならシェルがもらえる

通常、秘密鍵がなければメッセージへの署名は行えず、また変な mを送ろうにもPKCS1 v1.5できちんとパディングが行われている。

そこでpwnというタグを頼りになにかおかしいところが無いかを探す。すると mにパディングを付与する部分に気になる処理がある

/**
 * Pad message with PKCS1 v1.5
 */
char* pad_pkcs1_v15(char *msg, int s, int n) {
  mpz_t r;
  char *padded;
  int i, rlen = n - s - 3;

  /* Initialize */
  mpz_init(r);
  padded = (char*)malloc(n);
  memcpy(&padded[3 + rlen], msg, s);

  /* Generate PS */
  msg = (char*)realloc(msg, rlen);
  for(i = 0; i < rlen; i++) {
    ...

ここでrealloc(msg, rlen) としているが、rlen n - s - 3となっていて、 nは128で固定、 sは入力文字数なので、 s = 125となるようにメッセージを送ることで rlen = 0とすることができる。reallocのサイズ部分に0を渡したときの動作は処理系に依存するが、glibcの実装ではfree相当の動作になるから、意図しないfreeを起こすことができる。ここで意図しないfreeを起こしたmsgget_message関数の末尾で次のようにもう一度freeされる

void get_message(mpz_t m) {
  char *padded, *msg;
  size_t s;

  /* Read message */
  msg = (char*)malloc(SECURITY_PARAMETER / 8);
  
  printf("Message: ");
  if ((s = read(0, msg, SECURITY_PARAMETER / 8)) <= 0)
    fatal("I/O Error\n");
  if (msg[s-1] == '\n') msg[--s] = '\0';

  /* Pad message */
  padded = pad_pkcs1_v15(msg, s, SECURITY_PARAMETER / 8);

  /* Bytes to integer */
  mpz_import(m, 1, 1, SECURITY_PARAMETER / 8, 1, 0, padded);
  free(padded);
  free(msg);
}

これで128バイトの領域をdouble freeすることができた。そしてその後処理を追っていくと、この領域はgenerate_signature関数のなかで確保されている。ちょうど同じサイズの領域を確保しているので、spqiは同じ領域を共有している

void generate_signature(mpz_t sign, mpz_t m, PrivateKey *priv) {
  mpz_t sp, sq, qi;
  mpz_init2(sp, SECURITY_PARAMETER); // same
  mpz_init2(sq, SECURITY_PARAMETER);
  mpz_init2(qi, SECURITY_PARAMETER); // same
  ...

すると、RSA-CRTの署名処理が

 i_q = q \mod p

 S_p = m^{d_p} \mod p

 S_q = m^{d_q} \mod q

 S = S_q + q(i_q(Sp − Sq) \mod p)

だったものが実質↓のようになる。

 S = S_q + q(i_q(i_q − Sq) \mod p)

このときに何が起こるかは https://www.cryptologie.net/article/371/fault-attacks-on-rsas-signatures/ にわかりやすく解説されている。

そもそも↑の式は m^{d_p} \mod p, m^{d_q} \mod qを中国剰余定理(正確にはGarner's algorithm)で m^d \mod pqにまとめているので、 s^e \equiv m \mod p, s^e \equiv m \mod qが成り立つが、 S_pが壊れたときの署名 \tilde{s}については \tilde{s}^e \equiv m \mod qだが \tilde{s}^e \not\equiv m \mod pとなる。すなわち、 \tilde{s}^e - m qを因数に持つが pは因数に持たない。したがって、 GCD(\tilde{s}^e - m, N) = qがなりたち、素因数分解ができてしまう。これをBellcore Attackという。

というわけで実装する

from ptrlib import Socket, Process
from math import gcd

# sock = Process("./chall")
sock = Socket("localhost", 10298)
sock.sendlineafter("Message: ", "A" * 125)
m = int(sock.recvlineafter("m = "), 16)
N = int(sock.recvregex(r"\(([0-9a-f]+), 10001\)")[0], 16)
sig = int(sock.recvlineafter("signature = "), 16)
m2 = int(sock.recvlineafter("message: "), 16)

e = 65537
p = gcd(pow(sig, e, N) - m, N)
q = N // p
assert p != 1 and p != N

d = pow(e, -1, (p-1)*(q-1))
s_p = pow(m2, d, p)
s_q = pow(m2, d, q)
q_i = pow(q, -1, p)
sign2 = (s_q + q * ( q_i * (s_p - s_q) % p )) % N

sock.sendlineafter("Signature: ", hex(sign2)[2:])
sock.interactive()