⬆️ ⬇️

Charm Solitaire game defense study

Charm solitaire

About 7-8 years ago, I accidentally came across a CharmSolitaire game, copied along with other games from another screw in the process of sharing information. This is not an ordinary card solitaire. In the unregistered version, the game has one hour, and only half of the levels are open. In that copy, time is almost over. I did not have the money to buy, so most likely I would delete it. But at that time I was a little keen on burglary and decided to try to find the registration code. The experience was quite interesting. The article describes the main features of protection, as well as how security through obscurity can weaken it.







There is no key in the open form in the article, but whoever is interested can find it on his own.



All actions you perform at your own risk.

')



Protection research



We load the file into IDA, launch the game and press the “Key” button. Enter something, for example 123321, and look through ArtMoney.



image



We put hardware breakpoint on this address. Switch to the game window, skip the breakpoint trigger until the game window is active. Click OK, breakpoint works.



Press Ctrl + F7 (Run until return) until we get from the system dll to the process space. This will be the message handling procedure for Controls :: TWinControl :: DefaultHandler (). We continue to exit the functions until we get to the call Controls :: TControl :: GetText (void):

Hidden text
004C2700: call @Controls@TControl@GetText$qqrv ; Controls::TControl::GetText(void) EIP-> mov edx, [ebp+var_8] mov eax, [ebp+var_4] call sub_4C23A8 test al, al jnz short loc_4C277C ... mov edx, offset _str_WKeyError.Text call sub_4AF2F8 ... jmp short loc_4C27D2 loc_4C277C: ... mov edx, offset _str_WRegistrationTh.Text call sub_4AF2F8 




The variable [ebp + var_8] is a pointer to the line with the entered code. Further we see the sub_4C23A8 () function call and the conditional transition. In the strings _str_WKeyError and _str_WRegistrationThanks you can guess that sub_4C23A8 () is the key verification function.

Hidden text
 check_key_4C23A8 proc near ... mov [ebp+key_8], edx mov [ebp+this_4], eax ... mov [ebp+is_right_key_9], 0 cmp [ebp+key_8], 0 jz loc_4C257B lea edx, [ebp+key_copy_28] mov eax, [ebp+key_8] call copy_digits_492734 mov edx, [ebp+key_copy_28] mov eax, ds:pp_key_4F305C call @System@@LStrAsg$qqrv ; System::__linkproc__ LStrAsg(void) ... mov eax, ds:pp_dirname_4F2F54 push dword ptr [eax] push offset _str_slash.Text push offset _str_CharmSolitaire.Text push offset _str__udf.Text lea eax, [ebp+udf_filename_2C] mov edx, 4 call str_cat_40522C mov edx, [ebp+udf_filename_2C] ; %GAME_DIR%\CharmSolitaire.udf mov eax, [ebp+mem_stream_encrypted_10] call @Classes@TMemoryStream@LoadFromFile$qqrx17System@AnsiString_0 ; Classes::TMemoryStream::LoadFromFile(System::AnsiString) mov eax, ds:pp_key_4F305C cmp dword ptr [eax], 0 jz short loc_4C2496 mov eax, ds:pp_key_4F305C mov eax, [eax] call strlen_40516C 004C2473: cmp eax, 18h jnz short loc_4C2496 004C2478: ... loc_4C25A5: mov al, [ebp+is_right_key_9] ... retn check_key_4C23A8 endp 




From the instructions at address 004C2473 it can be seen that the length of the string should be 18h, that is, 24 characters. OK, we set breakpoint, we launch, we enter key 123456789012345678901234.

Hidden text
 004C2478: lea edx, [ebp+var_30] mov eax, ds:pp_key_4F305C mov eax, [eax] call sub_4924D0 mov edx, [ebp+var_30] mov eax, ds:off_4F2C24 call @System@@LStrAsg$qqrv ; System::__linkproc__ LStrAsg(void) jmp short loc_4C24A5 




The sub_4924D0 procedure performs some string manipulations and translates the result into int64.

Hidden text
 key_to_hex_4924D0 proc near ... mov [ebp+p_res_10], edx mov [ebp+key_C], eax ... lea edx, [ebp+key_copy_24] mov eax, [ebp+key_C] call copy_digits_492734 mov eax, [ebp+key_copy_24] lea edx, [ebp+mixed_str_14] call mix_symbols_492688 lea eax, [ebp+mixed_str_14] mov ecx, 3 mov edx, 1 call delete_symbols_40540C jmp short loc_492539 loc_492527: lea eax, [ebp+mixed_str_14] mov ecx, 1 mov edx, 1 call delete_symbols_40540C loc_492539: mov eax, [ebp+mixed_str_14] cmp byte ptr [eax], 30h ; '0' jz short loc_492527 ; delete leading zeros push 0 ; default value push 0 ; default value mov eax, [ebp+mixed_str_14] call @Sysutils@StrToInt64Def$qqrx17System@AnsiStringj ; Sysutils::StrToInt64Def(System::AnsiString,__int64) mov [ebp+v64lo_8], eax mov [ebp+v64hi_4], edx mov eax, [ebp+p_res_10] call @System@@LStrClr$qqrr17System@AnsiString ; System::__linkproc__ LStrClr(System::AnsiString &) mov [ebp+i_18], 1 loc_492562: mov eax, [ebp+i_18] test byte ptr [ebp+eax-1+v64lo_8], 7Fh jbe short loc_49258C lea eax, [ebp+char_str_28] mov edx, [ebp+i_18] mov dl, byte ptr [ebp+edx-1+v64lo_8] and dl, 7Fh call str_from_pchar_405084 ; Borland Visual Component Library & Packages mov edx, [ebp+char_str_28] mov eax, [ebp+p_res_10] call @System@@LStrCat$qqrv ; System::__linkproc__ LStrCat(void) mov eax, [ebp+p_res_10] loc_49258C: inc [ebp+i_18] cmp [ebp+i_18], 9 jnz short loc_492562 loc_4925A2: ... retn key_to_hex_4924D0 endp 




Pseudo code:

 mixed_str_14 = mix_symbols_492688(key); v64_4 = StrToInt64Def(mixed_str_14, 0); while (v64_4[i] & 0x7F > 0) (string)p_res_10 += (char)v64_4[i] & 0x7F, i++; 


The function mix_symbols_492688 works like this - the values ​​from the first half of the line become odd places (if we count from 0), from the second one to even ones. Our line turns into 314253647586970819203142.



In the StrToInt64Def function, another ValInt64 system function is called. It has one feature - if there are still characters left after processing, then the current position is returned to the output variable (code), otherwise 0. Processing ends if the current value exceeds 0x0CCCCCCCCCCCCCCC (because 0x0CCCCCCCCCCCC = 0x7FFFFFFFFFFFFFFFF / 0x0A; 0x0A is base number systems). In StrToInt64Def there is a check for this, and if non-0 returned to the code, the default value is returned instead of the result (in this case, 0).



314253647586970819203142 clearly exceeds this value. Take for example the code 0x0CCCCCCCCCCCCCCC. Translate into the decimal system and perform the actions inverse to the actions of the function mix_symbols_492688 - we write odd characters in the first half, even in the second:

 0x0CCCCCCCCCCCCCCC = 922337203685477580 000000922337203685477580 0 0 0 2 3 7 0 6 5 7 5 0 0 0 0 9 2 3 2 3 8 4 7 8 000237065750000923238478 




We start again, we enter a new code, we return to the function check_key_4C23A8.

Hidden text
 004C2478: lea edx, [ebp+p_key64_30] mov eax, ds:pp_key_4F305C mov eax, [eax] call key_to_hex_4924D0 mov edx, [ebp+p_key64_30] mov eax, ds:p_key_bytes_4F2C24 call @System@@LStrAsg$qqrv ; System::__linkproc__ LStrAsg(void) jmp short loc_4C24A5 ... loc_4C24A5: mov ecx, ds:p_key_bytes_4F2C24 mov ecx, [ecx] mov edx, [ebp+mem_stream_decrypted_14] mov eax, [ebp+mem_stream_encrypted_10] call sub_492B48 mov eax, [ebp+mem_stream_decrypted_14] call sub_492C94 test al, al jz loc_4C255F ... loc_4C255F: mov [ebp+is_right_key_9], 0 ... loc_4C25A5: mov al, [ebp+is_right_key_9] ... ret check_key_4C23A8 endp 




First, take a look at the sub_492C94 function. The constants 20h, 09h, 0Dh, 0Ah, in the comparison instructions say that there is some kind of work with the text. A more detailed examination shows that this function checks whether the contents of mem_stream_decrypted_14 are text. Let's call it is_text_492C94.



The main work takes place in the sub_492B48 function. There, using the key, data from mem_stream_encrypted_10 is decrypted, into which the contents of the file CharmSolitaire.udf were uploaded. You can look at it in the HEX-editor. In the beginning there is a block, where every 8 bytes is 0x33. Interesting, but not very clear how to use it. Go ahead.



Hidden text
 decrypt_492B48 proc near ... mov [ebp+key_bytes_28], ecx mov [ebp+mem_stream_decrypted_24], edx mov [ebp+mem_stream_encrypted_20], eax ... mov eax, [ebp+key_bytes_28] call strlen_40516C test eax, eax jnz short loc_492B87 ... loc_492B87: ... ; key_bytes_28    8  ... ; key_bytes8_34   8   key_bytes_28 ... ;    ,    loc_492BD4: lea edx, [ebp+buf_14] mov ecx, 8 mov eax, [ebp+mem_stream_encrypted_20] mov ebx, [eax] call dword ptr [ebx+0Ch] ; read bytes mov [ebp+bytes_read_C], eax xor eax, eax mov [ebp+i_2C], eax loc_492BEC: mov eax, [ebp+i_2C] mov al, [ebp+eax+key_bytes8_34] mov edx, [ebp+i_2C] xor byte ptr [ebp+edx+buf_14], al inc [ebp+i_2C] cmp [ebp+i_2C], 8 jnz short loc_492BEC cmp [ebp+bytes_read_C], 8 jnz short loc_492C3C push ebp call sub_492A6C pop ecx xor eax, eax mov [ebp+i_2C], eax loc_492C15: mov eax, [ebp+i_2C] mov al, [ebp+eax+key_bytes8_34] mov edx, [ebp+i_2C] xor byte ptr [ebp+edx+decrypted_buf_1C], al inc [ebp+i_2C] cmp [ebp+i_2C], 8 jnz short loc_492C15 lea edx, [ebp+decrypted_buf_1C] mov ecx, [ebp+bytes_read_C] mov eax, [ebp+mem_stream_decrypted_24] mov ebx, [eax] call dword ptr [ebx+10h] ; write bytes jmp short loc_492C4A loc_492C3C: lea edx, [ebp+buf_14] mov ecx, [ebp+bytes_read_C] mov eax, [ebp+mem_stream_decrypted_24] mov ebx, [eax] call dword ptr [ebx+10h] ; write bytes loc_492C4A: cmp [ebp+bytes_read_C], 8 jz short loc_492BD4 ... retn decrypt_492B48 endp 




Pseudocode:

 bytes_read = mem_stream_encrypted->read(buf, 8); for (i = 0; i < 8; i++) buf ^= key[i]; if (bytes_read == 8) { sub_492A6C(buf, out decrypted_buf); for (i = 0; i < 8; i++) decrypted_buf ^= key[i]; mem_stream_decrypted->write(decrypted_buf, bytes_read); } else { mem_stream_decrypted->write(buf, bytes_read); } 


First, the XOR is done with the key

Then, if the number of bytes read is equal to 8, the sub_492A6C procedure is called, which in some way shuffles the bits of the result.

Then again the XOR is done with the key.

The result is written to the output buffer.

If not equal (the last bytes of the encrypted buffer), then nothing is called, and the second XOR is not done.



sub_492A6C is a nested function, passed to the ebp parent function. Pseudocode is quite large, easier to describe in words. It takes 8 bytes as input, it makes up the first byte of the result from the first bits, the second byte from the second bits, and so on.



 (   ) 11111111 10000000 00000000 10000000 00000000 10000000 00000000 -> 10000000 00000000 10000000 00000000 10000000 00000000 10000000 00000000 10000000 


It seems to me like this dialogue:

- Let's encrypt via XOR with a key.

- No, let's XOR, then here and there we turn, and again XOR. To completely was incomprehensible.

- Right, come on.



In other words, an 8x8-bit matrix is ​​drawn around the main diagonal (transposed), after which a repeated XOR is made with the key. Therein lies the mistake.





Breaking into



What are we doing? We xorim a 8x8 bit block with key bytes, reverse this matrix, and xorim again with the same key. It turns out that the bits on the main diagonal are not encrypted at all. The remaining bits have a dependency:

 C[x][y] ^ K[x][y] ^ K[y][x] = D[y][x] C[y][x] ^ K[y][x] ^ K[x][y] = D[x][y]  C -  , D -  , K - , x  y -    . 


The elements K [x] [y] ^ K [y] [x] form a symmetric matrix T [x] [y]:

 T[x][y] = T[y][x] C[x][y] ^ T[x][y] = D[y][x] C[y][x] ^ T[x][y] = D[x][y] 


This means that we do not need to search for all the original bits of the key. It can be assumed that the upper triangle of the matrix of the key is zero. Then the upper and lower triangles of the matrix T will be equal to the lower triangle of the key.



This allows you to reduce the number of options for busting more than 2 times - half of the matrix + diagonal. The number of unknown bits: (7 + 6 + 5 + 4 + 3 + 2 + 1) = 28. Total 2 ^ 28 options, and after decrypting the matrix should get the text.



Search method:

- we read the encrypted block of 8 bytes

- we place the current value of the counter in the lower triangle of the key

- we do XOR with the ciphered block

- we turn the received matrix

- doing XOR again

- repeat

- after decryption we check for text; if the text did not work, then the key is wrong

- to speed up the search, you can check each decrypted block



Types of bits in the matrix (low bit on the left):

 #define FIXED_0 0 #define FIXED_1 1 #define UNKNOWN 2 const unsigned char bitTypes[8][8] = { {0, 0, 0, 0, 0, 0, 0, 0}, {2, 0, 0, 0, 0, 0, 0, 0}, {2, 2, 0, 0, 0, 0, 0, 0}, {2, 2, 2, 0, 0, 0, 0, 0}, {2, 2, 2, 2, 0, 0, 0, 0}, {2, 2, 2, 2, 2, 0, 0, 0}, {2, 2, 2, 2, 2, 2, 0, 0}, {2, 2, 2, 2, 2, 2, 2, 0} }; 
The current value of the brute force counter is distributed over the UNKNOWN bits (there is a code at the end of the article).



There is also a curious feature. Making XOR parts that are on the same side of the '=' sign, we get:

 C[x][y] ^ K[x][y] ^ K[y][x] ^ C[y][x] ^ K[y][x] ^ K[x][y] = D[y][x] ^ D[x][y] C[x][y] ^ C[y][x] ^ (K[x][y] ^ K[x][y]) ^ (K[y][x] ^ K[y][x]) = D[y][x] ^ D[x][y] C[x][y] ^ C[y][x] = D[y][x] ^ D[x][y] 


The XOR of two symmetric ciphertext bits is equal to the XOR of these decrypted text bits. Perhaps this could be useful in some cases, but here it is not important.





Not so simple



1. After the search is completed, 65 text variants will be obtained.



On my system, it took about 15-20 minutes. You can view them all manually and select the appropriate one. But we have a hint - every 8th byte at the beginning of the file is 0x33. Now we know how it appears - it is (high bits of a block of 8 characters) XOR (8th byte of matrix T). If we assume that the text is in Latin, then the high-order bits are everywhere 0, and 0x33 is the 8th byte of the matrix in open form.



Then you can sort out 256 times less options, that is, 2 ^ 20:

 #define FIXED_0 0 #define FIXED_1 1 #define UNKNOWN 2 const unsigned char bitTypes[8][8] = { {0, 0, 0, 0, 0, 0, 0, 0}, {2, 0, 0, 0, 0, 0, 0, 0}, {2, 2, 0, 0, 0, 0, 0, 0}, {2, 2, 2, 0, 0, 0, 0, 0}, {2, 2, 2, 2, 0, 0, 0, 0}, {2, 2, 2, 2, 2, 0, 0, 0}, {2, 2, 2, 2, 2, 2, 0, 0}, {1, 1, 0, 0, 1, 1, 0, 0} }; 


After starting the search, we find the only option. This will be the intended key. But in this form it does not fit.



2. If the number of bytes in the file is not a multiple of 8, then the remainder is obtained by encrypted plain byte XOR with the key.



Here you need to check only manually - look at the decrypted text, assume what the last bytes can be, and on the basis of them restore the matrix.

Hidden text
For example, the length of the file CharmSolitaire.udf is 0x823, that is, the last 3 bytes are encrypted with a regular XOR with a key.

First you need to find the intended key and decrypt 0x820 bytes.

Based on the text, assume what the remaining 3 bytes should be.

Then find the XOR between 3 encrypted and manually decrypted bytes, these will be real key bytes.



We write bit bytes of the intended key.

Over write the bits of these bytes. In this case, if some bits do not match, you need to invert the symmetric bit in the other half of the matrix.

If the bit is on the main diagonal, just overwrite.



Hint: The decrypted text ends with “five.” :) (video memory)



Levels after the 30th are also encrypted. Level is an XML file. If the key is inaccurate, then there may be different problems - from artifacts in graphics to an exception with a message about incorrect XML markup. Based on the CharmSolitaire.udf file, you can find 3 lower bytes of the real key and complete it with level 39 with this key. Its length is 0x246F, that is, you can find all 7 unknown bytes.

Hidden text
- transfer the game to windowed mode.

- put a breakpoint on loc_492C3C in the decrypt_492B48 procedure (this is the code for recording the last decoded bytes)

- take the bytes that are in the variable buf_14

- make XOR with the current key

- make an XOR with text that should be



Hint: level ends with a tag
  </ Level> 0x0D, 0x0A 






Result



8 bytes of the key:

Bre6Vqd3



Latin text at the beginning of the file:

Some years ago, she lived there.



Program Code:

Hidden text
 #include <vcl.h> #pragma hdrstop #include "MainUnit.h" #include <vector> //--------------------------------------------------------------------------- #pragma package(smart_init) #pragma resource "*.dfm" TMainForm *MainForm; //--------------------------------------------------------------------------- __fastcall TMainForm::TMainForm(TComponent* Owner) : TForm(Owner) { } //--------------------------------------------------------------------------- inline unsigned int getBit(unsigned char byte, unsigned int bitNumber) { return (byte & ((unsigned char)1 << bitNumber) ? 1 : 0); } inline void setBit(unsigned char &byte, unsigned int bitNumber, unsigned int bitValue) { if (bitValue) byte |= ((unsigned char)1 << bitNumber); else byte &= ~((unsigned char)1 << bitNumber); } int isText(unsigned char *pData, int streamSize) { int isText = 1; if (streamSize < 1) return isText; char prevChar = 0; do { if (*pData < 0x20 && *pData != 9) { if ((*pData == 0x0D || *pData == 0x0A)) { //         //       0x0A,       //if (*pData == 0x0A && prevChar != 0x0D) //{ // isText = 0; // break; //} } else { isText = 0; break; } } prevChar = *pData; pData++; } while (--streamSize); return isText; } #define FIXED_0 0 #define FIXED_1 1 #define UNKNOWN 2 const unsigned char bitTypes[8][8] = { //    {0, 0, 0, 0, 0, 0, 0, 0}, {2, 0, 0, 0, 0, 0, 0, 0}, {2, 2, 0, 0, 0, 0, 0, 0}, {2, 2, 2, 0, 0, 0, 0, 0}, {2, 2, 2, 2, 0, 0, 0, 0}, {2, 2, 2, 2, 2, 0, 0, 0}, {2, 2, 2, 2, 2, 2, 0, 0}, {1, 1, 0, 0, 1, 1, 0, 0} }; void getKeyMatrix(unsigned int keyBits, unsigned char matrix[8]) { int x, y; unsigned int bitValue = 0, bitNumber = 0; memset(matrix, 8, 0); for(y = 0; y < 8; y++) { for(x = 0; x < 8; x++) { //        if (bitTypes[y][x] == UNKNOWN) { bitValue = getBit(((unsigned char*)&keyBits)[bitNumber / 8], bitNumber % 8); bitNumber++; } else if (bitTypes[y][x] == FIXED_1) bitValue = 1; else bitValue = 0; setBit(matrix[y], x, bitValue); } } } void reverseMatrix(unsigned char block[8]) { unsigned int x, y, bitValue; unsigned char tmpBlock[8]; for (y = 0; y < 8; y++) { for (x = 0; x < 8; x++) { bitValue = getBit(block[x], y); setBit(tmpBlock[y], x, bitValue); } } memcpy(block, tmpBlock, 8); } void decryptBlock(unsigned int keyBits, unsigned char *encryptedBlock, unsigned char *decryptedBlock, unsigned int blockSize) { unsigned char key[8]; unsigned int i; getKeyMatrix(keyBits, key); for(i = 0; i < blockSize; i++) decryptedBlock[i] = encryptedBlock[i] ^ key[i]; if (blockSize == 8) { reverseMatrix(decryptedBlock); for(i = 0; i < 8; i++) decryptedBlock[i] = decryptedBlock[i] ^ key[i]; } } void decryptText(unsigned char *encryptedText, unsigned char *decryptedText, unsigned int textSize, unsigned int keyBits) { unsigned int position = 0, blockSize = 8, bytesToRead = 0; unsigned int i, j; while(position < textSize) { if (position + blockSize <= textSize) bytesToRead = blockSize; else bytesToRead = textSize - position; decryptBlock(keyBits, encryptedText + position, decryptedText + position, bytesToRead); //       if (bytesToRead == 8 && !isText(decryptedText + position, 8)) break; position += bytesToRead; } } void getKeyVariants(unsigned char *encryptedText, unsigned int textSize, std::vector<unsigned int> &keyList) { unsigned int variantsCount = 0; unsigned int possibleBits = 0; unsigned char *decryptedText = new unsigned char[textSize]; //for (possibleBits = 0; possibleBits < (1 << 20); possibleBits++) for (possibleBits = 0; possibleBits < (1 << 6); possibleBits++) { decryptText(encryptedText, decryptedText, textSize, possibleBits); if (isText(decryptedText, textSize - textSize % 8)) { keyList.push_back(possibleBits); variantsCount++; } } variantsCount = variantsCount; //   delete []decryptedText; } AnsiString getKeyText(unsigned char keyMatrix[8]) { AnsiString str = "000000000000000000000000" + IntToStr(*(__int64*)keyMatrix); str = str.SubString(str.Length() - 24 + 1, 24); //   1 AnsiString keyText = ""; for(int i = 0; i < 24; i += 2) keyText.cat_printf("%c", str.c_str()[i + 1]); for(int i = 0; i < 24; i += 2) keyText.cat_printf("%c", str.c_str()[i]); return keyText; } //--------------------------------------------------------------------------- std::vector<unsigned int> keyList; void __fastcall TMainForm::btnStartClick(TObject *Sender) { AnsiString filename = "F:\\Games\\Charm Solitaire\\CharmSolitaire.udf"; TMemoryStream *encryptedStream = new TMemoryStream(); encryptedStream->LoadFromFile(filename); getKeyVariants((unsigned char *)encryptedStream->Memory, encryptedStream->Size, keyList); unsigned char keyMatrix[8]; getKeyMatrix(keyList[0], keyMatrix); getKeyText(keyMatrix); } void __fastcall TMainForm::btnDecryptClick(TObject *Sender) { AnsiString filename = "F:\\Games\\Charm Solitaire\\CharmSolitaire.udf"; TMemoryStream *encryptedStream = new TMemoryStream(); encryptedStream->LoadFromFile(filename); unsigned int keyBits = keyList[0]; unsigned int textSize = encryptedStream->Size; unsigned char *decryptedText = new unsigned char[textSize + 1]; decryptedText[textSize] = 0; decryptText((unsigned char *)encryptedStream->Memory, decryptedText, textSize, keyBits); mDecryptedText->Text = AnsiString((char*)decryptedText); } //--------------------------------------------------------------------------- 






Source: https://habr.com/ru/post/260071/



All Articles