Showing posts with label POSIX. Show all posts
Showing posts with label POSIX. Show all posts

08/09/2021

Маленький взлом системы (1): наконец-то вы сможете изменять строки типа char* String

Вряд ли найдутся люди, готовые поспорить с тем, что невозможно сосчитать как часто нам хочется изменить содержание строки объявленной как

char* String = "Hello, World!";

Несмотря на то, что эта операция выглядит абсолютно логично и мы ожидаем от неё весьма конкретного и несложного результата, пытаясь сделать что-то вроде

String[1] = 'a';

мы получаем ошибку сегментации (Segmentation fault и сигнал SIGSEGV). И, как правило, на этом вся разработка заканчивается.

Ни в коем случае не пытайтесь это сделать — ваш процессор немедленно сгорит и все несохранённые данные будут потеряны!

Ошибка сегментации возникает в случае, когда процесс «лезет» в недоступную ему область памяти с целью записи (или чтения, см. ниже). А что это за область памяти такая, в которую мы не можем писать? В нашем примере, это область в сегменте данных, помеченная как память только для чтения — RO DATA. Посмотрим что получается в коде программы, в которой объявлена строка char* String. Из программы типа:

int main ()
{
  char* lString = "Hello, World!";
}

мы получим:

    .section   .rodata
.LC0:
    .string "Hello, World!"

Конечно, можно просто заменить .section .rodata на .section .data, но каждый раз вмешиваться в сборку проекта на не самом удобном этапе — трансляции, мы предлагать не будем.

Я решил рассмотреть функцию POSIX — mprotect(). Функция изменяет условия доступа к области памяти.

Но с этой функцией связана одна ошибка. В документации — как в man, так и в интернете, указано, что mprotect() помечает память начиная от адреса страницы до адреса страницы + длинна в байтах за вычетом единицы. В интернете я случайно нашёл упоминание, что длинна здесь указывается в количестве страниц. Что логично, так как MMU работает со страницами, а не с байтами. На нашем примере я подтвердил это — указывая в качестве длинны единицу я пометил достаточное количество памяти для всех наших переменных, то есть, видимо, mprotect() действительно берёт длину в количестве страниц.

Принимает функция на вход адрес, длину и флаг. В нашем примере нас будет интересовать, флаг и, как ни странно, адрес. Флаг нам нужен PROT_WRITE. А с адресом всё чуть более интересно. Так как MMU работает со страницами, начальный адрес, должен быть кратным размеру страницы. С тем, чтобы получить нужный нам адрес, мы вычислим ближайшую (в меньшую сторону) к интересующей нас переменной границу страницы памяти. Сделаем это мы следующим образом. Запросим размер страницы, сбросим у адреса переменной все правые биты, совпадающие с размером страницы. Проще говоря — обнулим адрес переменной справа на величину размера страницы.

Например: адрес lString равен 555555556004h, размер страницы получаем от системы, в моём случае он оказался равен 4096. Из размера страницы уберём единицу, так как фактически она адресуется с 0 по 4095, получим FFFh. Видно, что страницы с таким размером кратны трём полубайтам или полутора байтам. В нашем примере адрес ближайшей к переменной странице равен 555555556000h. Чтобы вычислить этот, адрес нужно сбросить крайние полтора байта адреса переменной. Инвертировав размер страницы получаем маску для логической операции FFFFFFFFFFFFF000h. Приведём это к типу void*, что даст нам полный размер адреса в памяти для любой архитектуры. В результате получим следующие результаты подготовки:

void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));
Длину, для нашего эксперимента мы поставим равной одной странице. Размер страницы позволит нам поиграть не только с записью в запретные места, но и с границами переменных.
mprotect (lPageBoundary, 1, PROT_WRITE);

Всё. С этого момента начинаются невиданные до сих пор чудеса. Например, следующая программа выводит «World!», а не столь неприятный и уже надоевший «Segmentation fault»:

#include "string.h"
#include "unistd.h"
#include "stdio.h"
#include "sys/mman.h"

int main ()
{
  char *lStr1 = "Hello";
  char *lStr2 = " orld!";
  void* lPageBoundary = (void*) ((long) lStr1 & ~(getpagesize () - 1));

  // Comment next line to turn magic off:
  mprotect (lPageBoundary, 1, PROT_WRITE);
  lStr2[0] = 'W';
  printf ("%s\n", lStr2);
}

Но я предлагаю пройти дальше и ещё чуть-чуть поиграть с адресами. Как вы можете видеть, я объявил две переменные — lStr1 и lStr2. char* — это переменная типа asciz или LPSZ (Long Pointer to Zero Terminated String) или, по-русски — строка оконченная нулём. Как известно, функции, работающие со строками, определяют их длину и окончание по этому самому нулю. Давайте проверим, что будет, если вместо оконечного нуля lStr1 поставить пробел и вывести эту строку при помощи printf(). Здесь же продемонстрируем что мы можем писать (изменять память) в пределах всей памяти модифицированной функцией mprotect(). Эксперимент с удалением последнего нуля из строки lStr1 я предлагаю реализовать путём записи по адресу строки lStr2 - 1:

lStr2[-1] = ' ';
printf ("%s\n", lStr1);

Так как строки расположены в памяти непосредственно одна за второй, получается, что я удалил завершающий нуль lStr1 и поставил не его место пробел. Соответственно, строка lStr1 перестала быть asciz и по логике, printf() должен «провалиться» дальше. Проверим это, получим вывод:

Hello World!

Всё верно, printf() прошёл до первого завершающего нуля, а им оказался завершающий нуль строки lStr2. Мы получили вывод «совмещённой» строки.

На самом деле это выглядит непривычно только на C. На ассемблере подобная работа с данными является повседневной нормой — например длинна строки (string или ascii/asciz) или массива может быть вычислена вычитанием адреса следующей за ней переменной из её собственного адреса.

Но и это ещё не всё. Возможно, к этому моменту, у вас в голове возникла мысль — «Если можно так, то, может, я смогу изменять значения переменных, объявленных как const»? Ответ положителен — эту давнюю мечту можно реализовать. Но с небольшим нюансом. Как мы знаем, компилятор нам не позволит писать в переменные, объявленные с модификатором const. Попытка сделать:

const char* lStr3 = "hello world!";
lStr3[0] = 'H';

приведёт к:

./mprotect.c:18:12: error: assignment of read-only location '*lStr3'
   lStr3[0] = 'H';
            ^

Для разрешения этой ситуации обманем компилятор следующим образом:

*((char*)lStr3 + 0) = 'H';

Мы взяли адрес от переменной, добавили к нему смещение и по нему прописали, что хотели. Теперь всё собирается и работает. За смещение здесь взят нуль, это не имеет технического смысла. Я написал здесь смещение для того чтобы в полной мере раскрыть форму записи доступа к переменным объявленным как const. По этой форме записи вы можете адресоваться на любой символ строки... и не только на него, и не только вперёд, так как мы «взяли» себе целую страницу.

NB
Вроде и очевидно, но считаю что я должен предупредить. Мы сняли запрет записи на область памяти, возможно вы снимете запрет на большую область памяти. Это значит, что вы можете модифицировать память без какого либо вообще контроля со стороны ОС, MMU и компилятора, так как мы полностью исключили этот функционал. Вы можете писать в эти области памяти, но, если вы хотите сохранить адекватную работоспособность своего кода, вам нужно ещё более тщательно следить за границами областей памяти изменяемых вами!

P.S
Возможно вы заметили, что мы использовали флаг доступа PROT_WRITE, без PROT_READ и, при этом мы читали данные из этих областей. Всё это было так потому что у MMU x86 (а этот эксперимент проводился на x86-ой машине) нет режима записи без чтения, поэтому можно поставить PROT_WRITE | PROT_READ, но смысла в этом нет. Если вы хотите поиграть с доступом, вы можете попробовать PROT_NONE. В этом случае вы не будете иметь никакого доступа к странице памяти и даже попытка чтения, например через printf(), приведёт к ошибке сегментации. Эту особенность можно было бы использовать каким-то образом на практике, но это затруднено тем, что мы можем помечать только целую страницу, а они бывают только 4Kb/2Mb/4Mb и 4Gb размером, в зависимости от архитектуры и/или режима работы. 4Kb — довольно много для использования механизмов доступа к памяти в качестве какой-нибудь «ловушки». Хотя, если программа достаточно большая, можно группировать флаги, на изменение которых в какие-то моменты мы хотим реагировать, в блоки по 4Kb и в обработчике сигнала реализовать логику реакции на сработавшее исключение защиты.

25/02/2020

Многопоточность в POSIX или Multithreading в ANSI C (1): поток и процесс, начало работы с libpthread

В этой статье мы научимся создавать, запускать и останавливать потоки в POSIX-совместимых системах на Plain C.
Сначала вкратце рассмотрим, что такое поток. Воспользуемся последним стандартом POSIX.1-2017, чтобы разобраться в терминологии:
Live Process
An address space with one or more threads executing within that address space, and the required system resources for those threads.
Здесь написано следующее: процесс есть адресное пространство с одним или более потоков выполняющимися в нём и необходимыми системными ресурсами для этих потоков.
Приписка «живой» нас не интересует, в стандарте используется термин Live Process для отличия его от Zombie Process. В этой статье мы будем говорить только о «живых».
Так же в стандарте написано, что многие системные ресурсы определённые стандартом являются общими для всех потоков в процессе. Эти ресурсы включают в себя ID процесса, ID родительского процесса, ID группы процессов, текущую директорию, файловые дескрипторы и пр.
Thread
A single flow of control within a process. Each thread has its own thread ID, scheduling priority and policy, errno value, floating point environment, thread-specific key/value bindings, and the required system resources to support a flow of control. Anything whose address may be determined by a thread, including but not limited to static variables, storage obtained via malloc(), directly addressable storage obtained through implementation-defined functions, and automatic variables, are accessible to all threads in the same process.
Что означает: поток есть одно «течение контроля» (нам, русскоязычным, привычнее термин «поток выполнения»). Каждый поток имеет собственный ID, приоритет планировщика и политики, значение errno и пр.. Всё, чей адрес может быть определён потоком, включая, но не ограничиваясь статическими переменными, динамически выделенная память и др. доступны всем потокам одного процесса.

Переведя это на русский язык окончательно, получим следующее: процесс — это инстанция программы, которая может содержать один или более потоков, поток — это блок кода, работающий параллельно с основным и с другими потоками.

Я считаю, что разобраться в этом было важно, так как всё (во всяком случае системное) программирование заключается в распределении ресурсов операционной системы и управлении ими, а то, что мы выше почерпнули из стандарта даёт следующие выводы:
  1. Мы можем пользоваться (читать, писать) ресурсами в своих потоках в пределах одного процесса без использования т.н. IPC (методы межпроцессного взаимодействия).
  2. Мы не можем обращаться к ресурсам других процессов без применения IPC. Даже из т.н. основного потока выполнения (main (), например), даже из под root'а. А POSIX хоть и предоставляет инструменты IPC, но это достаточно сложные механизмы.
Теперь рассмотрим технологию создания второго и последующих потоков. На всякий случай, напомню, первый у нас всегда есть — его создала ОС при запуске вашей программы (возможно, о том как это происходит я напишу). Этот поток я называю основным.
В самой по себе ANSI C нет механизмов работы с многопоточностью. Есть различные реализации работы с потоками. Например, специфичная GNU Portable Threads, Protothreads и пр. Первая преследовала цель максимальной портируемости, вторая — легковесности. Мы будем рассматривать самый распространённый (хотя и не самый оптимальный, почему я распишу в последующих статьях) стандарт, т.н. POSIX Threads (pthread). POSIX Threads API реализован в библиотеке libpthread.

Приведём пример простой многопоточной программы:

#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>

/*
 * Здесь мы проверяем, есть ли в системе POSIX Threads
 */
#ifndef _POSIX_THREADS
#error No POSIX Threads!
#endif

/*
 * Функция, код которой будет выполняться в новом потоке.
 * Обратите внимание на то, что мы можем передать сюда
 * указатель на область памяти. Это можно использовать
 * как механизм передачи некоторых начальных данных потоку.
 */
void *ThreadRoutine (void *_Arg)
{
  printf ("String argument passed to thread routine: %s\n", 
          (char *)_Arg);
  while (0)
  {
    sleep (1);
    printf ("Thread is doing infinite work...\n");
  }
  printf ("Thread is about to exit normally...\n");
  
  /*
   * Завершить выполнение потока можно двумя способами: 
   * return и pthread_exit () с возвращаемым нами значением 
   * в параметре. Они эквивалентны за исключением некоторой
   * (незначительной) разницы в быстродействии, что, в свою
   * очередь, связано с функционалом вызова обработчиков 
   * завершения потока. Но мы их здесь не будем рассматривать.
   */
  /* return (void *)(uintptr_t)0xC0FFEE; */
  pthread_exit ((void *)(uintptr_t)0xC0FFEE);
  /*
   * А что будет, если вызвать exit () из функции потока?
   * Приложение завершится, так как exit () это не возврат
   * из функции, а более сложный системный вызов, который
   * "корректно" закрывает приложение вместе со всеми 
   * потоками. Недопонимание этой разницы может возникнуть 
   * из-за того, что мы часто завершаем наше приложение 
   * простым return'ом в main'е. На самом деле, при 
   * "возврате" из main'а компилятор подставляет функционал 
   * "корректного" закрытия программы. Поэтому завершать 
   * работу потока нужно только через return/pthread_exit (). 
   * Вы можете заменить return/pthread_exit () на exit (0) 
   * и посмотреть что произойдёт - вы не увидите вывода 
   * "Thread return".
   */
  /* exit (0); */
}

int main (int _argc, char * _argv[])
{
  /* Идентификатор потока, на Linux'е это целое число, на
   * Mac OS это структура.
   */
  pthread_t thread_id;
  /* Возвращаемое значение функции потока */
  void *ThreadRet = NULL;
  /*
   * Функция pthread_create создаёт поток. Первый параметр - 
   * идентификатор потока (для дальнейших операций с ним). 
   * Второй - аттрибуты, третий - функция потока, четвёртый - 
   * аргумент (мы передадим туда название исполняемого файла).
   */
  pthread_create (&thread_id, NULL, ThreadRoutine, _argv[0]);
  /*
   * Функция pthread_join ожидает выхода из функции потока.
   * Так же здесь мы получаем возвращаемое значение из потока.
   * Перед тем как раскомментировать этот вызов, убедитесь, что
   * цикл while в функции потока выключен (например, поставьте 
   * while (0)). Иначе программа зависнет в ожидании завершения 
   * потока. Ведь pthread_join () ждёт корректного завершения
   * работы потока.
   */
   pthread_join (thread_id, &ThreadRet);
  /*
   * Перед тем как включить этот эксперимент, поставьте в функции
   * потока while (1), чтобы включить его бесконечную работу.
   * Функция pthread_cancel () аварийно завершает поток.
   * Дадим ему 5 секунд "пожить" и завершим.
   */
  /* sleep (5); */
  /* pthread_cancel (thread_id); */
  /* Выводим значение, возвращённое из функции потока */
  printf ("Thread return: %lX\n", (uintptr_t)ThreadRet);
  return EXIT_SUCCESS;
}


NB!
Обратите внимание на то, как при возврате из функции потока, я создаю указатель на некий адрес. Это своеобразный трюк. Чтобы не выделять память для возвращаемого значения, с последующим её освобождением, но вернуть простой int, как результат работы, я вернул указатель на адрес памяти, который придумал из головы. Вы можете возвращать указатели на любые адреса, и анализировать их по возврату из pthread_join (). Но стоит помнить, что обращаться по таким, вымышленным адресам нельзя.

Забрать пример:
https://gitlab.com/daftsoft/pthread1.git

Собирать программы с POSIX Threads нужно с указанием библиотеки pthread:
gcc -std=c89 -pedantic -Wall pthread1.c -lpthread -o pthread1

К слову, в Linux'е не всегда был реализован стандарт POSIX Threads. Как же были реализованы потоки в Linux'е до «принятия» POSIX Threads? В то время (c 2.0 до 2.4 включительно) в Linux'е была своя технология, называлась она LinuxThreads. В общих чертах, она заключалась в копировании кода процесса с предоставлением доступа к памяти родительского процесса. При этом, у потоков были разные идентификаторы процессов (PID) и взаимодействие между ними осуществлялось посредством одного из механизмов IPC — сигналов (USR1 и USR2), что исключало возможность использовать этих сигналов разработчиками. То есть, по современным меркам (или по меркам стандарта), это были не совсем потоки. Всё это несовершенство продолжалось до начала 2000-х годов, когда LinuxThreads была заменена библиотекой потоков POSIX (Native POSIX Thread Library, NPTL), реализующей стандарт POSIX Threads в Linux.
Представленного примера достаточно для того, чтобы начать писать свои первые многопоточные программы.
В следующей статье мы, на примере, рассмотрим как действительно работают «общие ресурсы» в потоках в пределах одного процесса, а так же иные аспекты управления потоками.