Voici la quatrième partie du récit de la piste des cartes à puce au NSEC 2013. Vous pouvez consulter les trois premières parties: partie 1, partie 2 et partie 3.
J’avais maintenant 3 des 5 composantes inconnues nécessaires pour créer les clés de chiffrement mentionnées sur la page web des cartes à puce. Il me restait à trouver les Ben Pyr app data et les journaux d’accès xorés.
J’ai décidé de m’attaquer aux Ben Pyr app data, quoi que ce soit. La troisième appliquette de la carte était l’appliquette Ben Pyr; le texte disait qu’elle était là pour valider que la carte appartenait bien à l’entreprise, ou quelque chose du genre. Pour ce faire, elle chiffrait des données secrètes avec une clé secrète et on pouvait valider le tout en lui renvoyant le cryptogramme.
Cette appliquette avait deux fonctions: ENCRYPT (qui ne prenait aucun octet en entrée, comme l’indiquait l’en-tête HEXADÉCIMAL affiché à côté de la commande) et DECRYPT (qui exigeait 16 octets de données). J’ai d’abord lancé ENCRYPT et reçu, sans erreur, un cryptogramme de 16 octets. J’ai ensuite lancé DECRYPT avec ce cryptogramme et reçu, sans erreur… rien d’autre. D’habitude, quand on déchiffre, le but est de récupérer quelque chose; ici, le but était simplement de valider que les données envoyées étaient bonnes.
À ce stade, je savais qu’il faudrait jouer avec la fonction DECRYPT, la seule à accepter une entrée. J’en ai parlé à Daniel Boteanu, notre expert de ce genre d’attaque. Après lui avoir décrit ce que j’avais, nous avons décidé de modifier le dernier octet de l’entrée pour nous assurer d’obtenir une erreur, et nous l’avons obtenue. Daniel m’a ensuite dit de modifier le premier octet de l’entrée en laissant le reste du cryptogramme intact. Cette fois, aucune erreur. Puisqu’on pouvait modifier le cryptogramme sans provoquer d’erreur, l’idée nous est venue que l’application ne vérifiait que la validité du remplissage (padding).
Daniel m’a alors dit que ça ressemblait à une attaque par oracle de remplissage PKCS5. J’ai aussitôt cherché dans Google et trouvé un billet de blogue qui l’expliquait bien: https://www.skullsecurity.org.
Cette attaque repose sur le fonctionnement du remplissage PKCS5. Avec PKCS5, si le dernier bloc contient 7 octets, on ajoute un seul octet de valeur 1. S’il en contient 6, on ajoute 2 octets de valeur 2, et ainsi de suite. Si le dernier bloc contient 8 octets, on ajoute un bloc complet de 8 octets de valeur 8. Au déchiffrement, on peut donc toujours lire le dernier octet du texte clair et retirer le nombre d’octets qu’il indique. On récupère ainsi un message de la même longueur que celui qui a été chiffré.
L’attaque repose aussi sur le fonctionnement du mode d’enchaînement CBC. Au déchiffrement en mode CBC, on déchiffre le cryptogramme avec la clé, puis on fait un XOR du résultat avec le cryptogramme du bloc précédent pour obtenir le texte clair (voir cette page Wikipédia pour plus de détails: https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation).
La clé est toujours secrète, alors normalement on ne peut jamais faire l’étape de déchiffrement correctement et on obtient du bruit en guise de texte clair. C’est là que l’oracle entre en jeu. L’oracle est un programme qui déchiffre le cryptogramme avec la bonne clé et qui vous dit si le remplissage est correct. On voit tout de suite que l’oracle fait le travail difficile à notre place en déchiffrant le cryptogramme. Regardons ça de plus près. Au déchiffrement du dernier bloc, l’oracle déchiffre le texte avec la bonne clé, fait un XOR avec le cryptogramme du bloc précédent, puis vérifie si le remplissage est correct.
Maintenant, si nous envoyons deux blocs de cryptogramme à l’oracle, le premier ne contenant que des zéros et le second étant le véritable dernier bloc de cryptogramme, l’oracle déchiffre le premier bloc (les zéros) et obtient du bruit, puis déchiffre le second bloc (à ce moment, le résultat est le texte clair XORÉ avec le véritable bloc de cryptogramme précédent), puis fait un XOR avec le bloc précédent que nous lui avons envoyé (les zéros). Il vérifie ensuite le remplissage.
On voit que l’oracle fait ceci:
La combinaison de remplissage la plus simple à réussir est un seul octet de valeur 1 à la fin. On incrémente donc le dernier octet de notre faux bloc de cryptogramme précédent jusqu’à ce que l’oracle nous dise que le remplissage est correct. Quand ça arrive, on connaît le dernier octet de notre faux cryptogramme, on connaît le dernier octet du véritable cryptogramme du bloc précédent (nous avons tout le cryptogramme) et on sait que le XOR de ces 2 valeurs avec le texte clair vaut 1. On obtient donc le dernier octet du texte clair en faisant le XOR de 1 avec les deux cryptogrammes!
Une fois le dernier octet du texte clair connu, on peut régler le dernier octet de notre faux cryptogramme pour qu’il donne 2, puis incrémenter l’avant-dernier octet de notre faux cryptogramme jusqu’à ce que l’oracle nous dise de nouveau que c’est bon. On peut ainsi récupérer tous les octets du texte clair, un à un!
Il reste une subtilité: que faire du premier bloc, puisqu’on ne connaît pas le VI (l’équivalent du bloc de cryptogramme précédent). Dans ce cas-ci, j’ai supposé que le VI n’était que des zéros, et c’était juste.
J’ai codé tout cet algorithme en Java en utilisant la fonction DECRYPT de la carte à puce comme oracle et le résultat de la fonction ENCRYPT comme cryptogramme valide.
Voici le code tel qu’il était à la fin de l’attaque:
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.List;
import javax.smartcardio.Card;
import javax.smartcardio.CardChannel;
import javax.smartcardio.CardException;
import javax.smartcardio.CardTerminal;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import javax.smartcardio.TerminalFactory;
public class Oracle {
static byte[] selectApdu = new byte[] {0x00, (byte) 0xA4, 0x04, 0x00, 0x07, 0x42, 0x45, 0x4E, 0x50, 0x59, 0x52, 0x31};
static byte[] decryptApdu = new byte[] {0x00, (byte) 0x88, 0x02, 0x00, 0x10};
static byte[] iv = new byte[8];
static byte[] cipherBlock1 = {(byte) 0xD5, (byte) 0xE1, 0x2B, 0x2B, (byte) 0x8B, 0x2D, 0x79, 0x25};
static byte[] cipherBlock2 = {0x01, (byte) 0xBC, 0x34, 0x37, 0x68, 0x10, (byte) 0xD4, (byte) 0xBB};
static byte[] plain2 = new byte[] {0x72, 0x53, 0x75, 0x63, 0x6B, 0x73, 0x02, 0x02};
static String value = "4F6E696F6E4F7461725375636B73";
static String converted = "OnionOtarSucks";
public static void main(String[] args) throws Exception {
// show the list of available terminals
javax.smartcardio.TerminalFactory factory = TerminalFactory.getDefault();
List<CardTerminal> terminals = factory.terminals().list();
System.out.println("Terminals: " + terminals);
// get the first terminal
CardTerminal terminal = terminals.get(0);
// establish a connection with the card
Card card = terminal.connect("T=1");
System.out.println("card: " + card);
CardChannel channel = card.getBasicChannel();
ResponseAPDU r = channel.transmit(new CommandAPDU(selectApdu));
System.out.println("response: " + bytesToHex(r.getBytes()));
byte[] newCipher = new byte[8];
byte[] plainText = new byte[8];
for(int k = 1; k <= 8; ++k) {
boolean success = false;
for(int i = 1; i < k; ++i) {
// We correct the padding for all lower bytes
newCipher[8-i] = (byte)(k ^ plainText[8-i] ^ iv[8-i]);
}
while(!success) {
success = tryDecrypt(channel, cipherBlock1, newCipher);
if(!success) {
newCipher[8-k]++;
}
}
// We found the right padding
plainText[8-k] = (byte) (k ^ newCipher[8-k] ^ iv[8-k]);
}
System.out.println("response: " + bytesToHex(plainText));
// disconnect
card.disconnect(false);
}
private static boolean tryDecrypt(CardChannel channel, byte[] goodBlock, byte[] newCipher) throws Exception {
ByteArrayOutputStream os = new ByteArrayOutputStream();
os.write(decryptApdu);
os.write(newCipher);
os.write(goodBlock);
ResponseAPDU r = channel.transmit(new CommandAPDU(os.toByteArray()));
byte[] resp = r.getBytes();
System.out.println("response: " + bytesToHex(r.getBytes()));
return resp[0] == (byte)0x90 && resp[1] == 00;
}
public static String bytesToHex(byte[] bytes) {
final char[] hexArray = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
char[] hexChars = new char[bytes.length * 2];
int v;
for ( int j = 0; j < bytes.length; j++ ) {
v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
}
J’ai aussi nettoyé un peu le code pour séparer l’algorithme de l’oracle.
Une interface pour définir un oracle:
package com.okiok.comp;
/**
* This interface represents the oracle which tells whether after decipherment, the PKCS5 padding is correct
* @author evigeant
*/
public interface DecipherPaddingOracle {
/**
* @param buffer The buffer to decipher (length should be a multiple of block length)
* @return True when the padding after decipherment is good, false otherwise
* @throws Exception if there was any error unrelated to padding
*/
boolean decipher(byte[] buffer) throws Exception;
/**
* @return The block length of the block cipher used
*/
int getBlockLength();
}
La classe qui résout:
package com.okiok.comp;
import java.nio.ByteBuffer;
/** * Perform Oracle padding attack * @author evigeant * */
public class OraclePaddingSolver {
/**
* Perform the Oracle Padding attack. In order for the attack to be successful, the block cipher must use CBC to chain blocks and pad using PKCS5.
*
* @param iv The iv used in the encryption. If this value is wrong, the first block of the plain text will be wrong.
* @param validCipherText The valid ciphertext from which we attempt to recover the plaintext. Length should be a multiple of the block length, otherwise there was an error in padding...
* @param oracle The oracle which tells us if the padding is right or wrong after decryption
* @return The plaintext recovered
* @throws Exception
*/
public static byte[] findPlainText(byte[] iv, byte[] validCipherText, DecipherPaddingOracle oracle) throws Exception {
// First break into blocks and attack each block separately
int blockSize = oracle.getBlockLength();
ByteBuffer outputBuffer = ByteBuffer.allocate(validCipherText.length);
for(int i = 0; i < validCipherText.length / blockSize; ++i) {
byte[] currentBlock = new byte[blockSize];
System.arraycopy(validCipherText, blockSize*i, currentBlock, 0, blockSize);
byte[] previousBlock = new byte[blockSize];
if(i > 0) {
System.arraycopy(validCipherText, blockSize*(i-1), previousBlock, 0, blockSize);
} else {
previousBlock = iv;
}
byte[] plainBlock = findBlockPlainText(previousBlock, currentBlock, oracle);
outputBuffer.put(plainBlock);
}
return outputBuffer.array();
}
private static byte[] findBlockPlainText(byte[] previousBlockCipherText, byte[] currentBlockCipherText, DecipherPaddingOracle oracle) throws Exception {
int blockSize = oracle.getBlockLength();
byte[] plainText = new byte[blockSize]; // This is the plaintext we have worked out so far
byte[] newCipher = new byte[blockSize]; // This is a fake block of ciphertext we play with
// We proceed one character at a time in the block
for(int k = 1; k <= blockSize; ++k) {
// We correct the padding for all lower bytes (bytes that we have already worked out)
// If we are looking for the first byte, this will do nothing
for(int i = 1; i < k; ++i) {
newCipher[blockSize-i] = (byte)(k ^ plainText[blockSize-i] ^ previousBlockCipherText[blockSize-i]);
}
boolean success = false;
ByteBuffer buf = ByteBuffer.allocate(2*blockSize);
for(int tries=newCipher[blockSize-k]; tries < 256 && !success; ++tries) {
buf.clear();
buf.put(newCipher);
buf.put(currentBlockCipherText);
// The oracle does not give us the result of the decryption, but it tells us if the decryption failed because of a padding error. (Padding errors are the usual suspect when decryption fails)
// The oracle will decrypt the second block using the right key, it will therefore get: [plaintext] XOR [REAL prev. block ciphertext] and because it is CBC, it will then XOR [our FAKE ciphertext]
success = oracle.decipher(buf.array());
if(!success) {
newCipher[blockSize-k]++;
}
}
if(success) {
// We found the right padding (success = true)
// It will then check the padding of the block since it is the last block. PKCS5 pads in the following way: XXXXXXX1, XXXXXX22, XXXXX333, etc. So the number of bytes added as padding is the number used in padding.
// Also, if the plaintext is a multiple of the block size, it will add one full block (ex: 88888888).
// So if the oracle doesn't report an error, we know that (for character k): PLAIN[k] XOR REAL_PREVIOUS_CIPHER[k] XOR FAKE[k] = k
plainText[blockSize-k] = (byte) (k ^ newCipher[blockSize-k] ^ previousBlockCipherText[blockSize-k]);
} else if(k > 1) {
// WARNING: There is a possibility that while looking for the right padding of the FIRST character (ex: XXXXXXX1), by a bad luck, we find a valid padding of the form (XXXXXX22 or XXXXX333, etc.), this is 256x less likely but it could happen.
// In that case, we would need to continue the search for the first character passed the problem and find the right value that yields (XXXXXXX1).
newCipher[blockSize-k] = 0; // We reset the current character
newCipher[blockSize-k+1]++; // We increment the previous character
k-=2; // Go back to the previous character
} else {
throw new Exception("There is a problem with the oracle, it returned false for all 256 characters");
}
}
return plainText;
}
}
Enfin, un exemple d’utilisation du solveur pour résoudre le défi de la compétition:
package com.okiok.comp;
import java.io.ByteArrayOutputStream;
import java.util.List;
import javax.smartcardio.Card;
import javax.smartcardio.CardChannel;
import javax.smartcardio.CardException;
import javax.smartcardio.CardTerminal;
import javax.smartcardio.CommandAPDU;
import javax.smartcardio.ResponseAPDU;
import javax.smartcardio.TerminalFactory;
/** * Perform Oracle padding attack * * @author evigeant */
public class OraclePaddingExample {
static byte[] iv = new byte[8]; // Guessed at 0
// This was retrieved from the smartcard by issuing the ENCRYPT command (without any data)
static byte[] cipherBlock = {(byte) 0xD5, (byte) 0xE1, 0x2B, 0x2B, (byte) 0x8B, 0x2D, 0x79, 0x25, 0x01, (byte) 0xBC, 0x34, 0x37, 0x68, 0x10, (byte) 0xD4, (byte) 0xBB};
public static void main(String[] args) throws Exception {
NsecBenPyrApp oracle = new NsecBenPyrApp();
byte[] plaintext = OraclePaddingSolver.findPlainText(iv, cipherBlock, oracle);
System.out.println("response: " + bytesToHex(plaintext));
oracle.close();
}
private static String bytesToHex(byte[] bytes) {
final char[] hexArray = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
char[] hexChars = new char[bytes.length * 2];
int v;
for ( int j = 0; j < bytes.length; j++ ) {
v = bytes[j] & 0xFF;
hexChars[j * 2] = hexArray[v >>> 4];
hexChars[j * 2 + 1] = hexArray[v & 0x0F];
}
return new String(hexChars);
}
/**
* For NSEC 2013 Smartcard track. This class selects the BENPYR app on the smart card and sends a decrypt command.
* The card returns an error when padding is wrong and returns success (0x9000) when the padding is right.
*
* @author evigeant
*/
private static class NsecBenPyrApp implements DecipherPaddingOracle {
static byte[] selectApdu = new byte[] {0x00, (byte) 0xA4, 0x04, 0x00, 0x07, 0x42, 0x45, 0x4E, 0x50, 0x59, 0x52, 0x31};
static byte[] decryptApdu = new byte[] {0x00, (byte) 0x88, 0x02, 0x00, 0x10};
private Card card;
private CardChannel channel;
public NsecBenPyrApp() throws Exception {
// show the list of available terminals
javax.smartcardio.TerminalFactory factory = TerminalFactory.getDefault();
List<CardTerminal> terminals = factory.terminals().list();
System.out.println("Terminals: " + terminals);
// get the first terminal
CardTerminal terminal = terminals.get(0);
// establish a connection with the card
card = terminal.connect("T=1");
System.out.println("card: " + card);
channel = card.getBasicChannel();
ResponseAPDU r = channel.transmit(new CommandAPDU(selectApdu));
System.out.println("response: " + bytesToHex(r.getBytes()));
}
@Override
public boolean decipher(byte[] buffer) throws Exception {
ByteArrayOutputStream os = new ByteArrayOutputStream();
os.write(decryptApdu);
os.write(buffer);
ResponseAPDU r = channel.transmit(new CommandAPDU(os.toByteArray()));
byte[] resp = r.getBytes();
System.out.println("response: " + bytesToHex(r.getBytes()));
return resp[0] == (byte)0x90 && resp[1] == 00;
}
@Override
public int getBlockLength() {
// USED 3DES CBC
return 8;
}
public void close() throws CardException {
card.disconnect(false);
}
}
}
Quand j’ai lancé le code et vu les octets sortir un à un, grâce aux messages de succès de la carte à puce, j’étais vraiment content. À ce moment-là, j’étais tellement heureux d’avoir cassé ça que j’avais le sentiment d’avoir accompli ce pour quoi j’étais venu. J’avais appris une nouvelle attaque et je l’avais exploitée avec succès en pratique.
Ce drapeau valait beaucoup de points, alors je contribuais à l’effort de l’équipe et j’étais récompensé pour tout le temps que j’y avais consacré.
