1
+ import * as fs from 'fs/promises' ;
2
+ import * as mockttp from 'mockttp' ;
1
3
import * as appium from 'appium' ;
2
4
import { remote } from 'webdriverio' ;
3
5
import { expect } from 'chai' ;
4
- import { delay } from '@httptoolkit/util' ;
6
+ import * as ChildProcess from 'child_process' ;
7
+
8
+ const IGNORED_BUTTONS = [
9
+ 'RAW CUSTOM-PINNED REQUEST' ,
10
+ ] ;
11
+
12
+ const waitForContentDescription = async ( button : WebdriverIO . Element ) : Promise < string > =>
13
+ button . waitUntil (
14
+ ( ) => button . getAttribute ( 'content-desc' ) ,
15
+ { timeout : 30_000 } // Some buttons (AppMattus webview) can take a while
16
+ ) ;
5
17
6
18
describe ( 'Test Android unpinning' , function ( ) {
7
19
8
20
this . timeout ( 60_000 ) ;
9
21
10
22
let appiumServer : any ;
11
23
let driver : WebdriverIO . Browser ;
24
+ let fridaSession : ChildProcess . ChildProcess ;
25
+ let proxyServer : mockttp . Mockttp ;
26
+
27
+ let seenRequests : mockttp . CompletedRequest [ ] = [ ] ;
28
+ let tlsFailures : mockttp . TlsConnectionEvent [ ] = [ ] ;
29
+
30
+ before ( async ( ) => {
31
+ const [ cert , key ] = await Promise . all ( [
32
+ fs . readFile ( './tmp/ca.crt' , 'utf8' ) ,
33
+ fs . readFile ( './tmp/ca.key' , 'utf8' )
34
+ ] ) . catch ( async ( ) => {
35
+ // If the files don't exist, generate a new CA cert
36
+ const ca = await mockttp . generateCACertificate ( ) ;
37
+ await fs . mkdir ( './tmp' ) ;
38
+ await fs . writeFile ( './tmp/ca.crt' , ca . cert ) ;
39
+ await fs . writeFile ( './tmp/ca.key' , ca . key ) ;
40
+ return [ ca . cert , ca . key ] ;
41
+ } ) ;
42
+
43
+ proxyServer = mockttp . getLocal ( {
44
+ recordTraffic : false ,
45
+ https : {
46
+ cert,
47
+ key
48
+ } ,
49
+ socks : true ,
50
+ passthrough : [ 'unknown-protocol' ] ,
51
+ http2 : true
52
+ } ) ;
53
+
54
+ await proxyServer . start ( ) ;
55
+
56
+ const configBase = await fs . readFile ( '../../config.js' , 'utf8' ) ;
57
+ const config = configBase
58
+ . replace ( / (?< = c o n s t D E B U G = ` ) f a l s e / s, 'true' )
59
+ . replace ( / (?< = c o n s t C E R T _ P E M = ` ) [ ^ ` ] + (? = ` ) / s, cert . trim ( ) )
60
+ . replace ( / (?< = c o n s t P R O X Y _ H O S T = ' ) [ ^ ' ] + (? = ' ) / , '10.0.2.2' ) // Android emulator localhost IP
61
+ . replace ( / (?< = c o n s t P R O X Y _ P O R T = ) \d + (? = ; ) / , proxyServer . port . toString ( ) ) ;
62
+ await fs . writeFile ( './tmp/config.js' , config ) ;
63
+ } ) ;
12
64
13
65
before ( async ( ) => {
14
66
appiumServer = await appium . main ( {
15
67
loglevel : 'warn'
16
68
} ) ;
69
+ } ) ;
70
+
71
+ after ( async ( ) => {
72
+ if ( appiumServer ) {
73
+ await appiumServer . closeAllConnections ( ) ;
74
+ await appiumServer . close ( ) ;
75
+ await appiumServer . unref ( ) ;
76
+ }
77
+
78
+ if ( proxyServer ) {
79
+ await proxyServer . stop ( ) ;
80
+ }
81
+ } ) ;
82
+
83
+ beforeEach ( async ( ) => {
84
+ proxyServer . reset ( ) ;
85
+
86
+ let reqCount = 0 ;
87
+ proxyServer . forAnyRequest ( ) . thenCallback ( ( req ) => {
88
+ console . log ( `Intercepted request ${ reqCount ++ } : ${ req . method } ${ req . url } ` ) ;
89
+ return { statusCode : 200 , body : 'Mocked response' } ;
90
+ } ) ;
91
+
92
+ seenRequests = [ ] ;
93
+ tlsFailures = [ ] ;
94
+ proxyServer . on ( 'request' , ( req ) => seenRequests . push ( req ) ) ;
95
+ proxyServer . on ( 'tls-client-error' , ( event ) => tlsFailures . push ( event ) ) ;
96
+ } ) ;
97
+
98
+ async function launchFrida ( scripts : string [ ] ) {
99
+ fridaSession = ChildProcess . spawn ( 'frida' , [
100
+ '-U' ,
101
+ ...(
102
+ scripts . map ( ( script ) => [ '-l' , script ] ) . flat ( )
103
+ ) ,
104
+ '-f' , 'tech.httptoolkit.pinning_demo'
105
+ ] , {
106
+ cwd : '../..' ,
107
+ stdio : 'pipe'
108
+ } ) ;
109
+
110
+ fridaSession . stdout ?. pipe ( process . stdout ) ;
111
+ fridaSession . stderr ?. pipe ( process . stderr ) ;
112
+
113
+ // Wait for Frida to start the app successfully
114
+ await new Promise < void > ( ( resolve , reject ) => {
115
+ fridaSession ! . on ( 'error' , reject ) ;
116
+ fridaSession ! . stdout ?. on ( 'data' , ( d ) => {
117
+ if ( d . toString ( ) . includes ( 'Spawned `tech.httptoolkit.pinning_demo`' ) ) {
118
+ resolve ( ) ;
119
+ }
120
+ if ( d . toString ( ) . includes ( 'Error: ' ) ) {
121
+ reject ( new Error ( `Frida error: ${ d . toString ( ) } ` ) ) ;
122
+ }
123
+ } )
124
+ } ) ;
17
125
18
126
driver = await remote ( {
19
127
port : 4723 ,
128
+ logLevel : 'warn' ,
20
129
capabilities : {
21
130
platformName : 'android' ,
22
- 'appium:appPackage' : 'tech.httptoolkit.pinning_demo' ,
23
131
'appium:automationName' : 'UiAutomator2' ,
24
- 'appium:appActivity' : '.MainActivity'
132
+ 'appium:noReset' : true ,
133
+ 'appium:fullReset' : false ,
25
134
}
26
135
} ) ;
27
- } ) ;
136
+ }
28
137
29
- after ( async ( ) => {
30
- if ( driver ) await driver . deleteSession ( ) ;
31
- if ( appiumServer ) await appiumServer . close ( ) ;
32
- } )
33
-
34
- it ( 'should run a test' , async ( ) => {
35
- const button = driver . $ ( 'android=new UiSelector().text("UNPINNED REQUEST")' ) ;
36
- await button . click ( ) ;
37
-
38
- let contentDescription : string | undefined ;
39
- while ( ! contentDescription ) {
40
- await delay ( 500 ) ;
41
- contentDescription = await button . getAttribute ( 'content-desc' ) ;
138
+ afterEach ( async ( ) => {
139
+ if ( driver ) {
140
+ await driver . deleteSession ( ) ;
42
141
}
43
142
44
- expect ( contentDescription ) . to . include ( 'Success' ) ;
143
+ if ( fridaSession ) {
144
+ fridaSession . kill ( 'SIGUSR1' ) ;
145
+ await new Promise ( resolve => fridaSession ! . on ( 'exit' , resolve ) ) ;
146
+ }
147
+ } ) ;
148
+
149
+ // We run this 100% failure test first, to warm everything up
150
+ describe ( "without proxy config but no certificate trust" , ( ) => {
151
+
152
+ beforeEach ( async ( ) => {
153
+ await launchFrida ( [
154
+ './test/android/tmp/config.js' , // Our custom config
155
+ // Redirect traffic but don't configure the cert - everything should fail:
156
+ './android/android-proxy-override.js'
157
+ ] ) ;
158
+ } ) ;
159
+
160
+ it ( "all requests should fail" , async ( ) => {
161
+ const buttons = await driver . $$ ( 'android=new UiSelector().className("android.widget.Button")' ) ;
162
+ expect ( buttons ) . to . have . lengthOf ( 13 , 'Expected buttons were not present' ) ;
163
+
164
+ buttons . map ( button => button . click ( ) ) ;
165
+ await Promise . all ( await buttons . map ( async ( button ) => {
166
+ const buttonText = await button . getText ( ) ;
167
+ if ( ! IGNORED_BUTTONS . includes ( buttonText . toUpperCase ( ) ) ) {
168
+ expect ( await waitForContentDescription ( button ) ) . to . include ( 'Failed' ) ;
169
+ }
170
+ } ) ) ;
171
+
172
+ expect ( seenRequests ) . to . have . lengthOf ( 0 , 'Expected all requests to fail' ) ;
173
+ expect ( tlsFailures ) . to . have . lengthOf ( 13 , 'Expected TLS failures for all requests' ) ;
174
+ } ) ;
175
+
176
+ } ) ;
177
+
178
+ describe ( "given no interception" , ( ) => {
179
+
180
+ beforeEach ( async ( ) => {
181
+ await launchFrida ( [ ] ) ;
182
+ } ) ;
183
+
184
+ it ( 'all buttons should succeed initially' , async ( ) => {
185
+ const buttons = await driver . $$ ( 'android=new UiSelector().className("android.widget.Button")' ) ;
186
+ expect ( buttons ) . to . have . lengthOf ( 13 , 'Expected buttons were not present' ) ;
187
+
188
+ buttons . map ( button => button . click ( ) ) ;
189
+ await Promise . all ( await buttons . map ( async ( button ) => {
190
+ const buttonText = await button . getText ( ) ;
191
+ if ( ! IGNORED_BUTTONS . includes ( buttonText . toUpperCase ( ) ) ) {
192
+ expect ( await waitForContentDescription ( button ) ) . to . include ( 'Success' ) ;
193
+ }
194
+ } ) ) ;
195
+
196
+ expect ( seenRequests ) . to . have . lengthOf ( 0 , 'Expected no requests to be intercepted' ) ;
197
+ expect ( tlsFailures ) . to . have . lengthOf ( 0 , 'Expected no TLS failures' ) ;
198
+ } ) ;
199
+
200
+ } ) ;
201
+
202
+ describe ( "given basic interception" , ( ) => {
203
+
204
+ beforeEach ( async ( ) => {
205
+ await launchFrida ( [
206
+ './test/android/tmp/config.js' , // Our custom config
207
+ // Otherwise just the basic Android settings injection scripts to set the
208
+ // system cert & system proxy:
209
+ './android/android-proxy-override.js' ,
210
+ './android/android-system-certificate-injection.js'
211
+ ] ) ;
212
+ } ) ;
213
+
214
+ it ( "all unpinned requests should succeed, all others should fail" , async ( ) => {
215
+ const buttons = await driver . $$ ( 'android=new UiSelector().className("android.widget.Button")' ) ;
216
+ expect ( buttons ) . to . have . lengthOf ( 13 , 'Expected buttons were not present' ) ;
217
+
218
+ buttons . map ( button => button . click ( ) ) ;
219
+ await Promise . all ( await buttons . map ( async ( button ) => {
220
+ const buttonText = await button . getText ( ) ;
221
+ if ( buttonText . toUpperCase ( ) . startsWith ( 'UNPINNED' ) ) {
222
+ expect ( await waitForContentDescription ( button ) ) . to . include ( 'Success' ) ;
223
+ }
224
+ // Some pinnned requests will still pass because the basic cert
225
+ // injection is just *that* good.
226
+ } ) ) ;
227
+
228
+ expect ( seenRequests ) . to . have . lengthOf . at . least ( 3 , 'Expected unpinned requests to be intercepted' ) ;
229
+ expect ( tlsFailures ) . to . have . lengthOf . at . least ( 5 , 'Expected most pinned requests to fail' ) ;
230
+ } ) ;
231
+
232
+ } ) ;
233
+
234
+ describe ( "given full unpinned interception" , ( ) => {
235
+
236
+ beforeEach ( async ( ) => {
237
+ await launchFrida ( [
238
+ './test/android/tmp/config.js' , // Our custom config
239
+ // Otherwise the standard scripts, as in the README:
240
+ './native-connect-hook.js' ,
241
+ './native-tls-hook.js' ,
242
+ './android/android-proxy-override.js' ,
243
+ './android/android-system-certificate-injection.js' ,
244
+ './android/android-certificate-unpinning.js' ,
245
+ './android/android-certificate-unpinning-fallback.js' ,
246
+ './android/android-disable-root-detection.js' ,
247
+ ] ) ;
248
+ } ) ;
249
+
250
+ it ( "all buttons except 'Raw custom-pinned request' should succeed" , async ( ) => {
251
+ const buttons = await driver . $$ ( 'android=new UiSelector().className("android.widget.Button")' ) ;
252
+ expect ( buttons ) . to . have . lengthOf ( 13 , 'Expected buttons were not present' ) ;
253
+
254
+ buttons . map ( button => button . click ( ) ) ;
255
+ await Promise . all ( await buttons . map ( async ( button ) => {
256
+ const buttonText = await button . getText ( ) ;
257
+ if ( ! IGNORED_BUTTONS . includes ( buttonText . toUpperCase ( ) ) ) {
258
+ expect ( await waitForContentDescription ( button ) ) . to . include ( 'Success' ) ;
259
+ }
260
+ } ) ) ;
261
+
262
+ expect ( seenRequests ) . to . have . lengthOf ( 14 , 'Expected all requests to be intercepted' ) ;
263
+ expect ( tlsFailures ) . to . have . lengthOf ( 1 , 'Expected only raw request to fail' ) ;
264
+ } ) ;
265
+
45
266
} ) ;
46
267
47
268
} ) ;
0 commit comments