Насколько опасен доступ к массиву за пределами?

Насколько опасен доступ к массиву за его пределами (в C)? Иногда может случиться, что я читаю извне массива (теперь я понимаю, что затем я получаю доступ к памяти, используемой некоторыми другими частями моей программы или даже за ее пределами), или я пытаюсь установить значение индекса вне массива. Программа иногда вылетает, но иногда просто запускается, только давая неожиданные результаты.

Теперь я хотел бы знать, насколько это опасно на самом деле? Если это повредит моей программе, это не так плохо. Если, с другой стороны, это что-то нарушает вне моей программы, потому что мне каким-то образом удалось получить доступ к какой-то совершенно не связанной памяти, то это очень плохо, я думаю. Я много читал: "все может случиться", "сегментация может быть наименее плохой проблемой", "ваш жесткий диск может стать розовым, а единороги могут петь под вашим окном", и это все хорошо, но в чем на самом деле опасность?

Мои вопросы:

  1. Может ли чтение значений вне массива повредить что-либо кроме моей программы? Я мог бы представить, что просто глядя на вещи ничего не изменится, или это, например, изменит атрибут "последний раз открыт" файла, к которому я попал?
  2. Может ли установка значений вне массива повредить что-нибудь помимо моей программы? Из этого вопроса о переполнении стека я понял, что можно получить доступ к любой ячейке памяти, что нет гарантии безопасности.
  3. Теперь я запускаю свои маленькие программы из XCode. Обеспечивает ли это дополнительную защиту моей программы, когда она не может выйти за пределы собственной памяти? Может ли это повредить XCode?
  4. Любые рекомендации о том, как безопасно выполнить мой по своей сути глючный код?

Я использую OSX 10.7, Xcode 4.6.

13 ответов

Что касается стандарта ISO C (официальное определение языка), доступ к массиву за его пределами имеет "неопределенное поведение". Прямое значение этого:

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

Ненормативная записка распространяется на это:

Возможное неопределенное поведение варьируется от полного игнорирования ситуации с непредсказуемыми результатами до поведения во время перевода или выполнения программы документированным образом, характерным для среды (с выдачей диагностического сообщения или без него), до прекращения перевода или выполнения (с выдачей диагностического сообщения).

Такова теория. Какова реальность?

В "лучшем" случае вы получите доступ к некоторому фрагменту памяти, который принадлежит либо вашей текущей запущенной программе (что может привести к неправильной работе вашей программы), либо не принадлежит вашей текущей запущенной программе (что, вероятно, приведет к тому, что ваша программа сбой с чем-то вроде ошибки сегментации). Или вы можете попытаться записать в память, которой владеет ваша программа, но она помечена только для чтения; это, вероятно, также приведет к сбою вашей программы.

Это предполагает, что ваша программа работает в операционной системе, которая пытается защитить одновременно запущенные процессы друг от друга. Если ваш код работает на "голом железе", скажем, если он является частью ядра ОС или встроенной системы, то такой защиты нет; Ваш неправильный код - это то, что должно было обеспечить такую ​​защиту. В этом случае возможности повреждения значительно выше, включая, в некоторых случаях, физический ущерб оборудованию (или вещам или людям поблизости).

Даже в защищенной среде ОС защита не всегда на 100%. Например, существуют ошибки операционной системы, которые позволяют непривилегированным программам получать root (административный) доступ. Даже с обычными пользовательскими привилегиями неисправная программа может потреблять чрезмерные ресурсы (ЦП, память, диск), что может привести к выходу из строя всей системы. Многие вредоносные программы (вирусы и т. Д.) Используют переполнения буфера для получения несанкционированного доступа к системе.

(Один исторический пример: я слышал, что на некоторых старых системах с памятью ядра, многократный доступ к одной области памяти в замкнутом цикле мог буквально привести к тому, что этот кусок памяти растаял. Другие возможности включают разрушение дисплея CRT и перемещение чтения / записать головку дисковода с частотой гармоник шкафа привода, заставляя его ходить по столу и падать на пол.)

И всегда есть о чем беспокоиться Скайнет.

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

На практике очень маловероятно, что ваша глючная программа, работающая в системе MacOS X, сделает что-то более серьезное, чем сбой. Но невозможно полностью предотвратить ошибочный код от действительно плохих действий.

В целом современные операционные системы (в любом случае, популярные) запускают все приложения в защищенных областях памяти с помощью диспетчера виртуальной памяти. Оказывается, это не очень легко (по крайней мере) просто читать или писать в место, которое существует в РЕАЛЬНОМ пространстве за пределами региона (-ов), которые были назначены / выделены вашему процессу.

Прямые ответы:

1) Чтение почти никогда не повредит напрямую другому процессу, однако может косвенно повредить процесс, если вам случится прочитать значение KEY, используемое для шифрования, дешифрования или проверки программы / процесса. Чтение за пределами может иметь несколько неблагоприятных / неожиданных последствий для вашего кода, если вы принимаете решения на основе данных, которые вы читаете

2) Единственный способ, которым вы могли бы действительно повредить что-либо, записав в локатор, доступный по адресу памяти, - это если адрес памяти, в который вы пишете, на самом деле является аппаратным регистром (местоположение, которое на самом деле не для хранения данных, а для управления какой-то частью оборудования) не место в оперативной памяти. На самом деле, вы все равно обычно не повредите что-либо, если не пишете какое-то одноразовое программируемое место, которое нельзя перезаписать (или что-то в этом роде).

3) Как правило, запуск из отладчика запускает код в режиме отладки. Работа в режиме отладки позволяет TEND (но не всегда) останавливать ваш код быстрее, когда вы сделали что-то, что считается непрактичным или совершенно незаконным.

4) Никогда не используйте макросы, используйте структуры данных, которые уже имеют встроенную проверку границ индекса массива и т. Д....

ДОПОЛНИТЕЛЬНО Я должен добавить, что приведенная выше информация действительно только для систем, использующих операционную систему с окнами защиты памяти. Если вы пишете код для встроенной системы или даже системы, использующей операционную систему (в режиме реального времени или другую), у которой нет окон защиты памяти (или окон с виртуальной адресацией), следует проявлять большую осторожность при чтении и записи в память. Также в этих случаях всегда следует применять методы кодирования SAFE и SECURE, чтобы избежать проблем с безопасностью.

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

Это означает, что ваш пользователь может отправить вам строку, которая заставит вашу программу по существу вызвать exec("/bin/sh"), который превратит его в оболочку, выполняя все, что он хочет в вашей системе, включая сбор всех ваших данных и превращение вашей машины в узел ботнета.

Смотрите Smashing The Stack для удовольствия и прибыли, чтобы узнать, как это можно сделать.

Ты пишешь:

Я много читал: "все может случиться", "сегментация может быть наименее плохой проблемой", "твой жесткий диск может стать розовым, а единороги могут петь под твоим окном", что все хорошо, но в чем на самом деле опасность?

Скажем так: зарядите пистолет. Направьте его за окно без особой цели и огня. В чем опасность?

Проблема в том, что вы не знаете. Если ваш код перезаписывает что-то, что приводит к сбою вашей программы, у вас все в порядке, потому что он остановит его в определенном состоянии. Однако, если это не терпит крах, тогда проблемы начинают возникать. Какие ресурсы находятся под контролем вашей программы и что она может с ними сделать? Какие ресурсы могут попасть под контроль вашей программы и что она может с ними сделать? Я знаю, по крайней мере, одну серьезную проблему, которая была вызвана таким переполнением. Проблема заключалась в, казалось бы, бессмысленной статистической функции, которая испортила некоторую несвязанную таблицу преобразования для производственной базы данных. Результатом была некоторая очень дорогая очистка впоследствии. На самом деле, было бы намного дешевле и проще справиться, если бы эта проблема отформатировала жесткие диски... другими словами: розовые единороги могли бы быть вашей наименьшей проблемой.

Идея о том, что ваша операционная система защитит вас, оптимистична. Если возможно, постарайтесь не писать без границ.

Если вы не запустите вашу программу от имени пользователя root или любого другого привилегированного пользователя, это не повредит вашей системе, поэтому обычно это может быть хорошей идеей.

Записывая данные в какую-либо произвольную область памяти, вы не будете напрямую "портить" любую другую программу, запущенную на вашем компьютере, поскольку каждый процесс выполняется в своем собственном пространстве памяти.

Если вы попытаетесь получить доступ к любой памяти, не выделенной вашему процессу, операционная система остановит выполнение вашей программы с ошибкой сегментации.

Таким образом, напрямую (без запуска от имени root и прямого доступа к файлам, таким как /dev/mem) нет никакой опасности, что ваша программа будет мешать любой другой программе, работающей в вашей операционной системе.

Тем не менее - и, вероятно, это то, о чем вы слышали в плане опасности, - слепо записывая случайные данные в случайные места памяти случайно, вы наверняка можете повредить все, что сможете.

Например, ваша программа может захотеть удалить определенный файл, заданный именем файла, хранящимся где-то в вашей программе. Если случайно вы просто перезаписали место, где хранится имя файла, вы можете удалить совершенно другой файл.

NSArrays в Objective-C назначается определенный блок памяти. Превышение границ массива означает, что вы будете обращаться к памяти, которая не назначена массиву. Это означает:

  1. Эта память может иметь любое значение. Там нет никакого способа узнать, являются ли данные действительными на основе вашего типа данных.
  2. Эта память может содержать конфиденциальную информацию, такую ​​как закрытые ключи или другие учетные данные пользователя.
  3. Адрес памяти может быть недействительным или защищенным.
  4. Память может иметь изменяющееся значение, потому что к ней обращается другая программа или поток.
  5. Другие вещи используют адресное пространство памяти, например порты с отображением в памяти.
  6. Запись данных на неизвестный адрес памяти может привести к сбою вашей программы, перезаписи пространства памяти ОС и, как правило, к взрыву солнца.

С точки зрения вашей программы вы всегда хотите знать, когда ваш код выходит за границы массива. Это может привести к тому, что будут возвращены неизвестные значения, что приведет к сбою приложения или предоставлению неверных данных.

Вы можете попробовать использовать memcheck инструмент в Valgrind, когда вы тестируете свой код - он не будет отлавливать нарушения отдельных массивов в пределах стекового фрейма, но он должен отлавливать многие другие виды проблем с памятью, в том числе те, которые могут вызвать тонкие, более широкие проблемы, выходящие за рамки единого функция.

Из руководства:

Memcheck - это детектор ошибок памяти. Он может обнаружить следующие проблемы, которые часто встречаются в программах на C и C++.

  • Получать доступ к памяти вам не нужно, например, переполнение и переполнение блоков кучи, переполнение вершины стека и доступ к памяти после ее освобождения.
  • Использование неопределенных значений, то есть значений, которые не были инициализированы или были получены из других неопределенных значений.
  • Неправильное освобождение памяти кучи, например, двойное освобождение блоков кучи, или неправильное использование malloc/new/new[] по сравнению с free/delete/delete[]
  • Перекрывающиеся указатели src и dst в memcpy и связанных функциях.
  • Утечки памяти.

ETA: Хотя, как говорится в ответе Kaz, это не панацея и не всегда дает наиболее полезные результаты, особенно когда вы используете захватывающие шаблоны доступа.

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

Если вы когда-либо занимаетесь программированием на системном уровне или программированием встраиваемых систем, очень плохие вещи могут случиться, если вы пишете в произвольные области памяти. Старые системы и многие микроконтроллеры используют IO с отображением в памяти, поэтому запись в область памяти, которая сопоставляется с периферийным регистром, может нанести ущерб, особенно если это делается асинхронно.

Примером является программирование флэш-памяти. Режим программирования на микросхемах памяти включается путем записи определенной последовательности значений в определенные места внутри диапазона адресов микросхемы. Если во время этого процесса другой процесс записывает данные в любое другое место в чипе, это приведет к сбою цикла программирования.

В некоторых случаях аппаратное обеспечение будет переносить адреса (наиболее значимые биты / байты адреса игнорируются), поэтому запись по адресу, выходящему за пределы физического адресного пространства, фактически приведет к записи данных прямо в середине.

И, наконец, старые процессоры, такие как MC68000, могут быть заблокированы до такой степени, что только аппаратный сброс может привести их в действие. Я не работал над ними пару десятилетий, но я полагаю, что когда он столкнулся с ошибкой шины (несуществующей памятью) при попытке обработать исключение, он просто остановился бы до тех пор, пока не был произведен аппаратный сброс.

Моя самая большая рекомендация - откровенный штекер для продукта, но я не заинтересован в нем, и я никоим образом не связан с ним - но основан на паре десятилетий программирования на C и встроенных системах, где надежность была критической, ПК Gimpel Lint не только обнаружит подобные ошибки, но и сделает из вас лучшего программиста на C/C++, постоянно обращая внимание на вредные привычки.

Я также рекомендую прочитать стандарт кодирования MISRA C, если вы можете получить копию от кого-то. Я не видел ни одного недавнего из них, но в старые времена они давали хорошее объяснение того, почему вы не должны / не должны делать то, что они освещают.

Не знаю о вас, но примерно во 2-й или 3-й раз, когда я получаю coredump или зависание из любого приложения, мое мнение о том, какая компания его произвела, снижается вдвое. В 4-й или 5-й раз и независимо от того, какая упаковка находится, она становится полкой, и я вбиваю деревянную колу через центр упаковки / диска, в который она попала, просто чтобы убедиться, что она никогда не вернется, чтобы преследовать меня.

Я работаю с компилятором для DSP-чипа, который намеренно генерирует код, который обращается к концу массива из кода C, который не имеет!

Это потому, что циклы структурированы так, что конец итерации предварительно выбирает некоторые данные для следующей итерации. Таким образом, данные, предварительно выбранные в конце последней итерации, фактически никогда не используются.

Подобный код на C вызывает неопределенное поведение, но это только формальность из документа стандартов, который касается максимальной переносимости.

Чаще всего это не так, программа, которая выходит за границы, хитроумно оптимизирована. Это просто глючит. Код извлекает некоторое значение мусора и, в отличие от оптимизированных циклов вышеупомянутого компилятора, код затем использует значение в последующих вычислениях, тем самым разрушая их.

Стоит отлавливать подобные ошибки, и поэтому стоит сделать поведение неопределенным даже по одной этой причине: во время выполнения может появиться диагностическое сообщение типа "переполнение массива в строке 42 файла main.c".

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

Кроме того, обратите внимание, что в C нам разрешено создавать указатель, который находится за концом массива. И этот указатель должен сравнивать больше любого указателя на внутреннюю часть массива. Это означает, что реализация C не может поместить массив прямо в конец памяти, где адрес "один плюс" будет оборачиваться и выглядеть меньше, чем другие адреса в массиве.

Тем не менее, доступ к неинициализированным или выходящим за пределы значениям иногда является допустимым методом оптимизации, даже если он не является максимально переносимым. Именно поэтому инструмент Valgrind не сообщает о доступе к неинициализированным данным, когда такой доступ происходит, а только тогда, когда значение позднее используется каким-либо образом, что может повлиять на результат программы. Вы получаете диагностику типа "условная ветвь в xxx:nnn зависит от неинициализированного значения", и иногда бывает трудно отследить, где оно происходит. Если бы все такие доступы были немедленно перехвачены, было бы много ложных срабатываний, возникающих из оптимизированного компилятором кода, а также из правильно оптимизированного вручную кода.

Говоря об этом, я работал с некоторым кодеком от поставщика, который выдавал эти ошибки при портировании на Linux и работе под Valgrind. Но поставщик убедил меня, что только несколько битов используемого значения на самом деле были получены из неинициализированной памяти, и эти биты были тщательно исключены логикой. Используются только хорошие биты значения, и Valgrind не имеет возможности отследить до отдельных бит. Неинициализированный материал был получен при чтении слова после конца потока битов закодированных данных, но код знает, сколько битов находится в потоке, и не будет использовать больше битов, чем есть на самом деле. Поскольку доступ за концом массива битового потока не наносит вреда архитектуре DSP (после массива нет виртуальной памяти, нет портов с отображением в памяти и адрес не переносится), это допустимый метод оптимизации.

"Неопределенное поведение" на самом деле не имеет большого значения, потому что согласно ISO C, простое включение заголовка, который не определен в стандарте C, или вызов функции, которая не определена в самой программе или стандарте C, являются примерами неопределенного поведение. Неопределенное поведение не означает "не определено никем на планете", просто "не определено стандартом ISO C". Но, конечно, иногда неопределенное поведение на самом деле абсолютно никем не определено.

Я просто хочу добавить к этим вопросам несколько практических примеров. Представьте себе следующий код:

      #include <stdio.h>

int main(void) {
    int n[5];
    n[5] = 1;

    printf("answer %d\n", n[5]);

    return (0);
}

Который имеет неопределенное поведение. Если вы включите, например, оптимизацию clang (-Ofast), это приведет к чему-то вроде :

      answer 748418584

(Что, если вы скомпилируете без, вероятно, выведет правильный результатanswer 1)

Это связано с тем, что в первом случае присваивание 1 на самом деле никогда не собирается в окончательном коде (вы также можете посмотреть в ассемблерном коде godbolt).

(Однако следует отметить, что по этой логике mainне следует даже звонить, поэтому лучший совет — не полагаться на оптимизатор для решения вашего UB, а лучше знать, что иногда это может работать таким образом)

Вывод здесь заключается в том, что современные компиляторы, оптимизирующие C, будут предполагать, что неопределенное поведение (UB) никогда не произойдет (это означает, что приведенный выше код будет похож на что-то вроде ( но не то же самое ):

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

int main(void) {
    int n[5];

    if (0)
        n[5] = 1;

    printf("answer %d\n", (exit(-1), n[5]));

    return (0);
} 

Что наоборот прекрасно определено).

Это потому, что первый условный оператор никогда не достигает своего истинного состояния (0всегда ложно).

А по второму аргументу заprintfу нас есть точка последовательности, после которой мы вызываемexitи программа завершается до вызова UB во втором операторе запятой (так что это правильно определено).

Итак, второй вывод заключается в том, что UB не является UB, пока он никогда не оценивался.

Кроме того, я не вижу здесь упоминания о довольно современном дезинфицирующем средстве Undefined Behavior (по крайней мере, на clang), который (с опцией -fsanitize=undefined) даст следующий вывод в первом примере (но не во втором):

      /app/example.c:5:5: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.c:5:5 in 
/app/example.c:7:27: runtime error: index 5 out of bounds for type 'int[5]'
SUMMARY: UndefinedBehaviorSanitizer: undefined-behavior /app/example.c:7:27 in 

Вот все образцы в godbolt:

https://godbolt.org/z/eY9ja4fdh (первый пример и без флагов)

(первый пример и -Ofast clang)

https://godbolt.org/z/cGcY7Ta9Mhttps://godbolt.org/z/cGcY7Ta9M (второй пример и дезинфицирующее средство UB включены)

https://godbolt.org/z/vE531EKo4 (первый пример и дезинфицирующее средство UB включены)

Массивы с двумя или более измерениями требуют рассмотрения помимо упомянутых в других ответах. Рассмотрим следующие функции:

      char arr1[2][8];
char arr2[4];
int test1(int n)
{
  arr1[1][0] = 1;
  for (int i=0; i<n; i++) arr1[0][i] = arr2[i];      
  return arr1[1][0];
}
int test2(int ofs, int n)
{
  arr1[1][0] = 1;
  for (int i=0; i<n; i++) *(arr1[0]+i) = arr2[i];      
  return arr1[1][0];
}

Способ, которым gcc будет обрабатывать первую функцию, не допускает возможности того, что попытка записи arr[0][i] может повлиять на значение arr[1][0], и сгенерированный код не может вернуть что-либо, кроме жестко запрограммированное значение 1. Хотя Стандарт определяет значение array[index] в точности эквивалентен (*((array)+(index))), похоже, gcc по-разному интерпретирует понятие границ массива и распада указателя в случаях, когда используется оператор [] для значений типа массива, по сравнению с теми, которые используют явную арифметику указателя.

Если это программа пользовательского пространства, работающая на защищенной ОС, такой как linux, то худшее, что вы увидите, это ошибка сегментации.

Другие вопросы по тегам