Skip to content

Commit

Permalink
Implement new Smali patching logic
Browse files Browse the repository at this point in the history
  • Loading branch information
shroudedcode committed Jan 27, 2021
1 parent be21aed commit 0f85c10
Show file tree
Hide file tree
Showing 6 changed files with 283 additions and 104 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Inspecting a mobile app's HTTPS traffic using a proxy is probably the easiest wa

- decode the APK file using [Apktool][apktool]
- replace the app's [Network Security Configuration][network-security-config] to allow user-added certificates
- [insert `return-void` opcodes][patch-certificate-pinning] to disable [certificate pinning][certificate-pinning] logic
- modify the source code to disable various [certificate pinning][certificate-pinning] implementations
- encode the patched APK file using [Apktool][apktool]
- sign the patched APK file using [uber-apk-signer][uber-apk-signer]

Expand Down Expand Up @@ -67,14 +67,14 @@ $ npm install -g apk-mitm

- [Connor Tumbleson](https://github.com/iBotPeaches) for making [an awesome APK decompiler][apktool]
- [Patrick Favre-Bulle](https://github.com/patrickfav) for making [a very simple tool for signing APKs][uber-apk-signer]
- [Ryan Welton](https://github.com/Fuzion24) for [inspiring most of the certificate pinning removal code](https://github.com/Fuzion24/JustTrustMe)

## License

MIT 漏 [Niklas Higi](https://shroudedcode.com)

[network-security-config]: https://developer.android.com/training/articles/security-config
[certificate-pinning]: https://owasp.org/www-community/controls/Certificate_and_Public_Key_Pinning#what-is-pinning
[patch-certificate-pinning]: https://mobile-security.gitbook.io/mobile-security-testing-guide/android-testing-guide/0x05c-reverse-engineering-and-tampering#patching-example-disabling-certificate-pinning
[node]: https://nodejs.org/en/download/
[java]: https://www.oracle.com/technetwork/java/javase/downloads/index.html
[google-maps-android]: https://console.cloud.google.com/google/maps-apis/apis/maps-android-backend.googleapis.com
Expand Down
106 changes: 4 additions & 102 deletions src/tasks/disable-certificate-pinning.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,9 @@
import * as os from 'os'
import * as path from 'path'
import * as fs from '../utils/fs'

import globby = require('globby')
import escapeStringRegexp = require('escape-string-regexp')
import { ListrTaskWrapper } from 'listr'
import observeAsync from '../utils/observe-async'

const INTERFACE_LINE = '.implements Ljavax/net/ssl/X509TrustManager;'

/** The methods that need to be patched to disable certificate pinning. */
const METHOD_SIGNATURES = [
'checkClientTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V',
'checkServerTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V',
'getAcceptedIssuers()[Ljava/security/cert/X509Certificate;',
]

/** Patterns used to find the methods defined in `METHOD_SIGNATURES`. */
const METHOD_PATTERNS = METHOD_SIGNATURES.map(signature => {
const escapedSignature = escapeStringRegexp(signature)
return new RegExp(
`(\\.method public (?:final )?${escapedSignature})\\n([^]+?)\\n(\\.end method)`,
'g',
)
})

/** Code inserted into `checkClientTrusted` and `checkServerTrusted`. */
const RETURN_VOID_FIX = ['.locals 0', 'return-void']

/** Code inserted into `getAcceptedIssuers`. */
const RETURN_EMPTY_ARRAY_FIX = [
'.locals 1',
'const/4 v0, 0x0',
'new-array v0, v0, [Ljava/security/cert/X509Certificate;',
'return-object v0',
]
import observeAsync from '../utils/observe-async'
import processSmaliFile from './smali/process-file'

export default async function disableCertificatePinning(
directoryPath: string,
Expand All @@ -54,77 +23,10 @@ export default async function disableCertificatePinning(
// Required because Node.js streams are not typed as generics
const filePath = filePathChunk as string

const hadPinning = await processSmaliFile(filePath)
if (hadPinning) {
pinningFound = true

const relativePath = path.relative(directoryPath, filePath)
next(`Applied patch in "${relativePath}".`)
}
const hadPinning = await processSmaliFile(filePath, next)
if (hadPinning) pinningFound = true
}

if (!pinningFound) task.skip('No certificate pinning logic found.')
})
}

/**
* Process the given Smali file and apply applicable patches.
* @returns whether patches were applied
*/
async function processSmaliFile(filePath: string): Promise<boolean> {
let originalContent = await fs.readFile(filePath, 'utf-8')

// Don't scan classes that don't implement the interface
if (!originalContent.includes(INTERFACE_LINE)) return false

if (os.type() === 'Windows_NT') {
// Replace CRLF with LF, so that patches can just use '\n'
originalContent = originalContent.replace(/\r\n/g, '\n')
}

let patchedContent = originalContent

for (const pattern of METHOD_PATTERNS) {
patchedContent = patchedContent.replace(
pattern,
(_, openingLine: string, body: string, closingLine: string) => {
const bodyLines = body
.split('\n')
.map(line => line.replace(/^ /, ''))

const fixLines = openingLine.includes('getAcceptedIssuers')
? RETURN_EMPTY_ARRAY_FIX
: RETURN_VOID_FIX

const patchedBodyLines = [
'# inserted by apk-mitm to disable certificate pinning',
...fixLines,
'',
'# commented out by apk-mitm to disable old method body',
'# ',
...bodyLines.map(line => `# ${line}`),
]

return [
openingLine,
...patchedBodyLines.map(line => ` ${line}`),
closingLine,
]
.map(line => line.trimEnd())
.join('\n')
},
)
}

if (originalContent !== patchedContent) {
if (os.type() === 'Windows_NT') {
// Replace LF with CRLF again
patchedContent = patchedContent.replace(/\n/g, '\r\n')
}

await fs.writeFile(filePath, patchedContent)
return true
}

return false
}
33 changes: 33 additions & 0 deletions src/tasks/smali/parse-head.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
const CLASS_PATTERN = /\.class(?<keywords>.+)? L(?<name>[^\s]+);/
const IMPLEMENTS_PATTERN = /\.implements L(?<name>[^\s]+);/g

/**
* General information about a class extracted from the first few lines of a
* Smali file and used to find applicable Smali patches.
*/
export interface SmaliHead {
/** The name of the class. */
name: string

/** The interfaces implemented by this class. */
implements: string[]

/** Whether the "class" actually represents an interface. */
isInterface: boolean
}

/**
* Extracts general information like the class name, the implemented interfaces,
* and whether the class actually represents an interface from a Smali file.
*/
export default function parseSmaliHead(contents: string): SmaliHead {
const { keywords, name } = contents.match(CLASS_PATTERN)?.groups!

return {
name,
implements: Array.from(contents.matchAll(IMPLEMENTS_PATTERN)).map(
match => match.groups!.name,
),
isInterface: keywords?.trim().split(' ').includes('interface') ?? false,
}
}
97 changes: 97 additions & 0 deletions src/tasks/smali/patches.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { SmaliPatch } from './types'

/** `return void;` in Smali. */
const RETURN_VOID_SMALI = ['.locals 0', 'return-void']

/** `return true;` in Smali. */
const RETURN_TRUE_SMALI = ['.locals 1', 'const/4 v0, 0x1', 'return v0']

/** `return new java.security.cert.X509Certificate[] {};` in Smali. */
const RETURN_EMPTY_CERT_ARRAY_SMALI = [
'.locals 1',
'const/4 v0, 0x0',
'new-array v0, v0, [Ljava/security/cert/X509Certificate;',
'return-object v0',
]

/**
* A declarative list of all the patches that are
* applied to Smali code to disable certificate pinning.
*/
const smaliPatches: SmaliPatch[] = [
{
selector: {
type: 'interface',
name: 'javax/net/ssl/X509TrustManager',
},
methods: [
{
name: 'X509TrustManager#checkClientTrusted (javax)',
signature:
'checkClientTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V',
replacementLines: RETURN_VOID_SMALI,
},
{
name: 'X509TrustManager#checkServerTrusted (javax)',
signature:
'checkServerTrusted([Ljava/security/cert/X509Certificate;Ljava/lang/String;)V',
replacementLines: RETURN_VOID_SMALI,
},
{
name: 'X509TrustManager#getAcceptedIssuers (javax)',
signature: 'getAcceptedIssuers()[Ljava/security/cert/X509Certificate;',
replacementLines: RETURN_EMPTY_CERT_ARRAY_SMALI,
},
],
},
{
selector: {
type: 'interface',
name: 'javax/net/ssl/HostnameVerifier',
},
methods: [
{
name: 'HostnameVerifier#verify (javax)',
signature: 'verify(Ljava/lang/String;Ljavax/net/ssl/SSLSession;)Z',
replacementLines: RETURN_TRUE_SMALI,
},
],
},
{
selector: {
type: 'class',
name: 'com/squareup/okhttp/CertificatePinner',
},
methods: [
{
name: 'HostnameVerifier#check (OkHttp 2.5)',
// Inspired by: https://github.com/Fuzion24/JustTrustMe/blob/152557d/app/src/main/java/just/trust/me/Main.java#L456-L478
signature: 'check(Ljava/lang/String;Ljava/util/List;)V',
replacementLines: RETURN_VOID_SMALI,
},
],
},
{
selector: {
type: 'class',
name: 'okhttp3/CertificatePinner',
},
methods: [
{
name: 'CertificatePinner#check (OkHttp 3.x)',
// Inspired by: https://github.com/Fuzion24/JustTrustMe/blob/152557d/app/src/main/java/just/trust/me/Main.java#L480-L499
signature: 'check(Ljava/lang/String;Ljava/util/List;)V',
replacementLines: RETURN_VOID_SMALI,
},
{
name: 'CertificatePinner#check (OkHttp 4.2)',
// Inspired by: https://github.com/Fuzion24/JustTrustMe/blob/152557d/app/src/main/java/just/trust/me/Main.java#L539-L558
signature:
'check$okhttp(Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V',
replacementLines: RETURN_VOID_SMALI,
},
],
},
]

export default smaliPatches
109 changes: 109 additions & 0 deletions src/tasks/smali/process-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import * as os from 'os'
import * as fs from '../../utils/fs'
import escapeStringRegexp = require('escape-string-regexp')
import chalk = require('chalk')

import parseSmaliHead, { SmaliHead } from './parse-head'
import smaliPatches from './patches'
import { SmaliPatch } from './types'

/**
* Process the given Smali file and apply applicable patches.
* @returns whether patches were applied
*/
export default async function processSmaliFile(
filePath: string,
log: (message: string) => void,
): Promise<boolean> {
let originalContent = await fs.readFile(filePath, 'utf-8')

if (os.type() === 'Windows_NT') {
// Replace CRLF with LF, so that patches can just use '\n'
originalContent = originalContent.replace(/\r\n/g, '\n')
}

let patchedContent = originalContent

const smaliHead = parseSmaliHead(patchedContent)
if (smaliHead.isInterface) return false

const applicablePatches = smaliPatches.filter(patch =>
selectorMatchesClass(patch, smaliHead),
)
if (applicablePatches.length === 0) return false

const applicableMethods = applicablePatches.flatMap(patch => patch.methods)
for (const method of applicableMethods) {
const pattern = createMethodPattern(method.signature)
patchedContent = patchedContent.replace(
pattern,
(_, openingLine: string, body: string, closingLine: string) => {
const bodyLines = body
.split('\n')
.map(line => line.replace(/^ /, ''))

const patchedBodyLines = [
'# inserted by apk-mitm to disable certificate pinning',
...method.replacementLines,
'',
'# commented out by apk-mitm to disable old method body',
'# ',
...bodyLines.map(line => `# ${line}`),
]

log(
chalk`{bold ${smaliHead.name}}{dim :} Applied {bold ${method.name}} patch`,
)

return [
openingLine,
...patchedBodyLines.map(line => ` ${line}`),
closingLine,
]
.map(line => line.trimEnd())
.join('\n')
},
)
}

if (originalContent !== patchedContent) {
if (os.type() === 'Windows_NT') {
// Replace LF with CRLF again
patchedContent = patchedContent.replace(/\n/g, '\r\n')
}

await fs.writeFile(filePath, patchedContent)
return true
}

return false
}

/**
* Creates a full RegExp pattern for finding a method based on its signature.
*/
function createMethodPattern(signature: string): RegExp {
const escapedSignature = escapeStringRegexp(signature)
return new RegExp(
`(\\.method public (?:final )?${escapedSignature})\\n([^]+?)\\n(\\.end method)`,
'g',
)
}

/**
* Checks whether the given patch can be applied to the given
* class based on its name and the interfaces it implements.
*/
function selectorMatchesClass(
patch: SmaliPatch,
smaliHead: SmaliHead,
): boolean {
return (
/* The class matches */
(patch.selector.type === 'class' &&
patch.selector.name === smaliHead.name) ||
/* One of the implemented interfaces matches */
(patch.selector.type === 'interface' &&
smaliHead.implements.includes(patch.selector.name))
)
}

0 comments on commit 0f85c10

Please sign in to comment.