Android: исследование IPC Android и хукинг нативных библиотек

Android: исследование IPC Android и хукинг нативных библиотек

Life-Hack [Жизнь-Взлом]/Хакинг

#Обучение

Сегодня в выпуске: исследование IPC-механизмов Android, хукинг нативных библиотек с помощью Frida, правильный способ завершения короутин в Kotlin, введение в Kotlin Flow и StateFlow, перегрузка операторов в Kotlin, оператор Elvis и основы функционального программирования. А также подборка инструментов пентестера и библиотек для разработчиков.

ПОЧИТАТЬ

Как устроена система сообщений в Android

Android IPC: Part 2 — Binder and Service Manager Perspective — статья о Binder, одной из ключевых технологий Android.

Вопреки расхожему мнению, Android с самых первых версий использовал песочницы для изоляции приложений. И реализованы они были весьма интересным способом. Каждое приложение запускалось от имени отдельного пользователя Linux и, таким образом, имело доступ только к своему каталогу внутри /data/data.

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

Операционная система спроектирована так, что пользователи и даже программисты не догадываются о существовании какого-то IPC-механизма. Если программист хочет скопировать текст в буфер обмена, он просто получает ссылку на объект-сервис и вызывает один из его методов. Под капотом фреймворк преобразует этот вызов в сообщение Binder и отправляет его в ядро через файл-устройство /dev/binder. Это сообщение перехватывает Service manager, который находит в своем каталоге сервис буфера обмена, проверяет полномочия приложения на отправку ему сообщений и, если оно имеет все необходимые права, передает сообщение сервису. После получения и обработки сообщения сервис буфера обмена отправляет ответ, используя тот же Binder.

Кроме полномочий, Service manager также проверяет, имеет ли приложение право на создание сервиса и может ли оно использовать ряд опасных сервисов. И первое, и второе — проверяя значение UID (идентификатор пользователя) вызывающего процесса. Если UID больше 10 000 — приложение не имеет права регистрировать новый сервис (все сторонние приложения в Android получают UID больше 10 000), а если UID находится в районе 99 000–99 999, приложение получает ограничение на использование «опасных» сервисов. Именно в эту группу попадают вкладки браузера Chrome.

Хукинг нативных библиотек с помощью Frida

How to hook Android Native methods with Frida (Noob Friendly) — статья о перехвате функций нативных библиотек с помощью Frida.

Для начала файл APK следует развернуть. Это обычный архив ZIP, поэтому можно использовать любой архиватор. Каталог lib содержит набор нативных библиотек для различных архитектур. Находим библиотеку для архитектуры своего смартфона (обычно это arm64-v8a или armeabi-v7a) и анализируем ее содержимое с помощью утилиты nm (она доступна в Linux и macOS):

$ nm --demangle --dynamic libnative-lib.so 
00002000 A __bss_start
         U __cxa_atexit
         U __cxa_finalize
00002000 A _edata
00002000 A _end
00000630 T Java_com_erev0s_jniapp_MainActivity_Jniint
000005d0 T Jniint
         U rand
         U srand
         U __stack_chk_fail
         U time

Как видно, библиотека содержит в том числе функцию Java_com_erev0s_jniapp_MainActivity_Jniint. Судя по имени, она должна быть доступна для вызова из Java (на стороне Java она будет иметь имя com.erev0s.jniapp.MainActivty.Jniint).

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

Есть два способа перехватить эту функцию:

  1. На стороне Java, когда приложение только попытается вызвать нативную функцию.
  2. На стороне нативного кода, когда управление уже будет передано библиотеке.

Предположим, мы уже установили Frida на ПК и frida-server на смартфон (как это сделать, можно прочитать здесь). Теперь напишем такой скрипт:

Java.perform(function () {
    var Activity = Java.use('com.erev0s.jniapp.MainActivity')
    Activity.Jniint.implementation = function () {
        return 80085
    }
})

Этот скрипт переписывает функцию Jniint класса com.erev0s.jniapp.MainActivity так, чтобы она всегда возвращала значение 80 085.

Сохраняем скрипт в файл myhook.js и запускаем приложение под управлением Frida:

$ frida -U -l myhook.js com.erev0s.jniapp

Перехватить нативную функцию еще проще:

Interceptor.attach(Module.getExportByName('libnative-lib.so', 'Java_com_erev0s_jniapp_MainActivity_Jniint'), {
    onEnter: function(args) {},
    onLeave: function(retval) {
      retval.replace(0)
    }
}) 
Результат работы скрипта

РАЗРАБОТЧИКУ

Как правильно завершать короутины

Cancellation in coroutines — статья о работе с Kotlin Coroutines, а точнее о том, как правильно их завершать.

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

val job1 = scope.launch { … }
val job2 = scope.launch { … }

scope.cancel()

Второй — прямое завершение отдельно взятых короутин:

val job1 = scope.launch { … }
val job2 = scope.launch { … }

job1.cancel()

Вроде бы все просто. Но есть несколько неочевидных моментов:

  1. Завершение короутины должно быть кооперативным. Сам по себе метод cancel() не завершает короутину, а лишь посылает ей сигнал завершения. Короутина должна сама проверить, получила ли она его, используя поле Job.isActive и метод ensureActive(). Первое будет содержать false, если сигнал завершения пришел, второй выбросит CancellationException.
  2. Все стандартные suspend-функции пакета kotlinx.coroutines (withContext()delay() и так далее) умеют реагировать на сигнал завершения, поэтому при их использовании программисту необязательно делать это самостоятельно.
  3. При завершении короутины ее родитель получает исключение CancellationException.
  4. Завершенный CoroutineScope больше нельзя использовать для запуска короутин.

Swift guard против Kotlin Elvis

Why Kotlin’s Elvis Operator is Better Than Swift’s Guard Statement — заметка о том, почему языковые конструкции для защиты от разыменования null-переменной в Kotlin лучше, чем в Apple Swift.

Все, кто программировал на Swift, должны знать о ключевом слове guard:

func process(couldBeNullMesage: String?) {
    guard let notNullMessage = couldBeNullMesage else { return }
    printMessage(notNullMessage)
}

func printMessage(message: String) {
    print(message)
}

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

Теперь посмотрим, как сделать то же самое в Kotlin с помощью четырех разных способов (от худшего к лучшему).

1. Ключевое слово let

fun process(couldBeNullMesage: String?) {
    couldBeNullMesage?.let {
        printMessage(it)
    }
}

fun printMessage(message: String) {
    println(message)
}

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

2. Оператор Elvis

fun process(couldBeNullMesage: String?) {
    val notNullMessage = couldBeNullMesage ?: return
    printMessage(notNullMessage)
}

fun printMessage(message: String) {
    println(message)
}

Результат идентичен работе оператора guard, но сама запись короче.

3. Старый добрый if-else

fun process(couldBeNullMesage: String?) {
    if (couldBeNullMesage == null) return
    printMessage(couldBeNullMesage)
}

fun printMessage(message: String) {
    println(message)
}

Компилятор Kotlin достаточно умный, чтобы понять, что в функции printMessage используется переменная, которая уже не может быть null.

4. И снова оператор Elvis

fun process(couldBeNullMesage: String?) {
    couldBeNullMesage ?: return
    printMessage(couldBeNullMesage)
}

fun printMessage(message: String) {
    println(message)
}

Здесь мы совместили лаконичность оператора Elvis и автоматического приведения типов из предыдущего примера.

Итого

// Swift 
guard let notNullMessage = couldBeNullMesage else { return }
// Kotlin
couldBeNullMesage ?: return

Удобное логирование

Android Logging on Steroids: Clickable Logs With Location Info — заметка о том, как сделать логи более информативными и приятными для чтения.

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

Нам понадобится библиотека логирования Timber. Подключаем ее к проекту:

implementation 'com.jakewharton.timber:timber:4.7.1'

Далее добавляем в метод onCreate() приложения следующие строки:

class HyperlinkedDebugTree : Timber.DebugTree() {
    override fun createStackElementTag(element: StackTraceElement): String? {
        with(element) {
            return "($fileName:$lineNumber) $methodName()"
        }
    }
}

Timber.plant(HyperlinkedDebugTree())

Это все, теперь все вызовы Timber.d() будут формировать логи со ссылкой на исходный код.

Чтобы сделать жизнь еще проще, создадим следующую функцию-расширение:

inline fun Any?.log(prefix: String = "object:") = Timber.d("$prefix ${toString()}")

Теперь содержимое любой переменной (например, id) можно вывести в лог таким образом:

id.log()

Введение в Kotlin Flow

Into the Flow: Kotlin cold streams primer — одна из лучших статей о новой возможности Kotlin под названием Flow.

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

suspend fun doSomething(): List<Something>

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

Для их решения в Kotlin есть другой инструмент под названием Flow (поток):

fun doSomething(): Flow<Something>

Обрати внимание, что при объявлении функции мы не использовали ключевое слово suspend. Это потому, что на самом деле, какой бы сложной ни была функция, при запуске она ничего не делает и сразу возвращает управление. Функция начинает работать только после того, как мы вызовем метод collect() полученного от функции объекта. Например:

flow.collect { something -> println(something) }

Именно поэтому разработчики Kotlin называют потоки холодными, в противовес горячим каналам (Channel), которые также присутствуют в языке.

Для создания самого потока можно использовать функцию flow:

fun doSomething(): Flow<Int> = flow { 
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}

В данном случае функция «выпускает» в поток три объекта типа Int с перерывом в 100 миллисекунд. Обрати внимание, что flow — это suspend-функция, которая может запускать другие suspend-функции (в данном случае delay()).

Как уже было сказано выше, функция, возвращающая поток, не должна быть suspend-функцией. Но метод collect() объекта типа Flow — suspend-функция, которая должна работать внутри CoroutineScope:

val something = doSomething()

viewModelScope.launch {
    something.collect { value -> println(value) }
}

Создать поток можно и другими способами, например с помощью метода asFlow():

listOf(1,2,3).asFlow()
(1..3).asFlow()

Завершить поток возможно несколькими способами — от вызова collect() до методов типа first()fold()toList(), знакомых тебе по работе с коллекциями.

Сам поток можно трансформировать, чтобы получить новый поток с помощью методов map()filter()take():

(1..3).asFlow() 
    .transform { number ->
        emit(number*2)
        delay(100)
        emit(number*4) 
    }
    .collect { number -> println(number) }

По умолчанию функции flow и collect запускаются внутри текущего CoroutineScope, но его можно изменить, используя метод flowOn():

fun doSomething(): Flow<Int> = flow { 
    // Этот код будет выполнен внутри Dispatchers.IO (фоновый поток)
    for (i in 1..3) {
        delay(100)
        emit(i)
    }
}.flowOn(Dispatchers.IO)

[...]

viewModelScope.launch {
    doSomething().collect { value ->
        // Этот код будет выполнен внутри основного потока приложения
        print (value)
    } 
}

Несколько потоков можно объединить в один с помощью метода zip():

val flowA = (1..3).asFlow()
val flowB = flowOf("one", "two", "three")
flowA.zip(flowB) { a, b -> "$a and $b" }
     .collect { println(it) }

Этот код объединит каждый элемент первого потока с соответствующим элементом второго потока:

1 and one
2 and two
3 and three

Другой вариант объединения — функция combine():

al flowA = (1..3).asFlow()
val flowB = flowOf("single item")
flowA.combine(flowB) { a, b -> "$a and $b" }
     .collect { println(it) }

В данном случае каждый элемент первого потока будет объединен с последним элементом второго потока:

1 and single item
2 and single item
3 and single item

После трансформации потоков мы можем получить структуры данных, включающие в себя потоки потоков (Flow<Flow<X>>). Чтобы «выровнять» такие данные, можно использовать один из следующих методов:

  • flatMapConcat() — возвращает поток, который возвращает все элементы первого вложенного потока, затем все элементы второго потока и так далее;
  • flatMapMerge() — возвращает поток, в который попадают элементы из всех вложенных потоков в порядке очередности;
  • flatMapLatest() — возвращает последний вложенный поток.

Kotlin Flow и StateFlow

StateFlow, End of LiveData? — статья о StateFlow, новом классе библиотеки короутин Kotlin (начиная с 1.3.6) для хранения состояний.

Сразу начнем с примера:

fun main() = runBlocking {
    val stateFlow = MutableStateFlow<Int>(0)

    // Следим за изменением состояния
    val job = launch {
        stateFlow.collect {
            print("$it ")
        }
    }

    // Изменяем состояние
    (1..5).forEach {
        delay(500)
        stateFlow.value = it
    }

    job.cancel()
    job.join()
}

StateFlow базируется на потоках и позволяет разным компонентам приложения менять состояние и реагировать на изменение этого состояния.

В Android StateFlow можно использовать в качестве более продвинутого аналога LiveData. Создадим, например, следующую ViewModel:

@ExperimentalCoroutinesApi
class MainViewModel : ViewModel() {
    private val _countState = MutableStateFlow(0)

    val countState: StateFlow<Int> = _countState

    fun incrementCount() {
        _countState.value++
    }

    fun decrementCount() {
        _countState.value--
    }
}

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

class MainActivity : AppCompatActivity() {
    private val viewModel by lazy {
        ViewModelProvider(this)[MainViewModel::class.java]
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        initCountObserver()
        initView()
    }
}

private fun initCountObserver() {
    lifecycleScope.launch {
        viewModel.countState.collect { value ->
            textview_count.text = "$value"
        }
    }
}

private fun initView() {
    button_plus.setOnClickListener(::incrementCounter)
    button_minus.setOnClickListener(::decrementCounter)
}

private fun incrementCounter(view: View) {
    viewModel.incrementCount()
}

private fun decrementCounter(view: View) {
    viewModel.decrementCount()
}

Это все. Нажатие кнопки изменения числа изменит состояние ViewModel, а это, в свою очередь, приведет к автоматическому изменению TextView. И все это работает с учетом жизненного цикла активности благодаря использованию lifecycleScope.

Основы функционального программирования на Kotlin

Functional Programming in Kotlin — серия статей о функциональном программировании на Kotlin.

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

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

Пример чистой функции на языке Kotlin:

fun division(x: double, y: Double): Double = x / y

Пример нечистой функции:

fun addItems(value: Int, list: MutableList<Int>): List<Int> {
    list.add(value)
    return list
}

Функция из второго примера изменяет один из своих аргументов (list) и поэтому не может считаться чистой.

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

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

fun f(x: Int) = x + 1
fun g(x: Int) = 2 * x
println(f(g(2))

Но это неверно. Настоящая композиция должна выполняться как операция над функциями:

fun f(x: Int) = x + 1
fun g(x: Int) = 2 * x
fun compose(f: (Int) -> Int, g: (Int) -> Int): (Int) -> Int = { x -> f(g(x)) }

val fog = compose(::f, ::g)
println(fog(2))

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

fun r1(x: Boolean): Int = if(x) 1 else 0
fun r2(x: Int): Boolean = if(x == 0) false else true

fun <T, U, V>compose(f: (U) -> V, g: (T) -> U): (T) -> V = { f(g(it)) }

val r1of2 = compose<Int, Boolean, Int>(::r1, ::r2)
println(r1of2(2))

Перегрузка операторов в Kotlin

Code expressivity++ with operator overloading — статья о перегрузке операторов на примере, как бы странно это ни звучало, хора певцов.

Допустим, у нас есть класс Choir (хор), в который можно добавлять певцов (Singer):

class Choir {
    private val singers = mutableListOf<Singer>()

    fun addSinger(singer: Singer) {
        singers.add(singer)
    }

    ...
}

Теперь, чтобы добавить певца, необходимо сделать так:

choir.addSinger(singer)

Все понятно и логично, но было бы более привычно делать это так:

choir += singer

Именно для этого нужна перегрузка операторов. Например, чтобы добавить оператор +=, достаточно сделать так:

class Choir {
    private val singers = mutableListOf<Singer>()

    operator fun plusAssign(singer: Singer) {        
        singers.add(singer)
    }
}

Обрати внимание на ключевое слово operator и имя функции (plusAssign). У каждого оператора есть свое имя (полный список), а перегрузка одного оператора никогда не приводит к перегрузке его «родственников». К примеру, перегрузка оператора + не приведет к перегрузке оператора ++.

Перегрузка не всех операторов полезна всегда. В данном случае может быть лучше воспользоваться оператором вхождения (contains):

operator fun contains(s: Singer) : Boolean {
       return singers.contains(s)
}

Благодаря ему можно сделать так:

if (singerMeghan in choir) {
    println("Meghan is a part of the choir!")
}

Перегрузку операторов можно использовать в функциях-расширениях:

operator fun ViewGroup.plusAssign(other: View) = addView(other)

Теперь добавить View к ViewGroup можно с помощью оператора:

viewGroup += view

Как и в других языках, при перегрузке операторов в Kotlin следует руководствоваться простым правилом: краткость не всегда повышает читаемость кода. Стоит несколько раз подумать перед тем, как применять перегрузку.

Операторы, поддерживающие перегрузку

ИНСТРУМЕНТЫ

  • AndroPyTool — инструмент для всестороннего анализа приложений под Android;
  • apkurlgrep — утилита для извлечения URL-адресов из APK;
  • Luject — инструмент для внедрения библиотек в приложения;
  • Decompiler — декомпилятор для VSCode с поддержкой приложений под Android.

Источник


Report Page