Skip to content

Commit c1b4443

Browse files
committed
Rough but fundamentally working demo of iOS+Android Frida interception
1 parent cdf5d27 commit c1b4443

File tree

7 files changed

+379
-6
lines changed

7 files changed

+379
-6
lines changed
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import * as _ from 'lodash';
2+
import * as React from 'react';
3+
import { computed, observable, action, autorun, flow } from 'mobx';
4+
import { observer, inject, disposeOnUnmount } from 'mobx-react';
5+
6+
import { styled } from '../../../styles';
7+
8+
import { Interceptor } from '../../../model/interception/interceptors';
9+
import { ProxyStore } from '../../../model/proxy-store';
10+
import { AccountStore } from '../../../model/account/account-store';
11+
import { EventsStore } from '../../../model/events/events-store';
12+
import { RulesStore } from '../../../model/rules/rules-store';
13+
import { FridaActivationOptions, FridaHost, FridaTarget } from '../../../model/interception/frida';
14+
15+
import { getDetailedInterceptorMetadata } from '../../../services/server-api';
16+
17+
import { TextInput } from '../../common/inputs';
18+
import { Icon } from '../../../icons';
19+
import { InterceptionTargetList } from './intercept-target-list';
20+
import { IconButton } from '../../common/icon-button';
21+
22+
const ConfigContainer = styled.div`
23+
user-select: text;
24+
max-height: 440px;
25+
26+
height: 100%;
27+
width: 100%;
28+
display: flex;
29+
flex-direction: column;
30+
justify-content: start;
31+
32+
> p {
33+
line-height: 1.2;
34+
35+
&:not(:last-child) {
36+
margin-bottom: 5px;
37+
}
38+
39+
&:not(:first-child) {
40+
margin-top: 5px;
41+
}
42+
}
43+
44+
a[href] {
45+
color: ${p => p.theme.linkColor};
46+
47+
&:visited {
48+
color: ${p => p.theme.visitedLinkColor};
49+
}
50+
}
51+
`;
52+
53+
const BackAndSearchBlock = styled.div`
54+
margin: 5px -15px 0;
55+
56+
display: flex;
57+
flex-direction: row;
58+
align-items: stretch;
59+
60+
z-index: 1;
61+
box-shadow: 0 0 5px 2px rgba(0,0,0,${p => p.theme.boxShadowAlpha});
62+
63+
`;
64+
65+
const BackButton = styled(IconButton).attrs(() => ({
66+
icon: ['fas', 'arrow-left'],
67+
title: 'Jump to this request on the View page'
68+
}))`
69+
font-size: ${p => p.theme.textSize};
70+
padding: 2px 10px 0;
71+
`;
72+
73+
const SearchBox = styled(TextInput)`
74+
flex-grow: 1;
75+
76+
border: none;
77+
border-radius: 0;
78+
padding: 10px 10px 8px;
79+
`;
80+
81+
const Footer = styled.p`
82+
margin-top: auto;
83+
font-size: 85%;
84+
font-style: italic;
85+
`;
86+
87+
@inject('proxyStore')
88+
@inject('rulesStore')
89+
@inject('eventsStore')
90+
@inject('accountStore')
91+
@observer
92+
class FridaConfig extends React.Component<{
93+
proxyStore?: ProxyStore,
94+
rulesStore?: RulesStore,
95+
eventsStore?: EventsStore,
96+
accountStore?: AccountStore,
97+
98+
interceptor: Interceptor,
99+
activateInterceptor: (options: FridaActivationOptions) => Promise<void>,
100+
reportStarted: () => void,
101+
reportSuccess: () => void,
102+
closeSelf: () => void
103+
}> {
104+
105+
@computed private get fridaHosts(): Array<FridaHost> {
106+
return this.props.interceptor.metadata?.hosts || [];
107+
}
108+
109+
@observable fridaTargets: Array<FridaTarget> = [];
110+
111+
updateTargets = flow(function * (this: FridaConfig) {
112+
if (!this.selectedHost) {
113+
this.fridaTargets = [];
114+
return;
115+
}
116+
117+
const result: {
118+
targets: FridaTarget[]
119+
} | undefined = (
120+
yield getDetailedInterceptorMetadata(this.props.interceptor.id, this.selectedHost?.id)
121+
);
122+
123+
this.fridaTargets = result?.targets ?? [];
124+
}.bind(this));
125+
126+
127+
@observable private inProgressHostIds: string[] = [];
128+
@observable private inProgressTargetIds: string[] = [];
129+
130+
async componentDidMount() {
131+
if (this.fridaHosts.length === 1 && this.fridaHosts[0].state === 'available') {
132+
this.selectHost(this.fridaHosts[0].id);
133+
}
134+
135+
disposeOnUnmount(this, autorun(() => {
136+
if (this.selectedHostId && !this.fridaHosts.some(host => host.id === this.selectedHostId)) {
137+
this.deselectHost();
138+
}
139+
}));
140+
141+
this.updateTargets();
142+
const updateInterval = setInterval(this.updateTargets, 2000);
143+
disposeOnUnmount(this, () => clearInterval(updateInterval));
144+
}
145+
146+
@computed
147+
get deviceClassName() {
148+
const interceptorId = this.props.interceptor.id;
149+
if (interceptorId === 'android-frida') {
150+
return 'Android';
151+
} else if (interceptorId === 'ios-frida') {
152+
return 'iOS';
153+
} else {
154+
throw new Error(`Unknown Frida interceptor type: ${interceptorId}`);
155+
}
156+
}
157+
158+
@observable selectedHostId: string | undefined;
159+
160+
@computed
161+
get selectedHost() {
162+
if (!this.selectedHostId) return;
163+
const hosts = this.fridaHosts;
164+
return this.fridaHosts.find(host => host.id === this.selectedHostId && host.state !== 'unavailable');
165+
}
166+
167+
@action.bound
168+
selectHost(hostId: string) {
169+
this.selectedHostId = hostId;
170+
171+
const host = this.selectedHost;
172+
if (host?.state === 'available') {
173+
this.searchInput = '';
174+
this.updateTargets();
175+
} else if (host?.state === 'launch-required') {
176+
this.inProgressHostIds.push(hostId);
177+
this.props.activateInterceptor({
178+
action: 'launch',
179+
hostId
180+
}).finally(action(() => {
181+
_.pull(this.inProgressHostIds, hostId);
182+
}));
183+
} else if (host?.state === 'setup-required') {
184+
this.inProgressHostIds.push(hostId);
185+
this.props.activateInterceptor({
186+
action: 'setup',
187+
hostId
188+
}).finally(action(() => {
189+
_.pull(this.inProgressHostIds, hostId);
190+
}));
191+
} else {
192+
return;
193+
}
194+
}
195+
196+
@action.bound
197+
deselectHost() {
198+
this.selectedHostId = undefined;
199+
}
200+
201+
@action.bound
202+
interceptTarget(targetId: string) {
203+
const host = this.selectedHost;
204+
205+
if (!host) return;
206+
207+
this.inProgressTargetIds.push(targetId);
208+
this.props.activateInterceptor({
209+
action: 'intercept',
210+
hostId: host.id,
211+
targetId
212+
}).finally(action(() => {
213+
_.pull(this.inProgressTargetIds, targetId);
214+
}));
215+
}
216+
217+
@observable searchInput: string = '';
218+
219+
@action.bound
220+
onSearchChange(event: React.ChangeEvent<HTMLInputElement>) {
221+
this.searchInput = event.currentTarget.value;
222+
}
223+
224+
render() {
225+
const selectedHost = this.selectedHost;
226+
227+
const docsFooter = <Footer>
228+
For more information, see the in-depth <a
229+
href="https://httptoolkit.com/docs/guides/frida/"
230+
>Frida interception guide</a>.
231+
</Footer>;
232+
233+
if (selectedHost) {
234+
const lowercaseSearchInput = this.searchInput.toLowerCase();
235+
const targets = _.sortBy(
236+
this.fridaTargets
237+
.filter(({ name }) => name.toLowerCase().includes(lowercaseSearchInput)),
238+
(target) => target.name.toLowerCase()
239+
);
240+
241+
return <ConfigContainer>
242+
<BackAndSearchBlock>
243+
<BackButton onClick={this.deselectHost} />
244+
<SearchBox
245+
value={this.searchInput}
246+
onChange={this.onSearchChange}
247+
placeholder='Search for a target...'
248+
autoFocus={true}
249+
/>
250+
</BackAndSearchBlock>
251+
<InterceptionTargetList
252+
spinnerText='Scanning for apps to intercept...'
253+
targets={targets.map(target => {
254+
const { id, name } = target;
255+
const activating = this.inProgressTargetIds.includes(id);
256+
257+
return {
258+
id,
259+
title: `${this.deviceClassName} app: ${name} (${id})`,
260+
status: activating
261+
? 'activating'
262+
: 'available',
263+
content: <p>
264+
{ name }
265+
</p>
266+
};
267+
})
268+
}
269+
interceptTarget={this.interceptTarget}
270+
ellipseDirection='right'
271+
/>
272+
{ docsFooter }
273+
</ConfigContainer>;
274+
}
275+
276+
return <ConfigContainer>
277+
<InterceptionTargetList
278+
spinnerText={`Waiting for ${this.deviceClassName} devices to attach to...`}
279+
targets={this.fridaHosts.map(host => {
280+
const { id, name, state } = host;
281+
const activating = this.inProgressHostIds.includes(id);
282+
283+
return {
284+
id,
285+
title: `${this.deviceClassName} device ${name} in state ${state}`,
286+
status: activating
287+
? 'activating'
288+
: state === 'unavailable'
289+
? 'unavailable'
290+
// Available here means clickable - interceptable/setupable/launchable
291+
: 'available',
292+
content: <p>
293+
{
294+
activating
295+
? <Icon icon={['fas', 'spinner']} spin />
296+
: id.includes("emulator-")
297+
? <Icon icon={['far', 'window-maximize']} />
298+
: id.match(/\d+\.\d+\.\d+\.\d+:\d+/)
299+
? <Icon icon={['fas', 'network-wired']} />
300+
: <Icon icon={['fas', 'mobile-alt']} />
301+
} { name }<br />{ state }
302+
</p>
303+
};
304+
})}
305+
interceptTarget={this.selectHost}
306+
ellipseDirection='right'
307+
/>
308+
{ docsFooter }
309+
</ConfigContainer>;
310+
}
311+
312+
onSuccess = () => {
313+
this.props.reportSuccess();
314+
};
315+
316+
}
317+
318+
export const FridaCustomUi = {
319+
columnWidth: 1,
320+
rowHeight: 2,
321+
configComponent: FridaConfig
322+
};

src/components/intercept/config/intercept-target-list.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@ const Spinner = styled(Icon).attrs(() => ({
2828
`;
2929

3030
const ListScrollContainer = styled.div`
31-
max-height: 279px;
3231
overflow-y: auto;
3332
margin: 10px -15px;
3433
flex-grow: 1;

src/model/interception/frida.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export interface FridaHost {
2+
id: string;
3+
name: string;
4+
type: string;
5+
state:
6+
| 'unavailable'
7+
| 'setup-required'
8+
| 'launch-required'
9+
| 'available'
10+
}
11+
12+
export interface FridaTarget {
13+
id: string;
14+
name: string;
15+
}
16+
17+
export type FridaActivationOptions =
18+
| { action: 'setup', hostId: string }
19+
| { action: 'launch', hostId: string }
20+
| { action: 'intercept', hostId: string, targetId: string };

src/model/interception/interceptors.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ExistingTerminalCustomUi } from "../../components/intercept/config/exis
1515
import { ElectronCustomUi } from '../../components/intercept/config/electron-config';
1616
import { AndroidDeviceCustomUi } from "../../components/intercept/config/android-device-config";
1717
import { AndroidAdbCustomUi } from "../../components/intercept/config/android-adb-config";
18+
import { FridaCustomUi } from "../../components/intercept/config/frida-config";
1819
import { ExistingBrowserCustomUi } from "../../components/intercept/config/existing-browser-config";
1920
import { JvmCustomUi } from "../../components/intercept/config/jvm-config";
2021
import { DockerAttachCustomUi } from "../../components/intercept/config/docker-attach-config";
@@ -268,6 +269,30 @@ const INTERCEPT_OPTIONS: _.Dictionary<InterceptorConfig> = {
268269
uiConfig: AndroidDeviceCustomUi,
269270
tags: [...MOBILE_TAGS, ...ANDROID_TAGS]
270271
},
272+
'android-frida': {
273+
name: 'Android App via Frida',
274+
description: [
275+
'Intercept a target Android app',
276+
'This automatically disables most certificate pinning',
277+
'Requires a rooted device connected to ADB'
278+
],
279+
iconProps: recoloured(androidInterceptIconProps, '#ef6456'),
280+
281+
uiConfig: FridaCustomUi,
282+
tags: [...MOBILE_TAGS, ...ANDROID_TAGS]
283+
},
284+
'ios-frida': {
285+
name: 'iOS App via Frida',
286+
description: [
287+
'Intercept a target iOS app',
288+
'This automatically disables most certificate pinning',
289+
'Requires a jailbroken device running Frida Server'
290+
],
291+
iconProps: recoloured(SourceIcons.iOS, '#ef6456'),
292+
293+
uiConfig: FridaCustomUi,
294+
tags: [...MOBILE_TAGS, ...IOS_TAGS]
295+
},
271296
'manual-ios-device': {
272297
name: 'iOS via Manual Setup',
273298
description: ["Manually intercept all HTTP and HTTPS traffic from any iPhone or iPad"],

0 commit comments

Comments
 (0)