Erstellen des ELF-Dateipackers x86_64 für Linux

Einleitung


Dieser Beitrag beschreibt die Erstellung eines einfachen ausführbaren Datei-Packers für Linux x86_64. Es wird davon ausgegangen, dass der Leser mit der Programmiersprache C, der Assemblersprache für die x86_64-Architektur und den Geräte-ELF-Dateien vertraut ist. Um die Übersichtlichkeit zu gewährleisten, wurde die Fehlerbehandlung aus dem Code im Artikel entfernt und die Implementierungen einiger Funktionen wurden nicht angezeigt. Den vollständigen Code finden Sie, indem Sie auf die Links zu github ( loader , packer) klicken.

Die Idee ist folgende: Wir übertragen die ELF-Datei auf den Packer und erhalten am Ausgang eine neue mit der folgenden Struktur:
ELF-Header
Programmtitel
CodesegmentPaketierter ELF-Datei-Downloader
Gepackte ELF-Datei
256 Bytes zufälliger Daten

Für die Komprimierung wurde entschieden, den Huffman-Algorithmus für die Verschlüsselung zu verwenden - AES-CTR mit einem 256-Bit-Schlüssel, nämlich die Implementierung von kokke tiny-AES-c . 256 Bytes Zufallsdaten werden verwendet, um den AES-Schlüssel und den Initialisierungsvektor unter Verwendung eines Pseudozufallszahlengenerators wie folgt zu initialisieren:

for(int i = 0; i < 32; i++) { seed = (1103515245*seed + 12345) % 256; key[i] = buf[seed]; } 

Diese Entscheidung wurde durch den Wunsch verursacht, das Reverse Engineering zu erschweren. Bisher wurde mir klar, dass Komplikationen unbedeutend sind, aber ich habe nicht damit begonnen, sie zu beseitigen, da ich keine Zeit und Energie darauf verwenden wollte.

Bootloader


Zunächst wird die Arbeit des Bootloaders betrachtet. Der Loader sollte keine Abhängigkeiten haben, daher müssen alle erforderlichen Funktionen aus der Standard-C-Bibliothek unabhängig geschrieben werden (die Implementierung dieser Funktionen ist per Referenz verfügbar). Es sollte auch positionsunabhängig sein.

_Startfunktion


Der Bootloader startet mit der Funktion _start, die argc und argv einfach an main übergibt:

 .extern main .globl _start .text _start: movq (%rsp), %rdi movq %rsp, %rsi addq $8, %rsi call main 

Hauptfunktion


Die Datei main.c definiert zunächst mehrere externe Variablen:

 extern void* loader_end; //    , .   //  ELF . extern size_t payload_size; //   ELF  extern size_t key_seed; //     // -   . extern size_t iv_seed; //     // -     

Alle von ihnen werden als extern deklariert, um die Position der Zeichen zu finden, die den Variablen (Elf64_Sym) im Packer entsprechen, und um ihre Werte zu ändern.

Die Hauptfunktion selbst ist recht einfach. Der erste Schritt ist das Initialisieren von Zeigern auf eine gepackte ELF-Datei, einen 256-Byte-Puffer und auf den oberen Bereich des Stapels. Dann wird die ELF-Datei entschlüsselt und erweitert, dann wird sie mit der Funktion load_elf an der richtigen Stelle im Speicher abgelegt, und schließlich kehrt der Wert des Registers rsp in den ursprünglichen Zustand zurück, und es erfolgt ein Sprung zum Programmeinstiegspunkt:

 #define SET_STACK(sp) __asm__ __volatile__ ("movq %0, %%rsp"::"r"(sp)) #define JMP(addr) __asm__ __volatile__ ("jmp *%0"::"r"(addr)) int main(int argc, char **argv) { uint8_t *payload = (uint8_t*)&loader_end; //    // ELF  uint8_t *entropy_buf = payload + payload_size; //   256- //  void *rsp = argv-1; //     struct AES_ctx ctx; AES_init_ctx_iv(&ctx, entropy_buf, key_seed, iv_seed); //  AES AES_CTR_xcrypt_buffer(&ctx, payload, payload_size); //  ELF memset(&ctx, 0, sizeof(ctx)); //   AES size_t decoded_payload_size; //  ELF char *decoded_payload = huffman_decode((char*)payload, payload_size, &decoded_payload_size); //     ELF  , //   ET_EXEC  NULL. void *load_addr = elf_load_addr(rsp, decoded_payload, decoded_payload_size); load_addr = load_elf(load_addr, decoded_payload); //  ELF  , //    //  . memset(decoded_payload, 0, decoded_payload_size); //   ELF munmap(decoded_payload, decoded_payload_size); //   //    //  ELF     AES AES_init_ctx_iv(&ctx, entropy_buf, key_seed, iv_seed); AES_CTR_xcrypt_buffer(&ctx, payload, payload_size); memset(&ctx, 0, sizeof(ctx)); SET_STACK(rsp); //    JMP(load_addr); //       } 

Das Zurücksetzen des AES-Status und der dekomprimierten ELF-Datei erfolgt aus Sicherheitsgründen, sodass der Schlüssel und die entschlüsselten Daten nur für die Dauer der Verwendung im Speicher gespeichert werden.

Als nächstes betrachten wir die Implementierung einiger Funktionen.

load_elf


Ich habe diese Funktion vom github-Benutzer mit dem Spitznamen bediger aus seinem userlandexec-Repository genommen und finalisiert, da die ursprüngliche Funktion bei Dateien wie ET_DYN abgestürzt ist. Der Fehler trat auf, weil der Wert des ersten Arguments des mmap-Systemaufrufs auf NULL gesetzt war und die Adresse ziemlich nahe am Hauptprogramm zurückgegeben wurde. Bei nachfolgenden Aufrufen von mmap und beim Kopieren von Segmenten an die von ihnen zurückgegebenen Adressen wurde der Code des Hauptprogramms überschrieben, und es trat ein Fehler auf. Aus diesem Grund wurde beschlossen, die Startadresse als Parameter zur Funktion load_elf hinzuzufügen. Die Funktion selbst durchläuft alle Programm-Header, weist den PT_LOAD-Segmenten der ELF-Datei Speicher zu (seine Nummer muss ein Vielfaches der Seitengröße sein), kopiert deren Inhalt in die zugewiesenen Speicherbereiche und legt die entsprechenden Lese-, Schreib- und Ausführungsrechte für diese Bereiche fest:

 //      #define PAGEUP(x) (((unsigned long)x + 4095)&(~4095)) //      #define PAGEDOWN(x) ((unsigned long)x&(~4095)) void* load_elf(void *load_addr, void *mapped) { Elf64_Ehdr *ehdr = mapped; Elf64_Phdr *phdr = mapped + ehdr->e_phoff; void *text_segment = NULL; unsigned long initial_vaddr = 0; unsigned long brk_addr = 0; for(size_t i = 0; i < ehdr->e_phnum; i++, phdr++) { unsigned long rounded_len, k; void *segment; //   PT_LOAD,    if(phdr->p_type != PT_LOAD) continue; if(text_segment != 0 && ehdr->e_type == ET_DYN) { //  ET_DYN phdr->p_vaddr    , //        //    ,      //     load_addr = text_segment + phdr->p_vaddr - initial_vaddr; load_addr = (void*)PAGEDOWN(load_addr); } else if(ehdr->e_type == ET_EXEC) { //  ET_EXEC phdr->p_vaddr     load_addr = (void*)PAGEDOWN(phdr->p_vaddr); } //        rounded_len = phdr->p_memsz + (phdr->p_vaddr % 4096); rounded_len = PAGEUP(rounded_len); //        segment = mmap(load_addr, rounded_len, PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); if(ehdr->e_type == ET_EXEC) load_addr = (void*)phdr->p_vaddr; else load_addr = segment + (phdr->p_vaddr % 4096); //         memcpy(load_addr, mapped + phdr->p_offset, phdr->p_filesz); if(!text_segment) { text_segment = segment; initial_vaddr = phdr->p_vaddr; } unsigned int protflags = 0; if(phdr->p_flags & PF_R) protflags |= PROT_READ; if(phdr->p_flags & PF_W) protflags |= PROT_WRITE; if(phdr->p_flags & PF_X) protflags |= PROT_EXEC; mprotect(segment, rounded_len, protflags); //   // , ,  k = phdr->p_vaddr + phdr->p_memsz; if(k > brk_addr) brk_addr = k; } if (ehdr->e_type == ET_EXEC) { brk(PAGEUP(brk_addr)); //  ET_EXEC ehdr->e_entry     load_addr = (void*)ehdr->e_entry; } else { //  ET_DYN ehdr->e_entry    , //           load_addr = (void*)ehdr + ehdr->e_entry; } return load_addr; //       } 

elf_load_addr


Diese Funktion für ET_EXEC-ELF-Dateien gibt NULL zurück, da sich Dateien dieses Typs an den darin angegebenen Adressen befinden sollten. Für ET_DYN-Dateien werden zuerst die Adresse berechnet, die der Differenz zwischen der Basisadresse des Hauptprogramms (d. H. Dem Bootloader), der zum Platzieren der ELF im Speicher erforderlichen Speichermenge und 4096, 4096 - der Lücke, die erforderlich ist, um die ELF-Datei nicht direkt neben dem Hauptprogramm zu platzieren. Nach der Berechnung dieser Adresse wird geprüft, ob sich der Speicherbereich von der angegebenen Adresse bis zur Basisadresse des Hauptprogramms mit dem Bereich vom Anfang der entpackten ELF-Datei bis zu ihrem Ende überschneidet. Im Falle einer Überschneidung wird die Adresse zurückgegeben, die der Differenz zwischen der Startadresse des entpackten ELF und der zum Platzieren erforderlichen Speichermenge entspricht, andernfalls wird die zuvor berechnete Adresse zurückgegeben.

Die Basisadresse des Programms wird ermittelt, indem die Adresse der Programmheader aus dem Hilfsvektor (ELF-Hilfsvektor) extrahiert wird, der sich hinter den Zeigern auf die Umgebungsvariablen im Stapel befindet, und die Größe des ELF-Headers davon subtrahiert wird:

       ---------------------------------------------------------------------------    -> [ argc ] 8 [ argv[0] ] 8 [ argv[1] ] 8 [ argv[..] ] 8 * x [ argv[n – 1] ] 8 [ argv[n] ] 8 (= NULL) [ envp[0] ] 8 [ envp[1] ] 8 [ envp[..] ] 8 [ envp[term] ] 8 (= NULL) [ auxv[0] (Elf64_auxv_t) ] 16 [ auxv[1] (Elf64_auxv_t) ] 16 [ auxv[..] (Elf64_auxv_t) ] 16 [ auxv[term] (Elf64_auxv_t) ] 16 (= AT_NULL) [  ] 0 - 16 [    ] >= 0 [   ] >= 0 [   ] 8 (= NULL) <    > 0 --------------------------------------------------------------------------- 

Die Struktur, mit der jedes Element des Hilfsvektors beschrieben wird, hat die Form:

 typedef struct { uint64_t a_type; //   union { uint64_t a_val; //  } a_un; } Elf64_auxv_t; 

Einer der gültigen Werte für a_type ist AT_PHDR. A_val zeigt dann auf die Programmheader. Das Folgende ist der Code für die Funktion elf_load_addr:

 void* elf_base_addr(void *rsp) { void *base_addr = NULL; unsigned long argc = *(unsigned long*)rsp; char **envp = rsp + (argc+2)*sizeof(unsigned long); //    //   while(*envp++); //        Elf64_auxv_t *aux = (Elf64_auxv_t*)envp; //    //  for(; aux->a_type != AT_NULL; aux++) { //        if(aux->a_type == AT_PHDR) { //   ELF ,     //      base_addr = (void*)(aux->a_un.a_val – sizeof(Elf64_Ehdr)); break; } } return base_addr; } size_t elf_memory_size(void *mapped) { Elf64_Ehdr *ehdr = mapped; Elf64_Phdr *phdr = mapped + ehdr->e_phoff; size_t mem_size = 0, segment_len; for(size_t i = 0; i < ehdr->e_phnum; i++, phdr++) { if(phdr->p_type != PT_LOAD) continue; segment_len = phdr->p_memsz + (phdr->p_vaddr % 4096); mem_size += PAGEUP(segment_len); } return mem_size; } void* elf_load_addr(void *rsp, void *mapped, size_t mapped_size) { Elf64_Ehdr *ehdr = mapped; if(ehdr->e_type == ET_EXEC) return NULL; size_t mem_size = elf_memory_size(mapped) + 0x1000; void *load_addr = elf_base_addr(rsp); if(mapped < load_addr && mapped + mapped_size > load_addr - mem_size) load_addr = mapped; return load_addr - mem_size; } 

Linker-Skript-Beschreibung


Es ist erforderlich, die Zeichen für die oben beschriebenen externen Variablen zu definieren und sicherzustellen, dass sich der Code und die Ladedaten nach dem Kompilieren im selben Textabschnitt befinden. Dies ist erforderlich, um den Maschinencode des Ladegeräts bequem zu extrahieren, indem der Inhalt dieses Abschnitts einfach aus der Datei ausgeschnitten wird. Um diese Ziele zu erreichen, wurde das folgende Linker-Skript geschrieben:

 ENTRY(_start) SECTIONS { . = 0; .text :{ *(.text) *(.text.startup) *(.data) *(.rodata) payload_size = .; QUAD(0) key_seed = .; QUAD(0) iv_seed = .; QUAD(0) loader_end = .; } } 

Es ist erwähnenswert, dass QUAD (0) 8 Bytes mit Nullen platziert, anstatt bestimmte Werte durch den Packer zu ersetzen. Um den Maschinencode auszuschneiden, wurde ein kleines Dienstprogramm geschrieben, das auch die Verschiebung des Einstiegspunkts zum Bootloader vom Start des Bootloaders an, den Versatz der Werte von payload_size, key_seed und iv_seed vom Start des Bootloaders an an den Anfang des Maschinencodes schreibt. Den Code für dieses Dienstprogramm finden Sie hier . Damit ist die Bootloader-Beschreibung beendet.

Direkter Packer


Betrachten Sie die Hauptfunktion des Packers. Es werden zwei Befehlszeilenargumente verwendet: Der Name der Eingabedatei lautet argv [1] und der Name der Ausgabedatei lautet argv [2]. Zunächst wird die Eingabedatei im Speicher angezeigt und auf Kompatibilität mit dem Packer überprüft. Der Packer funktioniert nur mit zwei Arten von ELF-Dateien: ET_EXEC und ET_DYN und nur mit statisch kompilierten. Der Grund für die Einführung dieser Einschränkung war die Tatsache, dass verschiedene Linux-Systeme unterschiedliche Versionen von gemeinsam genutzten Bibliotheken haben, d. H. Die Wahrscheinlichkeit, dass ein dynamisch kompiliertes Programm nicht auf einem anderen System als dem übergeordneten System gestartet wird, ist sehr hoch. Der entsprechende Code in der Hauptfunktion:

 size_t mapped_size; void *mapped = map_file(argv[1], &mapped_size); if(check_elf(mapped) < 0) return 1; 

Wenn die Eingabedatei danach die Kompatibilitätsprüfung besteht, wird sie komprimiert:

 size_t comp_size; uint8_t *comp_buf = huffman_encode(mapped, &comp_size); 

Als nächstes wird der AES-Status generiert und die komprimierte ELF-Datei wird verschlüsselt. Der Zustand von AES wird durch die folgende Struktur bestimmt:

 #define AES_ENTROPY_BUFSIZE 256 typedef struct { uint8_t entropy_buf[AES_ENTROPY_BUFSIZE]; // 256-  size_t key_seed; //      size_t iv_seed; //       struct AES_ctx ctx; //  AES-CTR } AES_state_t; 

Entsprechender Code in main:

 AES_state_t aes_st; for(int i = 0; i < AES_ENTROPY_BUFSIZE; i++) state.entropy_buf[i] = rand() % 256; state.key_seed = rand(); state.iv_seed = rand(); AES_init_ctx_iv(&state.ctx, state.entropy_buf, state.key_seed, state.iv_seed); AES_CTR_xcrypt_buffer(&aes_st.ctx, comp_buf, comp_size); 

Danach wird die Struktur, in der Informationen zum Bootloader gespeichert sind, initialisiert. Die Werte für payload_size, key_seed und iv_seed im Bootloader werden in die Werte geändert, die im vorherigen Schritt generiert wurden. Anschließend wird der AES-Status zurückgesetzt. Informationen zum Bootloader sind in der folgenden Struktur gespeichert:

 typedef struct { char *loader_begin; //      size_t entry_offset; //       size_t *payload_size_patch_offset; //     // ELF    size_t *key_seed_pacth_offset; //     //       size_t *iv_seed_patch_offset; //     //     //    size_t loader_size; //     } loader_t; 

Entsprechender Code in main:

 loader_t loader; init_loader(&loader); *loader.payload_size_patch_offset = comp_size; *loader.key_seed_pacth_offset = aes_st.key_seed; *loader.iv_seed_patch_offset = aes_st.iv_seed; memset(&aes_st.ctx, 0, sizeof(aes_st.ctx)); 

Im letzten Teil erstellen wir eine Ausgabedatei, schreiben einen ELF-Header, einen Programm-Header, einen Loader-Code, eine komprimierte und verschlüsselte ELF-Datei und einen 256-Byte-Puffer hinein:

 int out_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0755); //  //   write_elf_ehdr(out_fd, &loader); //  ELF  write_elf_phdr(out_fd, &loader, comp_size); //    write(out_fd, loader.loader_begin, loader.loader_size); //   write(out_fd, comp_buf, comp_size); //     ELF write(out_fd, aes_st.entropy_buf, AES_ENTROPY_BUFSIZE); //  // 256-  

Der Hauptcode des Packers endet hier, dann werden die folgenden Funktionen betrachtet: die Funktion zum Initialisieren von Informationen über den Bootloader, die Funktion zum Schreiben des ELF-Headers und die Funktion zum Schreiben des Programm-Headers.

Bootloader-Informationen werden initialisiert


Der Loader-Maschinencode wird mithilfe des folgenden einfachen Codes in die ausführbare Packer-Datei eingebettet:

 .data .globl _loader_begin .globl _loader_end _loader_begin: .incbin "loader" _loader_end: 

Um die Adresse im Speicher zu ermitteln, werden die folgenden Variablen in der Datei main.c deklariert:

 extern void* _loader_begin; extern void* _loader_end; 

Betrachten Sie als Nächstes die Funktion init_loader. Zunächst werden die folgenden Werte sequentiell eingelesen: Der Offset des Einstiegspunkts vom Start des Bootloaders (entry_offset), die Verschiebung der Größe der gepackten ELF-Datei vom Start des Bootloaders (payload_size_patch_offset), die Verschiebung des Anfangswerts des Generators für den Schlüssel vom Start des Bootloaders (key_seed_offset) Initialisierung ab dem Start des Bootloaders (iv_seed_patch_offset). Dann wird die Loader-Adresse zu den letzten drei Werten hinzugefügt. Wenn also Zeiger dereferenziert und ihnen Werte zugewiesen werden, werden die im Layout zugewiesenen Nullen (QUAD (0)) durch die benötigten Werte ersetzt.

 void init_loader(loader_t *l) { void *loader_begin = (void*)&_loader_begin; l->entry_offset = *(size_t*)loader_begin; loader_begin += sizeof(size_t); l->payload_size_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->key_seed_pacth_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->iv_seed_patch_offset = *(void**)loader_begin; loader_begin += sizeof(void*); l->payload_size_patch_offset = (size_t)l->payload_size_patch_offset + loader_begin; l->key_seed_pacth_offset = (size_t)l->key_seed_pacth_offset + loader_begin; l->iv_seed_patch_offset = (size_t)l->iv_seed_patch_offset + loader_begin; l->loader_begin = loader_begin; l->loader_size = (void*)&_loader_end - loader_begin; } 


write_elf_ehdr


 void write_elf_ehdr(int fd, loader_t *loader) { //  ELF  Elf64_Ehdr ehdr; memset(ehdr.e_ident, 0, sizeof(ehdr.e_ident)); memcpy(ehdr.e_ident, ELFMAG, SELFMAG); ehdr.e_ident[EI_CLASS] = ELFCLASS64; ehdr.e_ident[EI_DATA] = ELFDATA2LSB; ehdr.e_ident[EI_VERSION] = EV_CURRENT; ehdr.e_ident[EI_OSABI] = ELFOSABI_NONE; ehdr.e_type = ET_DYN; ehdr.e_machine = EM_X86_64; ehdr.e_version = EV_CURRENT; ehdr.e_entry = sizeof(Elf64_Ehdr) + sizeof(Elf64_Phdr) + loader->entry_offset; ehdr.e_phoff = sizeof(Elf64_Ehdr); ehdr.e_shoff = 0; ehdr.e_flags = 0; ehdr.e_ehsize = sizeof(Elf64_Ehdr); ehdr.e_phentsize = sizeof(Elf64_Phdr); ehdr.e_phnum = 1; ehdr.e_shentsize = sizeof(Elf64_Shdr); ehdr.e_shnum = 0; ehdr.e_shstrndx = 0; write(fd, &ehdr, sizeof(ehdr)); //     return 0; } 

Hier erfolgt die Standardinitialisierung des ELF-Headers und das anschließende Schreiben in eine Datei. Beachten Sie nur, dass in ET_DYN-ELF-Dateien das vom ersten Programm-Header beschriebene Segment nicht nur den ausführbaren Code, sondern auch den ELF-Header und alle Header enthält Programme. Daher sollte sein Versatz von Anfang an gleich Null sein, die Größe sollte die Summe der Größe des ELF-Headers, aller Programmheader und des ausführbaren Codes sein, und der Eintrittspunkt wird als die Summe der Größe des ELF-Headers, der Größe aller Programmheader und des Versatzes von Anfang an des ausführbaren Codes bestimmt.

write_elf_phdr


 void write_elf_phdr(int fd, loader_t *loader, size_t payload_size) { //    Elf64_Phdr phdr; phdr.p_type = PT_LOAD; phdr.p_offset = 0; phdr.p_vaddr = 0; phdr.p_paddr = 0; phdr.p_filesz = sizeof(Elf64_Ehdr) + sizeof(Elf64_Phdr) + loader->loader_size + payload_size + AES_ENTROPY_BUFSIZE; phdr.p_memsz = phdr.p_filesz; phdr.p_flags = PF_R | PF_W | PF_X; phdr.p_align = 0x1000; write(fd, &phdr, sizeof(phdr)); //      } 

Hier wird der Programmkopf initialisiert und dann in eine Datei geschrieben. Beachten Sie den Versatz zum Dateianfang und die Größe des durch den Programmkopf beschriebenen Segments. Wie im vorherigen Absatz beschrieben, enthält das von diesem Header beschriebene Segment nicht nur den ausführbaren Code, sondern auch den ELF-Header und den Programm-Header. Wir machen das Segment auch mit ausführbarem Code zum Schreiben zugänglich. Dies liegt an der Tatsache, dass die im Bootloader verwendete AES-Implementierung Daten „an Ort und Stelle“ verschlüsselt und entschlüsselt.

Einige Fakten über die Arbeit des Packers


Während des Tests wurde festgestellt, dass die mit glibc statisch kompilierten Programme beim Start auf segfault gehen.

  movq% fs: 0x28,% rax 

Ich konnte nicht herausfinden, warum dies passiert. Ich freue mich, wenn Sie Informationen zu diesem Thema weitergeben. Anstelle von glibc kann auch musl-libc verwendet werden, alles funktioniert einwandfrei. Außerdem wurde der Packer mit statisch kompilierten Golang-Programmen getestet, beispielsweise einem http-Server. Für vollständige statische Abstürze von Golang-Programmen müssen die folgenden Flags verwendet werden:

  CGO_ENABLED = 0 go build -a -ldflags '-extldflags "-static"'. 

Das letzte, mit dem der Packer getestet wurde, waren ET_DYN-ELF-Dateien ohne dynamischen Linker. Wenn Sie mit diesen Dateien arbeiten, schlägt die Funktion elf_load_addr möglicherweise fehl. In der Praxis kann es vom Bootloader getrennt werden und eine feste Adresse verwenden, zum Beispiel 0x10000.

Fazit


Dieser Packer ist offensichtlich nicht sinnvoll für den vorgesehenen Zweck zu verwenden, da die von ihm geschützten Dateien ziemlich leicht entschlüsselt werden können. Ziel dieses Projekts war es, die Arbeit mit ELF-Dateien besser zu beherrschen, sie zu generieren und sich auf die Erstellung eines vollständigeren Packers vorzubereiten.

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


All Articles