Статьи

Безопасное кодирование с параллелизмом в Swift 4

В моей предыдущей статье о безопасном кодировании в Swift я обсуждал основные уязвимости безопасности в Swift, такие как атаки с использованием инъекций. Хотя атаки с использованием инъекций распространены, существуют и другие способы, с помощью которых ваше приложение может быть взломано. Распространенная, но иногда упускаемая из вида уязвимость — это расовые условия.

Swift 4 представляет эксклюзивный доступ к памяти , который состоит из набора правил, предотвращающих одновременный доступ к одной и той же области памяти. Например, аргумент inout в Swift сообщает методу, что он может изменить значение параметра внутри метода.

1
func changeMe(_ x : inout MyObject, andChange y : inout MyObject)

Но что произойдет, если мы передадим одну и ту же переменную для одновременного изменения?

1
changeMe(&myObject, andChange:&myObject) // ???

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

Как только у вас есть несколько потоков, которым необходимо одновременно записывать одни и те же данные, может возникнуть состояние состязания. Гоночные условия вызывают повреждение данных. Для атак такого типа уязвимости, как правило, более тонкие, а эксплойты более творческие. Например, может существовать возможность изменить общий ресурс для изменения потока кода безопасности, происходящего в другом потоке, или в случае статуса аутентификации злоумышленник может воспользоваться преимуществом временного промежутка между временем проверки и время использования флага .

Способ избежать гонки условий заключается в синхронизации данных. Синхронизация данных обычно означает «блокировку» их так, чтобы только один поток мог одновременно обращаться к этой части кода (называемой мьютексом — для взаимного исключения). Хотя вы можете сделать это явно, используя класс NSLock , есть вероятность пропустить места, где код должен был быть синхронизирован. Отслеживание замков и их блокировка могут быть затруднены.

Вместо использования примитивных блокировок вы можете использовать Grand Central Dispatch (GCD) — современный параллельный API-интерфейс Apple, разработанный для обеспечения производительности и безопасности. Вам не нужно думать о замках самостоятельно; это делает работу за вас за кулисами.

01
02
03
04
05
06
07
08
09
10
DispatchQueue.global(qos: .background).async //concurrent queue, shared by system
{
    //do long running work in the background here
    //…
     
    DispatchQueue.main.async //serial queue
    {
        //Update the UI — show the results back on the main thread
    }
}

Как видите, это довольно простой API, поэтому используйте GCD в качестве первого выбора при разработке приложения для параллелизма.

Проверки безопасности во время выполнения Swift не могут выполняться в потоках GCD, поскольку это приводит к значительному снижению производительности. Решение состоит в том, чтобы использовать инструмент Thread Sanitizer, если вы работаете с несколькими потоками. Инструмент Thread Sanitizer отлично подходит для поиска проблем, которые вы никогда не найдете, взглянув на код самостоятельно. Его можно включить, выбрав « Продукт»> «Схема»> «Редактировать схему»> «Диагностика» и выбрав опцию « Средство очистки потока» .

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

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

Иногда даже не очевидно, что вы находитесь в фоновом потоке. Например, NSURLSession , если ему NSURLSession значение nil , будет по умолчанию перезванивать в фоновом потоке. Если вы обновляете пользовательский интерфейс или записываете свои данные в этом блоке, есть хорошие шансы на условия гонки. (Исправьте это, поместив обновления пользовательского интерфейса в DispatchQueue.main.async {} или передайте OperationQueue.main в качестве очереди делегата.)

Новым в Xcode 9 и включенным по умолчанию является средство проверки основных потоков ( Продукт> Схема> Изменить схему> Диагностика> Проверка API времени выполнения> Проверка основных потоков ). Если ваш код не синхронизирован, проблемы будут отображаться в Проблемы времени выполнения на левой панели навигатора XCode, поэтому обратите внимание на это при тестировании вашего приложения.

Для обеспечения безопасности любые обратные вызовы или обработчики завершения, которые вы пишете, должны быть задокументированы независимо от того, возвращаются они в основном потоке или нет. А еще лучше, следуйте более новому дизайну API Apple, который позволяет вам передавать completionQueue в методе, чтобы вы могли четко решить и в каком потоке возвращается блок завершения.

Хватит говорить! Давайте погрузимся в пример.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
class Transaction
{
    //…
}
 
class Transactions
{
    private var lastTransaction : Transaction?
     
    func addTransaction(_ source : Transaction)
    {
        //…
        lastTransaction = source
    }
}
 
//First thread
transactions.addTransaction(transaction)
         
//Second thread
transactions.addTransaction(transaction)

Здесь у нас нет синхронизации, но более чем один поток получает доступ к данным одновременно. Преимущество Thread Sanitizer в том, что он обнаружит такой случай. Современный способ GCD исправить это — связать ваши данные с последовательной очередью отправки.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
class Transactions
{
    private var lastTransaction : Transaction?
    private var queue = DispatchQueue(label: «com.myCompany.myApp.bankQueue»)
     
    func addTransaction(_ source : Transaction)
    {
        queue.async
        {
            //…
            self.lastTransaction = source
        }
    }
}

Теперь код синхронизируется с блоком .async . Вам может быть интересно, когда выбрать .async а когда использовать .sync . Вы можете использовать .async когда вашему приложению не нужно ждать окончания операции внутри блока. Это может быть лучше объяснено на примере.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
let queue = DispatchQueue(label: «com.myCompany.myApp.bankQueue»)
var transactionIDs : [String] = [«00001», «00002»]
         
//First thread
queue.async
{
    transactionIDs.append(«00003»)
}
//not providing any output so don’t need to wait for it to finish
         
//Another thread
queue.sync
{
    if transactionIDs.contains(«00001») //…Need to wait here!
    {
        print(«Transaction already completed»)
    }
}

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

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

1
2
3
4
get
{
    return queue.sync { transactionID }
}

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

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

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

Давайте сначала посмотрим, как выглядит искажение структуры (так называемая «гонка доступа Swift»).

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
struct Transaction
{
    private var id : UInt32
    private var timestamp : Double
    //…
             
    mutating func begin()
    {
        id = arc4random_uniform(101) // 0 — 100
        //…
    }
             
    mutating func finish()
    {
        //…
        timestamp = NSDate().timeIntervalSince1970
    }
}

Два метода в примере изменяют сохраненные свойства, поэтому они помечаются как mutating . Допустим, поток 1 вызывает begin() а поток 2 вызывает finish() . Даже если begin() изменяет только id а finish() изменяет только временную timestamp , это все равно гонка доступа. Хотя обычно лучше блокировать внутри методов доступа, это не относится к структурам, так как вся структура должна быть исключительной.

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

Вот пример:

01
02
03
04
05
06
07
08
09
10
11
12
13
class Bank
{
    private var currentTransaction : Transaction?
    private var queue : DispatchQueue = DispatchQueue(label: «com.myCompany.myApp.bankQueue»)
    func doTransaction()
    {
        queue.sync
        {
                currentTransaction?.begin()
                //…
        }
    }
}

Было бы бессмысленно иметь всю эту защиту, когда ваш интерфейс предоставляет UnsafeMutablePointer объект или UnsafeMutablePointer совместно используемым данным, потому что теперь любой пользователь вашего класса может делать с данными все, что он хочет, без защиты GCD. Вместо этого верните копии в данные в получателе. Тщательный дизайн интерфейса и инкапсуляция данных важны, особенно при разработке параллельных программ, чтобы гарантировать, что общие данные действительно защищены.

Убедитесь, что синхронизированные переменные помечены как private , а не как open или public , что позволит членам из любого исходного файла получить к нему доступ. Одним из интересных изменений в Swift 4 является то, что область уровня private доступа расширена, чтобы быть доступной в расширениях. Ранее это можно было использовать только во вложенном объявлении, но в Swift 4 доступ к private переменной можно получить в расширении, если расширение этого объявления находится в том же исходном файле.

Не только переменные подвержены риску повреждения данных, но и файлы. Используйте класс FileManager Foundation, который является потокобезопасным, и проверьте флаги результата его файловых операций, прежде чем продолжить в своем коде.

Многие объекты Objective-C имеют изменчивый аналог, обозначенный их заголовком. NSMutableString версия NSString называется NSMutableString , NSArrayNSMutableArray и так далее. Помимо того факта, что эти объекты могут быть видоизменены вне синхронизации, типы указателей, поступающие из Objective-C, также подрывают опции Swift. Есть хороший шанс, что вы можете ожидать объект в Swift, но из Objective-C он возвращается как ноль.

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

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

Предваряйте ваши переменные Objective-C nullable когда можно вернуть nil, и nonnull если это не так.

1
— (nonnull NSString *)myStringFromString:(nullable NSString *)string;

Вы также можете добавить nullable и nonnull в список атрибутов свойств Objective-C.

1
@property (nullable, atomic, strong) NSDate *date;

Инструмент Static Analyzer в XCode всегда отлично подходил для поиска ошибок Objective-C. Теперь с аннотациями обнуляемости, в Xcode 9 вы можете использовать Static Analyzer для вашего кода Objective C, и он найдет несоответствия обнуляемости в вашем файле. Сделайте это, перейдя к продукту> Выполнить действие> Анализ .

Хотя он включен по умолчанию, вы также можете контролировать проверки обнуляемости в LLVM с -Wnullability* флагов -Wnullability* .

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

1
2
3
4
5
6
guard let dog = animal.dog() else
{
    //handle this case
    return
}
//continue…

Чтобы помочь вам в этом, в Xcode 9 была добавлена ​​новая функция для выполнения проверок обнуляемости во время выполнения. Он является частью Undefined Behavior Sanitizer, и, хотя он не включен по умолчанию, вы можете включить его, выбрав « Параметры сборки»> «Undefined Behavior Sanitizer» и установив « Да» для « Включить проверки аннотаций Nullability» .

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

Допустим, класс был разработан без учета параллелизма. Позже требования изменились, и теперь он должен поддерживать .lock() и .unlock() NSLock . Когда приходит время устанавливать блокировки вокруг частей вашего кода, вам может потребоваться переписать множество ваших методов просто для обеспечения безопасности потоков. Легко пропустить return скрытый в середине метода, который позже должен был заблокировать ваш экземпляр NSLock , что может затем вызвать состояние гонки. Кроме того, такие операторы, как return , не будут автоматически разблокировать блокировку. Другая часть вашего кода, предполагающая, что блокировка разблокирована и повторная попытка блокировки, заблокирует приложение (приложение зависнет и в конечном итоге будет остановлено системой). Сбои также могут быть уязвимостями безопасности в многопоточном коде, если временные рабочие файлы никогда не очищаются до завершения потока. Если ваш код имеет такую ​​структуру:

1
2
3
4
5
6
7
if x
    if y
        return true
    else
        return false
return false

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

1
2
3
4
5
6
7
8
var success = false
// <— lock
if x
    if y
        success = true
// < — unlock
return success

Метод .unlock() должен вызываться из того же потока, который вызывал .lock() , в противном случае это приводит к неопределенному поведению.

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

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

Поиск ошибок — хорошая мотивация для повторного использования кода. Знание того, что вы исправили проблему в одном месте и вам не нужно искать все те же вхождения в коде копирования / вставки, может быть большим облегчением.

Состояние гонки может быть сложно найти во время тестирования, потому что память может быть повреждена только «правильным способом», чтобы увидеть проблему, и иногда проблемы появляются спустя долгое время во время выполнения приложения.

Когда вы тестируете, закройте весь свой код. Просмотрите каждый поток и регистр и протестируйте каждую строку кода хотя бы один раз. Иногда это помогает вводить случайные данные (нечеткие входные данные) или выбирать экстремальные значения в надежде найти крайний случай, который не был бы очевиден при просмотре кода или использовании приложения обычным способом. Это, наряду с новыми доступными инструментами XCode, может иметь большое значение для предотвращения уязвимостей в безопасности. Хотя ни один код не является на 100% безопасным, выполнение стандартных процедур, таких как ранние функциональные тесты, модульные тесты, тестирование системы, стресс-тесты и регрессионные тесты, действительно окупится.

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

Если вы не используете тестовую конфигурацию, убедитесь, что вы тестировали свое приложение в режиме выпуска, перейдя в « Продукт»> «Схема»> «Редактировать схему» . Выберите « Выполнить» из списка слева, а на информационной панели справа измените « Конфигурация сборки» на « Выпуск» . Хотя в этом режиме полезно охватить все приложение, знайте, что из-за оптимизации точки останова и отладчик будут работать не так, как ожидалось. Например, описания переменных могут быть недоступны, даже если код выполняется правильно.

В этом посте мы рассмотрели условия гонки и как их избежать путем безопасного кодирования и использования таких инструментов, как Thread Sanitizer. Мы также говорили об Эксклюзивном Доступе к Памяти, который является отличным дополнением к Swift 4. Убедитесь, что он установлен на Полное применение в Настройках сборки> Эксклюзивный доступ к памяти !

Помните, что эти принудительные меры включены только для режима отладки, и, если вы все еще используете Swift 3.2, многие из приведенных принудительных мер представляются только в форме предупреждений. Так что отнеситесь к предупреждениям серьезно, или, что еще лучше, используйте все новые функции, доступные, приняв Swift 4 сегодня!

И пока вы здесь, ознакомьтесь с некоторыми другими моими постами о безопасном кодировании для iOS и Swift!

  • iOS SDK
    Защита связи на iOS
    Коллин Штюрт
  • iOS SDK
    Защита данных iOS в состоянии покоя: защита данных пользователя
    Коллин Штюрт
  • Безопасность
    Создание цифровых подписей с помощью Swift
    Коллин Штюрт
  • Безопасность
    Безопасное кодирование в Swift 4
    Коллин Штюрт