2 hommes réfléchissant devant des ordinateurs

Google CTF 2021 Tonality

A la Une25/02/2022

Par Florian Picca

Le but du challenge est de forger une signature ECDSA valide pour un message donné, avec la possibilité de demander une seule signature d'un autre message donné, mais la clé privée est mise à l'échelle par un facteur que nous contrôlons.

Détails
 

  • Catégorie : crypto
  • Points : 256
  • Résolutions : 30

Description
 

“In this challenge, you have access to a signing oracle. Good luck!”

nc tonality.2021.ctfcompetition.com 1337

Fichiers sources :

$ ls -l
total 52
-rw-r--r-- 1 enoent enoent 12310 nov.  30  1979 challenge_pb2.py
-rw-r--r-- 1 enoent enoent 18227 nov.  30  1979 challenge.pb.go
-rw-r--r-- 1 enoent enoent  1072 nov.  30  1979 challenge.proto
-rw-r--r-- 1 enoent enoent  2428 nov.  30  1979 challenger.go
-rw-r--r-- 1 enoent enoent  2169 nov.  30  1979 client_skel.py
-rw-r--r-- 1 enoent enoent  3587 nov.  30  1979 server.go

Comprendre le problème

Lorsque nous nous connectons au serveur, nous sommes accueillis par ce message :

$ nc tonality.2021.ctfcompetition.com 1337
== proof-of-work: disabled ==
l
Server says 1+1=2Server says 1+1=3�D
 dN<����UOB���q���Q&�ѕq �2��X�:<�;�nށ��Ŏm�_�v��zB���

Cela semble très étrange. Nous devrons examiner le code source fourni pour comprendre ce qui se passe.

Il y a beaucoup de fichiers sources pour un challenge de crypto. Certains d’entre eux sont écrits en Go (c’est le CTF de Google après tout) et d’autres en Python.

Tous ne sont pas intéressants, nous allons donc nous concentrer sur les principaux.

Le fichier server.go est le point d’entrée du serveur :

// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
    "crypto-tonality/challenger"
    "crypto-tonality/pb"
    "encoding/binary"
    "flag"
    "fmt"
    "io"
    "io/ioutil"
    "os"
    "strings"

    "github.com/golang/protobuf/proto"
)

var flagFile string

const maxMessageLengh = 10 * 1024 * 1024

func writeMessage(w io.Writer, m proto.Message) error {
    if err := binary.Write(w, binary.LittleEndian, uint32(proto.Size(m))); err != nil {
        return fmt.Errorf("failed to write message length, binary.Write() = %v, want nil err", err)
    }
    buf, err := proto.Marshal(m)
    if err != nil {
        return fmt.Errorf("failed to serialize message, proto.Marshal(%v) = %v, want nil err", m, err)
    }
    n, err := w.Write(buf)
    if n != len(buf) || err != nil {
        return fmt.Errorf("failed to write serialized message, w.Write(%X) = %v, %v, want n=%d and nil error", buf, n, err, len(buf))
    }
    return nil
}

func readMessage(r io.Reader, m proto.Message) error {
    var length uint32
    if err := binary.Read(r, binary.LittleEndian, &length); err != nil {
        return fmt.Errorf("failed to read length, binary.Read(buf) = %v, want nil error", err)
    }
    if length > maxMessageLengh {
        return fmt.Errorf("want legnth <= %d, got %d", maxMessageLengh, length)
    }
    buf := make([]byte, length)
    if n, err := r.Read(buf); n != len(buf) || err != nil {
        return fmt.Errorf("failed to read serialized message, r.Read(buf) = %v, %v, want n=%d and nil error", n, err, len(buf))
    }
    if err := proto.Unmarshal(buf, m); err != nil {
        return fmt.Errorf("failed to unmarshal message, proto.Unmarshal(%X, m) = %v, want nil error", buf, err)
    }
    return nil
}

func runSession(chal *challenger.Challenger, r io.Reader, w io.Writer) error {
    hello := chal.Hello(&pb.HelloRequest{})
    if err := writeMessage(w, hello); err != nil {
        return fmt.Errorf("writeMessage(w, hello=%X)=%v, want nil err", hello, err)
    }

    signReq := &pb.SignRequest{}
    if err := readMessage(r, signReq); err != nil {
        return fmt.Errorf("readMessage(r, signReq)=%v, want nil err", err)
    }

    signRes := chal.SignFirstMessage(signReq)
    if err := writeMessage(w, signRes); err != nil {
        return fmt.Errorf("writeMessage(w, signRes=%X)=%v, want nil err", signRes, err)
    }

    verifyReq := &pb.VerifyRequest{}
    if err := readMessage(r, verifyReq); err != nil {
        return fmt.Errorf("readMessage(r, verifyReq)=%v, want nil err", err)
    }

    verifyRes := chal.VerifySecondMessage(verifyReq)
    if err := writeMessage(w, verifyRes); err != nil {
        return fmt.Errorf("writeMessage(w, verifyRes=%X)=%v, want nil err", verifyRes, err)
    }
    return nil
}

func init() {
    flag.StringVar(&flagFile, "flag", "/flag", "flag filename")
}

func main() {
    defer func() {
        if r := recover(); r != nil {
            // Uncomment to catch panics during development.
            // panic(r)
        }
    }()

    flag.Parse()

    f, err := ioutil.ReadFile(flagFile)
    if err != nil {
        panic(err)
    }

    chal, err := challenger.NewChallenger(strings.TrimSpace(string(f)))
    if err != nil {
        panic(err)
    }

    err = runSession(chal, os.Stdin, os.Stdout)
    if err != nil {
        panic(err)
    }
}

Il fait appel aux fonctions définies dans challenger.go :

// Copyright 2021 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package challenger

import (
    "crypto-tonality/pb"
    "crypto/ecdsa"
    "crypto/elliptic"
    "crypto/rand"
    "crypto/sha1"
    "io"
    "math/big"
)

const m0 = "Server says 1+1=2"
const m1 = "Server says 1+1=3"

func hashMessage(m string) []byte {
    h := sha1.New()
    io.WriteString(h, m)
    return h.Sum(nil)
}

type Challenger struct {
    flag string
    sk   *ecdsa.PrivateKey
}

func NewChallenger(flag string) (*Challenger, error) {
    sk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
    if err != nil {
        return nil, err
    }
    return &Challenger{
        flag: flag,
        sk:   sk,
    }, nil
}

func (chal *Challenger) Hello(req *pb.HelloRequest) *pb.HelloResponse {
    return &pb.HelloResponse{
        Message0: m0,
        Message1: m1,
        Pubkey: &pb.Point{
            X: chal.sk.PublicKey.X.Bytes(),
            Y: chal.sk.PublicKey.Y.Bytes(),
        },
    }
}

func (chal *Challenger) scalePrivate(t *big.Int) *ecdsa.PrivateKey {
    rk := new(big.Int).Mul(chal.sk.D, t)
    rk.Mod(rk, chal.sk.PublicKey.Curve.Params().N)

    ret := new(ecdsa.PrivateKey)
    ret.PublicKey.Curve = chal.sk.PublicKey.Curve
    ret.D = rk
    ret.PublicKey.X, ret.PublicKey.Y = chal.sk.PublicKey.Curve.ScalarBaseMult(ret.D.Bytes())
    return ret
}

func (chal *Challenger) SignFirstMessage(req *pb.SignRequest) *pb.SignResponse {
    t := new(big.Int).SetBytes(req.Scalar)
    r, s, err := ecdsa.Sign(rand.Reader, chal.scalePrivate(t), hashMessage(m0))
    if err != nil {
        return &pb.SignResponse{}
    }
    return &pb.SignResponse{
        Message0Sig: &pb.Signature{
            R: r.Bytes(),
            S: s.Bytes(),
        },
    }
}

func (chal *Challenger) VerifySecondMessage(req *pb.VerifyRequest) *pb.VerifyResponse {
    r := new(big.Int).SetBytes(req.Message1Sig.R)
    s := new(big.Int).SetBytes(req.Message1Sig.S)

    ok := ecdsa.Verify(&chal.sk.PublicKey, hashMessage(m1), r, s)
    if !ok {
        return &pb.VerifyResponse{}
    }

    return &pb.VerifyResponse{Flag: chal.flag}
}

En gros, ce qu’il fait, c’est :

  1. Générer une clé privée sur la courbe elliptique P-256 d.
  2. Envoyer une requête Hello contenant
    • Deux messages fixes m0 et m1
    • La clé publique du serveur (x, y).
  3. Attendre une demande de signature contenant :
    • Un facteur d’échelle t
  4. Envoyer la signature (r0, s0) de m0 par la clé privée d*t’.
  5. Attendre une demande de vérification de signature contenant :
    • La signature à vérifier (r1, s1) de m1 par la clé privée d
  6. Vérifier que (r1, s1) est valide en utilisant sa clé publique (x, y).
  7. Si la signature est valide, envoyer le flag

Le serveur utilise protobuf pour envoyer les messages, ce qui explique la sortie étrange quand on se connecte directement en utilisant netcat.

Heureusement, un client Python est fourni dans client_skel.py :

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright 2021 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import pwnlib
import challenge_pb2
import struct
import sys

def handle_pow(tube):
  raise NotImplemented()

def read_message(tube, typ):
  n = struct.unpack('<L', tube.recvnb(4))[0]
  buf = tube.recvnb(n)
  msg = typ()
  msg.ParseFromString(buf)
  return msg

def write_message(tube, msg):
  buf = msg.SerializeToString()
  tube.send(struct.pack('<L', len(buf)))
  tube.send(buf)

def main():
  parser = argparse.ArgumentParser()
  parser.add_argument(
      '--port', metavar='P', type=int, default=1337, help='challenge #port')
  parser.add_argument(
      '--host', metavar='H', type=str, default='tonality.2021.ctfcompetition.com', help='challenge host')
  args = parser.parse_args()

  tube = pwnlib.tubes.remote.remote(args.host, args.port)
  print(tube.recvuntil('== proof-of-work: '))
  if tube.recvline().startswith(b'enabled'):
      handle_pow(tube)

  # Step 1: Hello.
  hello = read_message(tube, challenge_pb2.HelloResponse)
  print(hello)

  # Step 2: Sign.
  a = 1234
  sign_req = challenge_pb2.SignRequest()
  sign_req.scalar = a.to_bytes((a.bit_length() + 7) // 8, 'big')
  write_message(tube, sign_req)

  sign_res = read_message(tube, challenge_pb2.SignResponse)
  print(sign_res)

  # Step 3: Verify.
  verify_req = challenge_pb2.VerifyRequest()
  verify_req.message1_sig.r = b'\x11\x22'
  verify_req.message1_sig.s = b'\x33\x44'
  write_message(tube, verify_req)

  verify_res = read_message(tube, challenge_pb2.VerifyResponse)
  print(verify_res)
  return 0


if __name__ == '__main__':
  sys.exit(main())

Ce client envoie un facteur d’échelle t=1234 et ensuite envoie une demande de vérification factice.

Son exécution donne le résultat suivant :

$ python3 client_skel.py 
b'== proof-of-work: '
message0: "Server says 1+1=2"
message1: "Server says 1+1=3"
pubkey {
  x: "\325^\025\253[\323c(\020\260\017\216\221\332\305\375O\313\255K\252U\324\334\311\214\345\001\373\346\004\'"
  y: "k\214\363h\357\254\224f\271\026\222\204\"/\301\245\027~\205\242\213[\353\004\265#\007_\356bP\270"
}

message0_sig {
  r: "\206\254\371\260\212\204@\327JN\271\357\305\362O\3509\2177\032\234\371!\277\307\277$\251\310\n\244\336"
  s: "\230 B\356c%\256\335\232+}\346;\271S=\361\002#\243\326\236#\326\202J\210\262+y\210)"
}

Le but du challenge est de forger une signature valide pour m1 connaissant une signature de m0 calculée en utilisant la clé privée du serveur mise à l’échelle par un facteur t que nous contrôlons.

Résoudre le problème

D’après la définition de ECDSA, nous avons s0 = (r0*d*t + h0)*k^-1, avec h0 étant le hash SHA-1 de m0 et k le nonce aléatoire.

Voici l’astuce: si on définit h0 = t*h1, l’équation ci-dessus devient s0 = (r0*d*t + t*h1)*k^-1 = t*(r0*d + h1)*k^-1.

Encore une fois, d’après la définition de ECDSA, cela peut être réécrit comme s0 = t*s1, car (r0*d + h1)*k^-1 est la composante s de la signature de m1 par la clé d avec le même nonce k (r0 = (k*G).x).

Donc, pour forger une signature valide pour m1 connaissant (r0, s0), il suffit d’envoyer (r0, s0*t^-1), avec t = h0*h1^-1.

Implémentation de la solution

Nous devons d’abord calculer t et t^-1. Pour cela, nous devons travailler modulo l’ordre du générateur de la courbe P-256 :

import gmpy2
from Crypto.Hash import SHA1

# order of the generator of P-256
# https://neuromancer.sk/std/secg/secp256r1
n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551

m0 = b"Server says 1+1=2"
m1 = b"Server says 1+1=3"

h0 = int(SHA1.new(m0).hexdigest(), 16)
h1 = int(SHA1.new(m1).hexdigest(), 16)

t = int((h0 * gmpy2.invert(h1, n)) % n)
t_inv = int(gmpy2.invert(t, n))

print(f"{t     = }")
print(f"{t_inv = }")

Ce qui nous donne :

t     = 13325888469672337108611681934270644951924450129063937690100547119146820540863
t_inv = 79606180523211936313941056796648868354970640032974922105651641315498916037346

Maintenant nous pouvons modifier légèrement le client fourni pour envoyer notre facteur d’échelle t et demander de vérifier la signature forgée (r0, s0*t^-1) :

def main():
  #...
  # order of the generator of P-256
  # https://neuromancer.sk/std/secg/secp256r1
  n = 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551

  # Step 2: Sign.
  t = 13325888469672337108611681934270644951924450129063937690100547119146820540863
  sign_req = challenge_pb2.SignRequest()
  sign_req.scalar = t.to_bytes((t.bit_length() + 7) // 8, 'big')
  write_message(tube, sign_req)

  sign_res = read_message(tube, challenge_pb2.SignResponse)
  r = sign_res.message0_sig.r
  s = sign_res.message0_sig.s
  print(f"{sign_res.message0_sig.r.hex()=}")
  print(f"{sign_res.message0_sig.s.hex()=}")

  t_inv = 79606180523211936313941056796648868354970640032974922105651641315498916037346
  from Crypto.Util.number import bytes_to_long, long_to_bytes
  sn = (bytes_to_long(s)*t_inv) % n
  s = long_to_bytes(sn)

  # Step 3: Verify.
  verify_req = challenge_pb2.VerifyRequest()
  verify_req.message1_sig.r = r
  verify_req.message1_sig.s = s
  write_message(tube, verify_req)

  verify_res = read_message(tube, challenge_pb2.VerifyResponse)
  print(verify_res)

L’exécution du script ci-dessus nous donne le flag :

$ python3 client_skel.py 
b'== proof-of-work: '
hello.pubkey.x.hex()='ed7182459e761b4b9a3be2a8a403aed8afd2cd4c1caa76f49a0fe995102dfa15'
hello.pubkey.y.hex()='927afecd316004896fac6a544819a2bd283a6f3283538351a582e1c1792126c3'
sign_res.message0_sig.r.hex()='ff65a051354f432949455aab0f21fd76a1fece8a76c44e04456fc0a4d16ff278'
sign_res.message0_sig.s.hex()='028ae229b3b0ceee5ac6be2af978a16e88ff1235586a58b91af99f8d8194da5a'
flag: "CTF{TheySayTheEmptyCanRattlesTheMost}"

Flag : CTF{TheySayTheEmptyCanRattlesTheMost}

Réflexions postérieures 

Après la fin du CTF, les sources du challenge ont été publiées.

L’attaque est en fait une attaque par clé privée connexe, qui est décrite au chapitre 4.2 de cet article.

On ne sait toujours pas ce que le nom du challenge ou le contenu du flag sont censés signifier.

Nos autres actualités