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