Uma coisa que gosto de fazer na busca por falhas em aplicativos Android é análise dinâmica com JDWP (Java Debug Wire Protocol) e o Android Studio. Dali eu mapeio as chamadas com o objeto já montado na mão e escrevo script de Frida com bem menos chute. O processo pra chegar nesse ponto é tão entediante que eu não achei ferramenta nenhuma que me deixasse na cara do gol. Como todo bom dev, fiz a minha.
JDWP-WIRE
O binário se chama jdwpw. Pro MVP eu queria só resolver o ritual. O caminho é smali, Java e o debugger da JVM. Frida entra depois, quando o breakpoint já mostrou o que interceptar.
Cada passo imprime uma linha quando termina, antes do próximo comando bloqueante:
15:04:01 [ok] [pull] base.apk
15:04:12 [skip] [patch] already debuggable
15:04:40 [ok] [attach] 127.0.0.1:8700
Workspace local: ./.jdt/<package>/. Nada disso vai pro git.
Devices
Primeiro eu listo os dispositivos. Dava pra usar só adb devices. Eu preferi uma saída fixa, com erro claro quando não tem aparelho nenhum, ou quando tem mais de um e eu esqueci o -s.
$ jdwpw devices
SERIAL STATE KIND MODEL
RA0AA00A0AA device usb SM_A125M
Um aparelho só. Com dois na USB e sem -s, ele para e pede o serial.
Apps
Segundo, eu precisava puxar o app alvo, então precisava do package:
$ jdwpw apps
IDX PID NAME IDENTIFIER
1 16403 br.com.example br.com.example
Por baixo é o Package Manager. O padrão é pm list packages -3, só app de terceiro. Quem está em execução, com PID preenchido, sobe primeiro. Dentro do grupo, ordena por nome e depois por package. O IDX começa em 1 e é o número que o pull aceita.
A lista maior sai com --system, que inclui pacote de sistema. O -3 do pm é o filtro estreito, o padrão daí.
A coluna NAME ainda não é o nome bonito da gaveta. Se o dumpsys package imprime um label não localizado, ele aparece. Se o manifest só tem @string/app_name, o NAME cai no próprio package. “App Exemplo” fica pra depois.
Pull
Com o package na mão:
$ jdwpw pull br.com.example
# ou o IDX da lista: jdwpw pull 1
# APK que já está no host: jdwpw pull ./app.apk --package br.com.example
Ele pergunta o pm path, ignora aquele hash da pasta de instalação e baixa o APK do caminho real. Um path completo parece isto:
/data/app/~~G1qtMzpUeLCOxE6w5GdNoA==/com.android.chrome-rzQHaIg-sMGC7kje9zi_ng==/base.apk
Split vem junto. Se o pm path listar split_config.*, cada um cai em apk/. Sem merge.
--decode além de baixar roda o apktool (apktool d -f -o decode base.apk). Só o base é decodificado. A árvore fica assim:
.jdt/<pkg>/
├── apk/
│ ├── base.apk
│ └── split_config.<abi|dpi|lang>.apk
├── decode/
│ ├── AndroidManifest.xml
│ ├── apktool.yml
│ ├── smali/
│ │ └── <pacote>/.../*.smali
│ ├── smali_classes2/
│ ├── res/
│ ├── assets/
│ ├── lib/<abi>/*.so
│ ├── original/
│ │ ├── AndroidManifest.xml
│ │ └── META-INF/
│ └── unknown/
├── patched/
├── idea/
└── captures/
patched/, idea/ e captures/ nascem vazios. O keystore de debug (.jdt/debug.keystore) só aparece na hora de assinar.
Patch
Pra depurar um app de release eu preciso de um patch. Ele mexe no decode e para por aí. Install é outro comando:
$ jdwpw patch .jdt/br.com.example/decode
# ou decode + patch direto a partir do APK:
$ jdwpw patch --apk .jdt/br.com.example/apk/base.apk --package br.com.example
O manifest ganha android:debuggable="true". Se já estiver true, essa parte é skip. Se estiver false, vira true.
<application
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:debuggable="true"
>
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
Já que o XML está aberto, o mesmo passo mexe no network security config. Eu ligo o aparelho num proxy o tempo todo. O clássico é o Burp. Eu prefiro o OWASP ZAP. Sem o certificado do usuário na âncora de confiança, o app não fala com o proxy.
O arquivo escrito, ou completado quando o app já tem um, fica assim:
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
cleartextTrafficPermitted="true" libera HTTP. <certificates src="user" /> faz o app aceitar a CA que eu instalei no dispositivo. Se o manifest já apontava pra um XML de NSC, o patch acrescenta o certificado de usuário nesse arquivo e mantém o resto da config. Se já confia em user CA, skip só dessa parte.
O APK que volta pro aparelho é um build de debug assinado por mim. Play Integrity, detecção de repack ou checagem de assinatura pode matar o app aqui. O erro aparece e fica por isso mesmo.
Install
$ jdwpw install .jdt/br.com.example/decode
Também aceita o package (jdwpw install br.com.example, usando o que já está em .jdt/) ou um APK (jdwpw install .jdt/br.com.example/apk/base.apk).
Se a entrada for a pasta do decode, ele rebuilda com apktool, assina com a keystore de debug e instala com adb install -r -d. -r substitui. -d permite downgrade de versionCode, comum depois de um repack.
Mais de um APK, base mais splits, vai de adb install-multiple -r -d. Cada um é assinado. Sem merge.
Assinatura diferente da que está instalada (INSTALL_FAILED_UPDATE_INCOMPATIBLE) faz adb uninstall e tenta de novo. Os dados do app somem. Se a sessão logada importa, anota antes.
Attach
No dia a dia eu uso um comando:
$ jdwpw attach br.com.example --port 8700 --studio
Ele faz pull, decode, patch do que ainda falta, reassinatura e reinstall. Etapa já feita é skip só dela. Árvore apktool existente, com manifest e apktool.yml, não baixa de novo. Manifest já debuggable, ou NSC que já confia na CA do usuário, não repete esse pedaço. O install roda mesmo assim, e o app no aparelho passa a ser o build que a gente assinou.
Na hora de subir o processo, se a Android CLI oficial 1.0 estiver no PATH (android run --debug --apks=...), ela faz o install e o launch em modo debugger. O android velho do SDK conta como ausente. Sem essa CLI, cai em adb install e am set-debug-app -w.
Aí espera o PID do package sem sufixo, br.com.example. Processo :remote e isolated aparecem no pidof, e essa versão não deixa escolher qual. Com o PID em mão:
adb -s <serial> forward tcp:8700 jdwp:<pid>
A porta padrão é 8700. O forward escuta em IPv4, então o debugger do host liga em 127.0.0.1:8700. A confirmação é adb forward --list.
A primeira conexão TCP nesse socket é o handshake JDWP. Eu já perdi sessão porque alguma coisa abriu a porta só pra ver se respondia. O Android Studio chegava e o processo tinha saído do “waiting for debugger”. O jdwpw não abre esse socket. Deixa pra IDE.
No fim sai:
15:04:40 [ok] [attach] 127.0.0.1:8700
Dois aparelhos pedem -s <serial> no attach, no pull e no install. Se o cabo sair no meio, o forward não roda num serial que já não está lá.
Studio
--studio escreve o projeto mínimo em .jdt/<pkg>/idea depois do forward. O Android Studio eu abro na mão.
.jdt/br.com.example/idea/
├── decode.iml
└── .idea/
├── misc.xml
├── modules.xml
└── runConfigurations/Remote_Debug.xml
O content root do módulo aponta pra ../decode. O smali fica lá. O esqueleto não traz Gradle, não escolhe JDK e não instala smalidea.
A pasta que abre no Android Studio é idea/. decode/ entra só como content root. A run configuration Remote_Debug aponta pra 127.0.0.1 na porta que o attach imprimiu, e o attach é o botão Debug dela. “Attach Debugger to Android Process” e o Debug de um app Android normal ligam em outra sessão.
Sem AndroidManifest.xml no decode, o studio imprime skip e o forward continua valendo. Sem a flag --studio, o log é um skip pedindo a flag. Dá pra rodar o attach de novo com --studio. O que já está feito não baixa o APK outra vez.
Reset
Quando o attach fica torto, app preso em “waiting for debugger”, porta ocupada ou forward zumbi:
$ jdwpw reset br.com.example
am clear-debug-app e adb forward --remove na porta da sessão. O app patcheado continua instalado. O processo segue. O que solta é o wait-for-debugger.
Conclusão
O que tem até aqui já é muito bom e funcional. Eu plugo o aparelho, rodo o attach e caio no breakpoint com o request na mão, faltou abordar alguns, mas não achei relevante.
Ideia nova aparece no meio do uso, e outro jeito de lidar com um app também. Quando isso acontece eu atualizo o projeto e sigo, vlw flws!