Содержание статьи
Мы живем в то самое замечательное время, когда космические корабли бороздят просторы Большого театра, а каждая домохозяйка в курсе последних трендов из области связи. И даже если технические аспекты ей вряд ли интересны, то как посмотреть котиков через Wi-Fi - она уж точно знает. Сегодня мы сотворим небольшое приложение, которое будет полезно как увлеченным хакерам, занимающимся вардрайвингом, так и суровым любителям домашних животных.
Тактико-технические характеристики
Темой нашего сегодняшнего изыскания будет приложение-детектор точек доступа Wi-Fi, работающее в среде Android. Так как беспроводное оборудование, работающее на частоте 5 ГГц, не сильно распространено, ограничимся диапазоном 2 ГГц. Кроме того, будем особо отмечать, во-первых - открытые (незапароленные) точки доступа, во-вторых - имеющие устаревшее, а значит слабое шифрование WEP, и в-третьих — точки, поддерживающие технологию WPS. Об уязвимостях последней можно почитать в мартовском номере «Хакера» за 2012 год. Наконец, чтобы сделать задачу еще более интересной, нарисуем в качестве GUI экран «настоящего» радара. Одним словом, смотри рис. 1. В качестве среды разработки будем использовать Android Studio.
Глоссарий
- WAP (беспроводная точка доступа) — устройство для объединения компьютеров в единую беспроводную сеть (обычно встраивается в роутер).
- WPS (Wi-Fi Protected Setup) — стандарт полуавтоматического создания беспроводной сети Wi-Fi (посредством нажатия кнопки)
- WEP (Wired Equivalent Privacy) — алгоритм для обеспечения безопасности сетей Wi-Fi, основан на поточном шифровании RC4.
- WPA (Wi-Fi Protected Access) — замена WPA c усовершенствованной схемой шифрования RC4, обязательной аутентификацией с EAP.
- WPA2 — замена WPA, усилено шифрование за счет ССMP и AES.
- TKIP — протокол целостности временного ключа, в отличие от WEP использует более эффективный механизм управления ключами, но тот же самый алгоритм RC4 для шифрования данных.
- CCMP — протокол блочного шифрования с кодом аутентичности, созданный для замены TKIP.
- AES — симметричный алгоритм блочного шифрования, принятый в качестве стандарта шифрования правительством США.
- EAP — расширяемый протокол аутентификации клиентов.
Слушаем Wi-Fi-радио
Xakep #198. Случайностей не бывает
Использование технологии Wi-Fi предполагает наличие беспроводной точки доступа (WAP) и не менее одного клиента. Каждая WAP должна иметь два идентификатора — SSID и BSSID. Первый определяет имя точки и, соответственно, сети (например, home, AKADO_1742, FREE_WIFI) и задается непосредственно пользователем (последний тренд — именовать сеть иероглифами), второй, как правило, содержит MAC-адрес точки.
Каждые 100 мс WAP передает в эфир (широковещательно) свой идентификатор SSID, с помощью которого клиент может узнать о существовании этой точки доступа, используемых технологиях шифрования, ну и, конечно, подключиться к ней. Если SSID не рассылается, такая точка доступа называется скрытой и, чтобы подключиться к ней, клиент должен знать ее имя изначально. Нужно сказать, что «скрытность» не служит сколько-нибудь значимой защитой от злоумышленника, так как любой снифер трафика без труда покажет SSID.
Для защиты от несанкционированного использования точки доступа применяются разные виды аутентификации и шифрования: WEP, WPA, WPA2, TKIP/AES и тому подобные. Самый первый из них, WEP, в настоящее время считается технически устаревшим и не рекомендован к использованию, хотя иногда применяется для объединения в сеть совсем уж бородатых устройств. Взлом WEP можно осуществить всего за несколько минут. WPA, пришедший на смену WEP, сломать уже сложнее, хотя в 2008 году на конференции PacSec был представлен способ компрометации ключа TKIP в WPA (за разъяснением терминов отсылаю тебя к врезке) всего за четверть часа. Наконец, самой защищенной (на сегодняшний день, разумеется) технологией является WPA2 с алгоритмом шифрования AES.
Может показаться, что в природе в принципе не должно существовать открытых точек доступа, но это не так. Мы не будем касаться пользователей-экстремалов, по разным причинам держащих беспарольные шлюзы во всемирную паутину, а поговорим о... гостях.
Когда приходят гости — это хорошо, когда приходят гости со смартфонами — это тоже ничего так, а вот когда приходят гости со смартфонами и просят Wi-Fi — это уже не круто. Давать им пароль от точки доступа как-то не хочется, ведь это вторжение в личную сетевую жизнь, а отказывать — совсем уж не комильфо. Специально для этих целей в некоторых маршрутизаторах (aka роутерах) предусмотрено создание гостевой сети. Такая сеть существенно ограничена в скорости доступа в интернет, но открыта для подключения всем желающим. Единственным сдерживающим фактором служит белый (или черный ;)) список MAC-адресов смартфонов гостей, но собирать его — то еще занятие, да и смена MAC не такая уж экзотика. В итоге, как ни прискорбно для одних, найти открытую сеть к радости других можно.
Бойся желаний своих
Для работы с модулем Wi-Fi необходимо запросить у пользователя разрешение для чтения и изменения подключений:
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
Кроме того, Android позволяет указать конкретную аппаратную особенность, которая необходима для установки приложения. В нашем проекте уместно требовать наличие аппаратного модуля Wi-Fi:
<uses-feature android:name="android.hardware.wifi"/>
Если Wi-Fi-модуль на устройстве выключен, следующий код его оживит:
if (!wifi.isWifiEnabled())
if (wifi.getWifiState() != WifiManager.WIFI_STATE_ENABLING)
wifi.setWifiEnabled(true);
Тем не менее негласный кодекс хорошего программиста, создающего хорошие программы, рекомендует предварительно спрашивать у пользователя разрешения включить Wi-Fi.
Wi-Fi-класс
Для хранения информации обо всех найденных беспроводных точках доступа напишем специальный класс:
public class WiFiAP {
private String SSID;
private String BSSID;
private int Channel;
private int Level;
private int Security; // 0 — открытая сеть; 1 — WEP; 2 — WPA/WPA2
private boolean WPS; // WPS?
public WiFiAP(String SSID, String BSSID, int Channel, int Level, int Security, boolean WPS) {
this.SSID = SSID;
this.BSSID = BSSID;
this.Channel = Channel;
this.Level = Level;
this.Security = Security;
this.WPS = WPS;
}
...
// Геттеры
}
где Channel — канал, на котором вещает точка, а Level — уровень сигнала (0 — минимальный, 100 — максимальный). Кстати, допустимый частотный диапазон, который определяет номер канала, в разных странах неодинаков. Например, в России на сертифицированном оборудовании разрешены к использованию тринадцать каналов, тогда как во Франции только четыре, в США — одиннадцать. Для преобразования частоты в номер канала воспользуемся функцией
private final static ArrayList<Integer> channelsFrequency = new ArrayList<Integer>(
Arrays.asList(0, 2412, 2417, 2422, 2427, 2432, 2437, 2442, 2447,
2452, 2457, 2462, 2467, 2472, 2484));
public static int getChannelFromFrequency(int frequency) {
return channelsFrequency.indexOf(Integer.valueOf(frequency));
}
Wi-Fi-приемник
За работу с Wi-Fi в Android отвечает класс WifiManager, представляющий собой системный сервис. Для доступа к нему необходимо использовать константу Context.WIFI_SERVICE
:
wifi = (WifiManager)getSystemService(Context.WIFI_SERVICE);
Для запуска процесса сканирования точек доступа используется метод wifi.startScan()
. Результаты сканирования будут транслироваться в асинхронном режиме, поэтому для их получения и обработки нужно предусмотреть специальный широковещательный приемник, обрабатывающий системное намерение со стандартным действием SCAN_RESULTS_AVAILABLE_ACTION
.
ArrayList<WiFiAP> WiFiAPs = new ArrayList<WiFiAP>();
...
BroadcastReceiver scanReceiver = new BroadcastReceiver() {
@Override
public void onReceive(Context context, Intent intent) {
List<ScanResult> results = wifi.getScanResults();
if (results == null) return;
WiFiAPs.clear();
for (ScanResult result : results) {
WiFiAP AP = new WiFiAP(result.SSID,
result.BSSID,
getChannelFromFrequency(result.frequency),
WifiManager.calculateSignalLevel(result.level, maxSignalLevel),
getSecurity(result.capabilities),
getWPS(result.capabilities));
WiFiAPs.add(AP);
}
...
}
};
В методе onReceive работает цикл, добавляющий в массив WiFiAPs все найденные точки: параметры result.SSID и result.BSSID содержат уже готовые к употреблению строки, а вот с остальными нужно поработать. Так, result.frequency возвращает частоту, на которой работает точка доступа, но мы, как радиолюбители без стажа, привыкли работать с номером канала, поэтому вычисляем соответствующее значение — getChannelFromFrequency(result.frequency)
. Уровень сигнала, содержащийся в result.level, определен как уровень затухания сигнала (в децибелах) между клиентом и точкой доступа, и для нормализации этого значения в классе WifiManager предусмотрен метод calculateSignalLevel
, принимающий в качестве второго параметра условный максимальный уровень (в нашем случае — 100).
Параметр result.capabilities содержит строку с поддерживаемыми методами аутентификации и шифрования. В качестве примера она может иметь вид:
[WPA2-PSK-CCMP][ESS]
[WPA-PSK-TKIP][WPS]
[WEP]
[ESS]
Нас интересуют «хеш-теги»: WPA, WPA2, WEP, WPS — именно их пытаются обнаружить методы getSecurity и getWPS и возвращают результаты в поля Security и WPS соответственно.
Для работы широковещательный приемник должен быть зарегистрирован в системе. Существует два метода регистрации — в файле-манифесте проекта AndroidManifest.xml (этот вариант мы рассматривали несколько номеров назад) и непосредственно в коде. Обычно приемники, влияющие на пользовательский интерфейс конкретной активности, регистрируются внутри обработчика onResume и отменяются во время срабатывания onPause:
@Override
public void onResume() {
super.onResume();
IntentFilter filter = new IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION);
registerReceiver(scanReceiver, filter);
}
@Override
public void onPause() {
unregisterReceiver(scanReceiver);
super.onPause();
}
Итак, у нас есть список доступных (если повезет — очень доступных) беспроводных точек доступа. Осталось только все это красиво упаковать, чем мы и займемся далее.
Творим GUI
Так как Google в ближайшее время вряд ли реализует компонент типа «радар» в своем SDK, создадим его сами.
В Андроиде все визуальные компоненты называются представлениями (View). Чтобы соорудить новый элемент с чистого листа, необходимо наследовать один из двух классов — либо View, либо SurfaceView. Их принципиальные отличия приведены во врезке, а для нашей задачи вполне сгодится View.
View vs. SurfaceView
Для рисования в Андроиде предусмотрены два класса поверхностей — View и SurfaceView. В большинстве приложений представления (например, нестандартные компоненты) рисуются в главном графическом потоке. Но в этом же потоке работают активности, приемники и даже сервисы. Вынести код отрисовки в отдельный поток при использовании View принципиально невозможно, так как любое изменение элементов графического интерфейса из фонового потока в Андроиде явно запрещено.
Выйти из сложившейся ситуации поможет применение объекта SurfaceView, который поддерживает рисование из фоновых потоков, что делает SurfaceView идеальным решением для ресурсоемких задач с быстрым обновлением экрана (например, игр). Кроме того, SurfaceView предоставляет поверхность (Surface), где можно отображать сложные сцены, используя весь арсенал пакета OpenGL ES 1 и 2/3.
Класс View представляет собой пустую область экрана (канву) размером 100 × 100 пикселей. Для корректировки размера необходимо переопределить метод onMesure, а для рисования на канве — onDraw. Внутри первого вычисляется высота и ширина канвы, которая в нашем случае будет соответствовать размеру экрана Android-устройства; внутри второго мы будем рисовать экран нашего детектора и выводить точки доступа. Поскольку физический размер экрана может быть любым, все геометрические параметры должны не привязываться к абсолютным значениям, а вычисляться относительно какой-либо величины (например, радиуса радара). Каркас нашего класса представлен в листинге (как всегда, полный исходник ищи на dvd.xakep.ru):
public class RadarView extends View {
...
public RadarView(Context context) {
super(context);
setupView(); // Инициализируем объекты для рисования
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int measuredWidth = measure(widthMeasureSpec);
int measuredHeight = measure(heightMeasureSpec);
int d = Math.min(measuredWidth, measuredHeight);
setMeasuredDimension(d, d);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// Определяем шейдеры
...
}
@Override
protected void onDraw(Canvas canvas) {
// Рисуем
...
}
}
Метод onMeasure
вызывается в момент, когда родительское представление (разметка активности) размещает внутри себя дочерний элемент (наш радар). Параметры widthMeasureSpec и heightMeasureSpec определяют доступное пространство, которое мы и займем, вызвав метод setMeasuredDimension
. Так как наше представление — идеальный круг, который должен вписаться по ширине или высоте в экран (в зависимости от ориентации устройства), мы с помощью Math.min
вычисляем длину короткой грани и передаем ее в качестве обоих параметров.
Для рисования на канве используются кисти (Paint), позволяющие как задавать свойства графических примитивов (цвет, узор, толщину линий, размер текста и прочее), так и указывать порядок отрисовки: шейдерный градиент, цветовые фильтры, контурные эффекты и так далее. Метод onDraw
вызывается всякий раз при обновлении экрана, поэтому в нем нежелательно создание каких-либо объектов. Обычно все используемые объекты инициализируются в конструкторе представления, в нашем случае — в методе setupView
(дублирующий код опущен):
protected void setupView() {
// Сетка
axisPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
axisPaint.setColor(Color.argb(0x60, 0xFF, 0xFF, 0xFF));
axisPaint.setStyle(Paint.Style.STROKE);
axisPaint.setStrokeWidth(1);
// Текст и маркеры
textPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
textPaint.setFakeBoldText(true);
textPaint.setSubpixelText(true);
textPaint.setTextAlign(Align.LEFT);
...
{
Флаг Paint.ANTI_ALIAS_FLAG
включает режим сглаживания при рисовании, а метод Color.argb
, помимо цветовых составляющих (Red, Green, Blue), первым параметром позволяет указать степень прозрачности примитива. Возможности рисования на канве в Андроиде очень похожи на те, что предоставляет подсистема GDI в Windows.
Для придания реалистичности экрану нашего прибора будем использовать радиальный градиентный шейдер. Основная идея — цвет вблизи центра радара должен быть более светлым, чем по краям. В то же время он должен значительно темнеть при приближении к внешнему кольцу. Градиентный шейдер позволяет закрасить любой контур с помощью интерполированной цветовой гаммы, задавать которую удобно в виде массива:
mainGradientColors = new int[4];
mainGradientColors[0] = Color.rgb(0xB8, 0xE0, 0xFF);
mainGradientColors[1] = Color.rgb(0xA1, 0xCF, 0xFF);
mainGradientColors[2] = Color.rgb(0x62, 0xAA, 0xFF);
mainGradientColors[3] = Color.BLACK;
Здесь мы указываем опорные цвета, между которыми нужно вычислить интерполятор — от центра радара к краям. Так как шейдер радиальный, необходимо привязать распределение цветов по радиусу:
mainGradientPositions = new float[4];
mainGradientPositions[0] = 0.0f;
mainGradientPositions[1] = 0.2f;
mainGradientPositions[2] = 0.9f;
mainGradientPositions[3] = 1.0f;
где первый элемент массива соответствует центру радара, а последний — внешнему радиусу. Обрати внимание, все приведенные свойства относительны и никак не зависят от физического размера экрана, поэтому они вынесены в отделенный от onDraw метод.
Непосредственное создание шейдера с указанными выше параметрами и привязку к экранным координатам в нашем случае удобнее всего производить в методе onSizeChanged
:
private RadialGradient mainGradient;
...
int px = w / 2;
int py = h / 2;
center = new Point(px, py);
radius = Math.min(px, py) - 20; // Отступ от краев канвы
mainGradient = new RadialGradient(center.x, center.y, radius, mainGradientColors, mainGradientPositions, Shader.TileMode.CLAMP);
Этот метод вызывается при любом изменении размеров представления. Объект сenter (центр радара) и переменная radius (внешний радиус) вычисляются исходя из физического размера представления (то есть экрана) с помощью переданной в onSizeChanged
ширины (w) и высоты (h). Константа CLAMP определяет использование цвета по краям шейдера в случае, если область, заданная кистью шейдера, меньше области заполнения.
Теперь экран радара выглядит почти реалистично — не хватает только эффекта стеклянного купола над ним. Для его создания также подойдет радиальный градиентный шейдер с той лишь разницей, что вместо интерполяции по цвету используем интерполяцию по степени прозрачности:
int glassColor = 0xF5;
glassGradientColors[0] = Color.argb(0, glassColor, glassColor, glassColor);
glassGradientColors[1] = Color.argb(50, glassColor, glassColor, glassColor);
// и так далее
Полный листинг функции отрисовки onDraw
приведен ниже:
@Override
protected void onDraw(Canvas canvas) {
// Радиальный градиентный шейдер радара
mainGradientPaint.setShader(mainGradient);
canvas.drawOval(outerBox, mainGradientPaint);
// Сетка
canvas.drawCircle(center.x, center.y, radius*0.10f, axisPaint);
canvas.drawCircle(center.x, center.y, radius*0.40f, axisPaint);
canvas.drawCircle(center.x, center.y, radius*0.70f, axisPaint);
canvas.save();
canvas.rotate(15, center.x, center.y);
for (int i = 0; i < 12; i++) {
canvas.drawLine(center.x, center.y, center.x + radius * 0.75f, center.y, axisPaint);
canvas.rotate(30, center.x, center.y);
}
canvas.restore();
// Данные
drawData(canvas);
// Шейдер стекла
glassPaint.setShader(glassShader);
canvas.drawOval(innerBox, glassPaint);
// Внешняя окружность кольца
circlePaint.setStrokeWidth(1);
canvas.drawOval(outerBox, circlePaint);
// Внутренняя окружность кольца
circlePaint.setStrokeWidth(2);
canvas.drawOval(innerBox, circlePaint);
}
Метод drawCircle
рисует окружность на канве, а drawOval
— эллипс, ограниченный прямоугольником. Для привязки созданного ранее шейдера к кисти рисования используется метод setShader
. При нанесении сетки канва сначала сохраняет систему координат — canvas.save
, затем поворачивается в цикле на 30 градусов относительно центра canvas.rotate(30, center.x, center.y)
, образуя тем самым двенадцать секторов окружности. Каждый сектор соответствует номеру канала работы точек доступа (канал №13 мы не рассматриваем). Для возвращения к исходной системе координат вызывается метод canvas.restore
.
Метод drawData
отображает маркеры точек доступа и их названия (SSID): зеленым цветом — если точка открытая или используется WEP, синим — если доступен WPS и белым во всех остальных случаях (но без отображения SSID). При этом чем выше уровень сигнала точки, тем ближе она к центру радара и ярче. Полный код ради экономии места (а также из-за его очевидности) я приводить не буду — готовый исходник всегда ждет тебя с распростертыми объятиями.
Встраиваемая техника
Сотворенный класс RadarView
пока не привязан к какой-либо активности или фрагменту. Исправим это, дополнив разметку единственной активности нашего приложения (main.xml):
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<example.com.wifiradar.RadarView
android:id="@+id/radarView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
</FrameLayout>
С точки зрения разметки класс RadarView
представляет собой обычный компонент, но с тем отличием, что при задании необходимо в обязательном порядке указывать полное наименование класса — example.com.wifiradar.RadarView
. В поле android:id задается уникальный идентификатор, с помощью которого можно получить ссылку на наш радар в коде активности:
RadarView rv = (RadarView)this.findViewById(R.id.radarView);
rv.setData(WiFiAPs);
Теперь после получения списка беспроводных точек доступа достаточно лишь вызвать процедуру перерисовки радара:
rv.invalidate();
Outro
Все любят Wi-Fi! Нет, правда, все любят Wi-Fi! Это одна из немногих технологий беспроводной связи, которая доступна абсолютно всем и проста в использовании. Сегодня мы ненадолго заглянули за кулисы радиоэфира и даже создали симпатичный компонент для Андроида. Надеюсь, ты знаешь, как правильно использовать полученный скилл.