Mematahkan Retak Sederhana dengan Ghidra - Bagian 2

Di bagian pertama artikel, menggunakan Ghidra, kami secara otomatis menganalisis program crack sederhana (yang kami unduh dari crackmes.one). Kami menemukan cara mengubah nama fungsi "tidak dapat dipahami" tepat di daftar dekompiler, dan juga memahami algoritme program "tingkat atas", mis. yang dilakukan oleh main () .

Pada bagian ini, kami, seperti yang saya janjikan, akan mengambil analisis fungsi _construct_key () , yang, seperti yang kami ketahui , bertanggung jawab untuk membaca file biner yang ditransfer ke program dan memeriksa data yang dibaca.

Langkah 5 - Ikhtisar Fungsi _construct_key ()


Mari kita lihat daftar lengkap fungsi ini segera:

Listing _construct_key ()
char ** __cdecl _construct_key(FILE *param_1) { int iVar1; size_t sVar2; uint uVar3; uint local_3c; byte local_36; char local_35; int local_34; char *local_30 [4]; char *local_20; undefined4 local_19; undefined local_15; char **local_14; int local_10; local_14 = (char **)__prepare_key(); if (local_14 == (char **)0x0) { local_14 = (char **)0x0; } else { local_19 = 0; local_15 = 0; _text(&local_19,1,4,param_1); iVar1 = _text((char *)&local_19,*(char **)local_14[1],4); if (iVar1 == 0) { _text(local_14[1] + 4,2,1,param_1); _text(local_14[1] + 6,2,1,param_1); if ((*(short *)(local_14[1] + 6) == 4) && (*(short *)(local_14[1] + 4) == 5)) { local_30[0] = *local_14; local_30[1] = *local_14 + 0x10c; local_30[2] = *local_14 + 0x218; local_30[3] = *local_14 + 0x324; local_20 = *local_14 + 0x430; local_10 = 0; while (local_10 < 5) { local_35 = 0; _text(&local_35,1,1,param_1); if (*local_30[local_10] != local_35) { _free_key(local_14); return (char **)0x0; } local_36 = 0; _text(&local_36,1,1,param_1); if (local_36 == 0) { _free_key(local_14); return (char **)0x0; } *(uint *)(local_30[local_10] + 0x104) = (uint)local_36; _text(local_30[local_10] + 1,1,*(size_t *)(local_30[local_10] + 0x104),param_1); sVar2 = _text(local_30[local_10] + 1); if (sVar2 != *(size_t *)(local_30[local_10] + 0x104)) { _free_key(local_14); return (char **)0x0; } local_3c = 0; _text(&local_3c,1,1,param_1); local_3c = local_3c + 7; uVar3 = _text(param_1); if (local_3c < uVar3) { _free_key(local_14); return (char **)0x0; } *(uint *)(local_30[local_10] + 0x108) = local_3c; _text(param_1,local_3c,0); local_10 = local_10 + 1; } local_34 = 0; _text(&local_34,4,1,param_1); if (*(int *)(*local_14 + 0x53c) == local_34) { _text("Markers seem to still exist"); } else { _free_key(local_14); local_14 = (char **)0x0; } } else { _free_key(local_14); local_14 = (char **)0x0; } } else { _free_key(local_14); local_14 = (char **)0x0; } } return local_14; } 


Dengan fungsi ini kita akan melakukan hal yang sama seperti sebelumnya dengan main () - untuk permulaan kita akan membahas panggilan fungsi "terselubung". Seperti yang diharapkan, semua fungsi ini berasal dari pustaka standar C. Saya tidak akan menjelaskan prosedur untuk mengganti nama fungsi lagi - kembali ke bagian pertama artikel, jika perlu. Sebagai hasil dari penggantian nama, fungsi standar berikut โ€œditemukanโ€:

  • fread ()
  • strncmp ()
  • strlen ()
  • ftell ()
  • fseek ()
  • menempatkan ()

Kami mengganti nama fungsi pembungkus yang sesuai dalam kode kami (yang dengan tersembunyi disembunyikan di belakang kata _text ) dengan menambahkan indeks 2 (sehingga tidak akan ada kebingungan dengan fungsi-C asli). Hampir semua fungsi ini untuk bekerja dengan aliran file. Tidak mengherankan - pandangan sekilas pada kode sudah cukup untuk memahami bahwa ia secara berurutan membaca data dari suatu file (deskriptor yang diteruskan ke fungsi sebagai satu-satunya parameter) dan membandingkan data yang dibaca dengan array dua dimensi tertentu dari byte local_14 byte.

Mari kita asumsikan bahwa array ini berisi data untuk verifikasi kunci. Sebut saja, katakan key_array . Karena Hydra memungkinkan Anda untuk mengubah nama tidak hanya fungsi, tetapi juga variabel, kami akan menggunakan ini dan mengubah nama local_14 yang tidak dapat dipahami menjadi key_array yang lebih dimengerti. Ini dilakukan dengan cara yang sama seperti untuk fungsi: melalui menu tombol kanan mouse ( Ganti nama lokal ) atau dengan tombol L dari keyboard.

Jadi, segera setelah deklarasi variabel lokal, fungsi tertentu _prepare_key () dipanggil :

 key_array = (char **)__prepare_key(); if (key_array == (char **)0x0) { key_array = (char **)0x0; } 

Kami akan kembali ke _prepare_key () , ini adalah tingkat ke-3 bersarang dalam hierarki panggilan kami: main () -> _construct_key () -> _prepare_key () . Sementara itu, kami menerima bahwa itu menciptakan dan entah bagaimana menginisialisasi array dua dimensi "uji" ini. Dan hanya jika array ini tidak kosong, fungsi melanjutkan kerjanya, sebagaimana dibuktikan oleh blok lain segera setelah kondisi di atas.

Selanjutnya, program membaca 4 byte pertama dari file dan membandingkannya dengan bagian yang sesuai dari array key_array . (Kode di bawah ini setelah penggantian nama, termasuk variabel local_19, saya beri nama pertama_4bytes .)

 first_4bytes = 0; /*   4    */ fread2(&first_4bytes,1,4,param_1); /*   key_array[1][0...3] */ iVar1 = strncmp2((char *)&first_4bytes,*(char **)key_array[1],4); if (iVar1 == 0) { ... } 

Dengan demikian, eksekusi lebih lanjut hanya terjadi jika 4 byte pertama bersamaan (ingat ini). Kemudian kita membaca 2 blok 2-byte dari file (dan key_array yang sama digunakan sebagai buffer untuk menulis data):

 fread2(key_array[1] + 4,2,1,param_1); fread2(key_array[1] + 6,2,1,param_1); 

Dan lagi - selanjutnya fungsi hanya berfungsi jika kondisi berikutnya benar:

 if ((*(short *)(key_array[1] + 6) == 4) && (*(short *)(key_array[1] + 4) == 5)) { //   ... } 

Sangat mudah untuk melihat bahwa yang pertama dari blok 2-byte yang dibaca di atas harus nomor 5, dan yang kedua harus nomor 4 (tipe data pendek hanya menempati 2 byte pada platform 32-bit).

Berikutnya adalah ini:

 local_30[0] = *key_array; // .. key_array[0] local_30[1] = *key_array + 0x10c; local_30[2] = *key_array + 0x218; local_30[3] = *key_array + 0x324; local_20 = *key_array + 0x430; 

Di sini kita melihat bahwa array local_30 (dideklarasikan sebagai char * local_30 [4]) berisi offset dari pointer key_array . Yaitu, local_30 adalah larik garis penanda tempat data dari file tersebut mungkin akan dibaca. Berdasarkan asumsi ini, saya mengganti nama local_30 menjadi marker . Di bagian kode ini, hanya baris terakhir yang tampak sedikit mencurigakan, di mana penugasan offset terakhir (pada indeks 0x430, mis. 1072) dilakukan bukan oleh elemen marker berikutnya, tetapi oleh variabel local_20 yang terpisah ( char * ). Tapi kita akan mengetahuinya, tetapi untuk sekarang - mari kita lanjutkan!

Selanjutnya kita sedang menunggu siklus:

  i = 0; // local_10   i while (i < 5) { // ... i = i + 1; } 

Yaitu Hanya 5 iterasi dari 0 hingga 4 inklusif. Dalam loop, membaca dari file dan memeriksa kepatuhan dengan array penanda kami segera dimulai:

 char c_marker = 0; //   local_35 /*  .    */ fread2(&c_marker, 1, 1, param_1); if (*markers[i] != c_marker) { /*    -      */ _free_key(key_array); return (char **)0x0; } 

Yaitu, byte berikutnya dari file tersebut dibaca ke dalam variabel c_marker (dalam kode dekompilasi asli - local_35 ) dan diperiksa untuk kepatuhan dengan karakter pertama dari elemen penanda ke-i. Dalam kasus ketidakcocokan, array key_array adalah nol dan pointer ganda kosong dikembalikan. Lebih jauh di sepanjang kode, kita melihat bahwa ini dilakukan setiap kali data yang dibaca tidak cocok dengan data verifikasi.

Tetapi di sini, seperti yang mereka katakan, "anjing dimakamkan." Mari kita perhatikan siklus ini lebih dekat. Ini memiliki 5 iterasi, seperti yang kami ketahui. Anda dapat memeriksa ini jika ingin dengan melihat kode assembler:





Memang, perintah CMP membandingkan nilai variabel local_10 (kami sudah memiliki i ) dengan angka 4 dan jika nilainya kurang dari atau sama dengan 4 (perintah JLE), transisi ke label LAB_004017eb dibuat , yaitu. mulai dari tubuh siklus. Yaitu kondisi akan terpenuhi untuk i = 0, 1, 2, 3, dan 4 - hanya 5 iterasi! Semuanya akan baik-baik saja, tetapi marker juga diindeks oleh variabel ini dalam satu lingkaran, dan bagaimanapun, array ini dideklarasikan dengan hanya 4 elemen:

 char *markers [4]; 

Jadi, seseorang jelas-jelas berusaha menipu seseorang :) Ingat, saya mengatakan bahwa kalimat ini meragukan?

 local_20 = *key_array + 0x430; 

Seperti itu saja! Lihat saja seluruh daftar fungsi dan coba temukan setidaknya satu referensi lagi ke variabel local_20 . Dia tidak ada di sana! Kami menyimpulkan dari ini: offset ini juga harus disimpan dalam array markers , dan array itu sendiri harus mengandung 5 elemen. Mari kita perbaiki. Pergi ke deklarasi variabel, tekan Ctrl + L (ketik ulang variabel) dan dengan berani mengubah ukuran array ke 5:



Selesai Gulir ke bawah ke kode untuk menetapkan offset pointer ke spidol , dan - lihatlah! - variabel tambahan yang tidak bisa dipahami menghilang dan semuanya jatuh pada tempatnya:

 markers[0] = *key_array; markers[1] = *key_array + 0x10c; markers[2] = *key_array + 0x218; markers[3] = *key_array + 0x324; markers[4] = *key_array + 0x430; //   ...   ! 

Kembali ke loop sementara kami (dalam kode sumber ini kemungkinan besar akan untuk , tetapi kami tidak peduli). Selanjutnya, byte dari file tersebut dibaca lagi dan nilainya diperiksa:

 byte n_strlen1 = 0; //   local_36 /*  .    */ fread2(&n_strlen1,1,1,param_1); if (n_strlen1 == 0) { /*      */ _free_key(key_array); return (char **)0x0; } 

OK, n_strlen1 ini pasti bukan nol. Mengapa Anda akan melihat sekarang, tetapi pada saat yang sama Anda akan mengerti mengapa saya memberi variabel ini nama berikut:

  /*   n_strlen1)  (markers[i] + 0x104) */ *(uint *)(markers[i] + 0x104) = (uint)n_strlen1; /*    (n_strlen1)  (-->  ?) */ fread2(markers[i] + 1,1,*(size_t *)(markers[i] + 0x104),param_1); n_strlen2 = strlen2(markers[i] + 1); //   sVar2 if (n_strlen2 != *(size_t *)(markers[i] + 0x104)) { /*    (n_strlen2)  == n_strlen1 */ _free_key(key_array); return (char **)0x0; } 

Saya menambahkan komentar yang semuanya harus jelas. N_strlen1 byte dibaca dari file dan disimpan sebagai urutan karakter (yaitu string) ke dalam array penanda [i] - yaitu, setelah "stop-simbol" yang sesuai, yang sudah ditulis di sana dari key_array . Menyimpan nilai n_strlen1 dalam marker [i] pada offset 0x104 (260) tidak memainkan peran apa pun di sini (lihat baris pertama dalam kode di atas). Bahkan, kode ini dapat dioptimalkan sebagai berikut (dan tentu saja ini terjadi dalam kode sumber):

 fread2(markers[i] + 1, 1, (size_t) n_strlen1, param_1); n_strlen2 = strlen2(markers[i] + 1); if (n_strlen2 != (size_t) n_strlen1) { ... } 

Itu juga memeriksa bahwa panjang dari baris baca adalah n_strlen1 . Ini mungkin tampak tidak perlu, mengingat bahwa parameter ini diteruskan ke fungsi ketakutan , tetapi ketakutan membaca tidak lebih dari begitu banyak byte yang ditentukan dan dapat membaca kurang dari yang ditunjukkan, misalnya, dalam hal memenuhi penanda akhir file (EOF). Artinya, semuanya ketat: panjang garis (dalam byte) ditunjukkan dalam file, kemudian garis itu sendiri berjalan - dan tepat 5 kali. Tapi kita maju dari diri kita sendiri.

Lebih lanjut menyirami kode ini (yang saya juga segera berkomentar):

 uint n_pos = 0; //   local_3c /*  .    */ fread2(&n_pos,1,1,param_1); /*   7 */ n_pos = n_pos + 7; /*     */ uint n_filepos = ftell2(param_1); //   uVar3 if (n_pos < n_filepos) { /* n_pos   >= n_filepos */ _free_key(key_array); return (char **)0x0; } 

Ini masih lebih sederhana di sini: kita mengambil byte berikutnya dari file, menambahkan 7 dan membandingkan nilai yang dihasilkan dengan posisi kursor saat ini dalam aliran file yang diperoleh oleh fungsi ftell () . Nilai n_pos harus tidak kurang dari posisi kursor (mis. Offset dalam byte dari awal file).

Baris terakhir dalam loop:

 fseek2(param_1,n_pos,0); 

Yaitu mengatur ulang kursor file (dari awal) ke posisi yang ditunjukkan oleh n_pos oleh fungsi fseek () . OK, kami melakukan semua operasi ini dalam loop 5 kali. Fungsi _construct_key () diakhiri dengan kode berikut:

 int i_lastmarker = 0; //   local_34 /*   4    (int32) */ fread2(&i_lastmarker,4,1,param_1); if (*(int *)(*key_array + 0x53c) == i_lastmarker) { /*    == key_array[0][1340] ...   :) */ puts2("Markers seem to still exist"); } else { _free_key(key_array); key_array = (char **)0x0; } 

Dengan demikian, blok data terakhir dalam file harus berupa nilai integer 4-byte dan harus sama dengan nilai pada key_array [0] [1340] . Dalam hal ini, kami akan menerima pesan ucapan selamat di konsol. Jika tidak, array kosong masih kembali tanpa pujian :)

Langkah 6 - Ikhtisar fungsi __prepare_key ()


Kami hanya memiliki satu fungsi yang belum dirakit tersisa - __prepare_key () . Kami sudah menduga bahwa di dalamnya data verifikasi dihasilkan dalam bentuk array key_array , yang kemudian digunakan dalam fungsi _construct_key () untuk memverifikasi data dari file. Masih mencari tahu data seperti apa di sana!

Saya tidak akan menganalisis fungsi ini secara terperinci dan segera memberikan daftar lengkap dengan komentar setelah semua penggantian nama variabel yang diperlukan:

__Prepare_key () daftar fungsi
 void ** __prepare_key(void) { void **key_array; void *pvVar1; /* key_array = new char*[2]; // 2 4-  (char*) */ key_array = (void **)calloc2(1,8); if (key_array == (void **)0x0) { key_array = (void **)0x0; } else { pvVar1 = calloc2(1,0x540); /* key_array[0] = new char[1340] */ *key_array = pvVar1; pvVar1 = calloc2(1,8); /* key_array[1] = new char[8] */ key_array[1] = pvVar1; /* "VOID" */ *(undefined4 *)key_array[1] = 0x404024; /* 5  4 (2- ) */ *(undefined2 *)((int)key_array[1] + 4) = 5; *(undefined2 *)((int)key_array[1] + 6) = 4; /* key_array[0][0] = 'b' */ *(undefined *)*key_array = 0x62; *(undefined4 *)((int)*key_array + 0x104) = 3; /* 'W' */ *(undefined *)((int)*key_array + 0x218) = 0x57; /* 'p' */ *(undefined *)((int)*key_array + 0x324) = 0x70; /* 'l' */ *(undefined *)((int)*key_array + 0x10c) = 0x6c; /* 152 ( ASCII) */ *(undefined *)((int)*key_array + 0x430) = 0x98; /*   = 1122 (int32) */ *(undefined4 *)((int)*key_array + 0x53c) = 0x462; } return key_array; } 


Satu-satunya tempat yang layak dipertimbangkan adalah baris ini:

 *(undefined4 *)key_array[1] = 0x404024; 

Bagaimana saya mengerti bahwa di sini terletak garis "BATAL"? Faktanya adalah 0x404024 adalah alamat di ruang alamat program yang mengarah ke bagian .rdata . Mengklik dua kali pada nilai ini memungkinkan kita untuk melihat dengan jelas apa yang ada di sana:



Omong-omong, hal yang sama dapat dipahami dari kode assembler untuk baris ini:

004015da c7 00 24 MOV dword ptr [EAX], .rdata = 56h V
40 40 00

Data yang sesuai dengan baris VOID ada di bagian paling awal .rdata data (pada titik nol offset dari alamat yang sesuai).

Jadi, saat keluar dari fungsi ini, array dua dimensi harus dibentuk dengan data berikut:

[0] [0]:'b' [268]:'l' [536]:'W' [804]:'p' [1072]:152 [1340]:1122
[1] [0-3]:"VOID" [4-5]:5 [6-7]:4

Langkah 7 - Siapkan biner untuk crack


Sekarang kita dapat memulai sintesis file biner. Semua data awal ada di tangan kita:
1) data verifikasi ("simbol berhenti") dan posisinya dalam array verifikasi;
2) urutan data dalam file

Mari kita kembalikan struktur file yang kita cari sesuai dengan algoritma fungsi _construct_key () . Jadi, urutan data dalam file adalah sebagai berikut:

Struktur file
  1. 4 byte == key_array [1] [0 ... 3] == "VOID"
  2. 2 byte == key_array [1] [4] == 5
  3. 2 byte == key_array [1] [6] == 4
  4. 1 byte == key_array [0] [0] == 'b' (token)
  5. 1 byte == (panjang baris berikutnya) == n_strlen1
  6. n_strlen1 byte == (sembarang string) == n_strlen1
  7. 1 byte == (+7 == token berikutnya) == n_pos
  8. 1 byte == key_array [0] [0] == 'l' (token)
  9. 1 byte == (panjang baris berikutnya) == n_strlen1
  10. n_strlen1 byte == (sembarang string) == n_strlen1
  11. 1 byte == (+7 == token berikutnya) == n_pos
  12. 1 byte == key_array [0] [0] == 'W' (token)
  13. 1 byte == (panjang baris berikutnya) == n_strlen1
  14. n_strlen1 byte == (sembarang string) == n_strlen1
  15. 1 byte == (+7 == token berikutnya) == n_pos
  16. 1 byte == key_array [0] [0] == 'p' (token)
  17. 1 byte == (panjang baris berikutnya) == n_strlen1
  18. n_strlen1 byte == (sembarang string) == n_strlen1
  19. 1 byte == (+7 == token berikutnya) == n_pos
  20. 1 byte == key_array [0] [0] == 152 (token)
  21. 1 byte == (panjang baris berikutnya) == n_strlen1
  22. n_strlen1 byte == (sembarang string) == n_strlen1
  23. 1 byte == (+7 == token berikutnya) == n_pos
  24. 4 byte == (key_array [1340]) == 1122


Untuk lebih jelasnya, saya membuat di Excel tablet seperti itu dengan data file yang diinginkan:



Di sini, di baris ke-7 - data itu sendiri dalam bentuk karakter dan angka, di baris ke-6 - representasi heksadesimal mereka, di baris ke-8 - ukuran setiap elemen (dalam bytes), di baris ke-9 - offset relatif ke awal file. Pandangan ini sangat nyaman karena memungkinkan Anda untuk memasukkan baris apa pun di file yang akan datang (ditandai dengan isian kuning), sementara nilai-nilai panjang garis-garis ini, serta offset posisi simbol pemberhentian berikutnya dihitung dengan formula secara otomatis, sesuai dengan algoritma program. Di atas (dalam baris 1-4), struktur array cek key_array ditampilkan .

Unggul itu sendiri plus bahan sumber lainnya untuk artikel dapat diunduh di sini .

Pembuatan dan validasi file biner


Satu-satunya yang tersisa adalah membuat file yang diinginkan dalam format biner dan mengumpankannya dengan crack kami. Untuk menghasilkan file, saya menulis skrip Python sederhana:

Script untuk menghasilkan file
 import sys, os import struct import subprocess out_str = ['!', 'I', ' solved', ' this', ' crackme!'] def write_file(file_path): try: with open(file_path, 'wb') as outfile: outfile.write('VOID'.encode('ascii')) outfile.write(struct.pack('2h', 5, 4)) outfile.write('b'.encode('ascii')) outfile.write(struct.pack('B', len(out_str[0]))) outfile.write(out_str[0].encode('ascii')) pos = 10 + len(out_str[0]) outfile.write(struct.pack('B', pos - 6)) outfile.write('l'.encode('ascii')) outfile.write(struct.pack('B', len(out_str[1]))) outfile.write(out_str[1].encode('ascii')) pos += 3 + len(out_str[1]) outfile.write(struct.pack('B', pos - 6)) outfile.write('W'.encode('ascii')) outfile.write(struct.pack('B', len(out_str[2]))) outfile.write(out_str[2].encode('ascii')) pos += 3 + len(out_str[2]) outfile.write(struct.pack('B', pos - 6)) outfile.write('p'.encode('ascii')) outfile.write(struct.pack('B', len(out_str[3]))) outfile.write(out_str[3].encode('ascii')) pos += 3 + len(out_str[3]) outfile.write(struct.pack('B', pos - 6)) outfile.write(struct.pack('B', 152)) outfile.write(struct.pack('B', len(out_str[4]))) outfile.write(out_str[4].encode('ascii')) pos += 3 + len(out_str[4]) outfile.write(struct.pack('B', pos - 6)) outfile.write(struct.pack('i', 1122)) except Exception as err: print(err) raise def main(): if len(sys.argv) != 2: print('USAGE: {this_script.py} path_to_crackme[.exe]') return if not os.path.isfile(sys.argv[1]): print('File "{}" unavailable!'.format(sys.argv[1])) return file_path = os.path.splitext(sys.argv[1])[0] + '.dat' try: write_file(file_path) except: return try: outputstr = subprocess.check_output('"{}" -f "{}"'.format(sys.argv[1], file_path), stderr=subprocess.STDOUT) print(outputstr.decode('utf-8')) except Exception as err: print(err) if __name__ == '__main__': main() 


Script mengambil path ke crack sebagai parameter tunggal, kemudian menghasilkan file biner dengan kunci di direktori yang sama dan memanggil crack dengan parameter yang sesuai, menerjemahkan output program ke konsol.

Untuk mengonversi data teks menjadi biner, gunakan paket struct . Metode pack () memungkinkan Anda untuk menulis data biner dalam format yang menunjukkan tipe data ("B" = "byte", "i" = int, dll.), Dan Anda juga dapat menentukan urutan (">" = "Besar -endian "," <"=" Little-endian "). Urutan default adalah Little-endian. Karena kami sudah menentukan dalam artikel pertama bahwa ini persis kasus kami, maka kami hanya menunjukkan jenisnya.

Semua kode secara keseluruhan mereproduksi algoritma program yang kami temukan. Sebagai garis yang akan dicetak jika berhasil, saya tentukan "Saya memecahkan crackme ini!" (Anda dapat memodifikasi skrip ini sehingga memungkinkan untuk menentukan baris apa pun).

Periksa output:



Hore, semuanya bekerja! Jadi, setelah sedikit berkeringat dan memilah-milah beberapa fungsi, kami dapat sepenuhnya memulihkan algoritma program dan "memecahkannya". Tentu saja, ini hanya retakan sederhana, program pengujian, dan bahkan tingkat kesulitan ke-2 (dari 5 yang ditawarkan di situs itu). Pada kenyataannya, kita akan berurusan dengan hierarki panggilan yang kompleks dan lusinan - ratusan fungsi, dan dalam beberapa kasus - bagian terenkripsi data, kode sampah dan teknik kebingungan lainnya, hingga penggunaan mesin virtual internal dan kode-P ... Tapi ini, seperti yang mereka katakan, sudah cerita yang sama sekali berbeda.

Bahan untuk artikel.

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


All Articles