Skip to content

Commit fcdd175

Browse files
committed
Inject the Mockttp CT log into Android trusted CT log config
1 parent 8103f5a commit fcdd175

11 files changed

Lines changed: 945 additions & 16 deletions

File tree

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
"env-paths": "^1.0.0",
7575
"event-stream": "^4.0.1",
7676
"express": "^5.2.1",
77+
"flatbuffers": "25.1.24",
7778
"frida-js": "^0.4.0",
7879
"get-port": "^7.2.0",
7980
"graphql": "^16.13.2",

src/config.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@ export interface HtkConfig {
66
certPath: string;
77
certContent: string;
88
keyLength: number;
9+
certificateTransparency: boolean;
910
}
1011
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ async function generateHTTPSConfig(configPath: string) {
6666
keyPath,
6767
certPath,
6868
certContent,
69-
keyLength: 2048 // Reasonably secure keys please
69+
keyLength: 2048, // Reasonably secure keys please
70+
certificateTransparency: true
7071
};
7172
}
7273

src/interceptors/android/adb-commands.ts

Lines changed: 166 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,16 @@ import { logError } from '../../error-tracking';
88
import { waitUntil } from '../../util/promise';
99
import { getCertificateFingerprint, parseCert } from '../../certificates';
1010
import { streamToBuffer } from '../../util/stream';
11-
1211
export const ANDROID_TEMP = '/data/local/tmp';
1312
export const SYSTEM_CA_PATH = '/system/etc/security/cacerts';
1413

14+
const CT_LOG_DIR = '/data/misc/keychain/ct';
15+
const CT_LOG_LIST_PATHS = {
16+
v1: `${CT_LOG_DIR}/v1/current/log_list.json`,
17+
v2: `${CT_LOG_DIR}/v2/current/log_list.json`,
18+
v3: `${CT_LOG_DIR}/v3/current/log_list.ctfb` // Flatbuffer
19+
};
20+
1521
export const EMULATOR_HOST_IPS = [
1622
'10.0.2.2', // Standard emulator localhost ip
1723
'10.0.3.2', // Genymotion localhost ip
@@ -189,11 +195,11 @@ const filterDeviceNameCache = (connectedIds: string[]) => {
189195
};
190196

191197
export function stringAsStream(input: string) {
192-
const contentStream = new stream.Readable();
193-
contentStream._read = () => {};
194-
contentStream.push(input);
195-
contentStream.push(null);
196-
return contentStream;
198+
return bufferAsStream(Buffer.from(input, 'utf8'));
199+
}
200+
201+
function bufferAsStream(input: Buffer) {
202+
return stream.Readable.from([input], { objectMode: false });
197203
}
198204

199205
async function run(
@@ -279,7 +285,7 @@ const runAsRootCommands = [
279285
(...cmd: string[]) => ['su', 'root', cmd.join(' ')]
280286
];
281287

282-
type RootCmd = (...cmd: string[]) => string[];
288+
export type RootCmd = (...cmd: string[]) => string[];
283289

284290
export async function getRootCommand(adbClient: Adb.DeviceClient): Promise<RootCmd | undefined> {
285291
const rootTestScriptPath = `${ANDROID_TEMP}/htk-root-test.sh`;
@@ -412,6 +418,47 @@ const isMatchingCert = async (certStream: stream.Readable, expectedFingerprint:
412418
return expectedFingerprint === existingFingerprint;
413419
}
414420

421+
// Push a script, run it as root, and (optionally) check that it reported success. The
422+
// script deletes itself on any exit path, so nothing is left behind on the device.
423+
async function runRootScript(
424+
adbClient: Adb.DeviceClient,
425+
runAsRoot: RootCmd,
426+
scriptName: string,
427+
script: string,
428+
options: {
429+
files?: Array<{ content: Buffer, path: string }>,
430+
successMarker?: string,
431+
timeout?: number,
432+
skipLogging?: boolean
433+
} = {}
434+
) {
435+
const scriptPath = `${ANDROID_TEMP}/${scriptName}`;
436+
437+
await Promise.all([
438+
...(options.files ?? []).map(({ content, path }) =>
439+
// Due to an Android bug, user mode is always duplicated to group & others. We set as
440+
// read-only to avoid making these writable by others before we use them as root in a
441+
// moment. More details: https://github.com/openstf/adbkit/issues/126
442+
pushFile(adbClient, bufferAsStream(content), path, 0o444)
443+
),
444+
pushFile(adbClient, stringAsStream(`
445+
trap 'rm -f ${scriptPath}' EXIT
446+
${script}
447+
`), scriptPath, 0o444)
448+
]);
449+
450+
const output = await run(adbClient, runAsRoot('sh', scriptPath), {
451+
timeout: options.timeout ?? 10000,
452+
skipLogging: options.skipLogging
453+
});
454+
455+
if (options.successMarker && !output.includes(options.successMarker)) {
456+
throw new Error(`${scriptName} failed`);
457+
}
458+
459+
return output;
460+
}
461+
415462
export async function injectSystemCertificate(
416463
adbClient: Adb.DeviceClient,
417464
runAsRoot: RootCmd,
@@ -529,6 +576,118 @@ export async function injectSystemCertificate(
529576
}
530577
}
531578

579+
// Read device's current CT log list. Undefined if there is none, but throws if it can't
580+
// be read for some reason.
581+
export async function readCtLogList(
582+
adbClient: Adb.DeviceClient,
583+
runAsRoot: RootCmd
584+
): Promise<Buffer | undefined> {
585+
// Multiple possible formats, most recent is used by preference. Can't pull directly as we
586+
// need `su`, so we read on-device and base64 to avoid binary getting mangled in transfer.
587+
// To confirm the exact result, we need to wrap our output with extra info, otherwise we
588+
// can't reliably differentiate missing/error/truncated/OK.
589+
const output = await runRootScript(adbClient, runAsRoot, 'htk-read-ct-logs.sh', `
590+
for LIST_PATH in ${[
591+
CT_LOG_LIST_PATHS.v3,
592+
CT_LOG_LIST_PATHS.v2,
593+
CT_LOG_LIST_PATHS.v1
594+
].join(' ')}; do
595+
if [ -f "$LIST_PATH" ]; then
596+
echo "HTK-CT-LIST $LIST_PATH"
597+
base64 "$LIST_PATH"
598+
echo "HTK-CT-READ $?"
599+
exit 0
600+
fi
601+
done
602+
603+
echo "HTK-CT-LIST none"
604+
echo "HTK-CT-READ 0"
605+
`, { timeout: 3000, skipLogging: true });
606+
607+
const result = output.match(/HTK-CT-LIST (\S+)\r?\n([\s\S]*)HTK-CT-READ (\d+)/);
608+
if (!result) {
609+
throw new Error(`Could not read the device's CT log list: ${
610+
output.trim().slice(0, 200) || 'no output'
611+
}`);
612+
}
613+
614+
const [, listPath, listContent, readResult] = result;
615+
616+
if (listPath === 'none') {
617+
console.log('No existing CT log list found on device');
618+
return undefined;
619+
}
620+
621+
if (readResult !== '0') {
622+
throw new Error(`Reading ${listPath} failed with status ${readResult}`);
623+
}
624+
625+
console.log(`Read existing CT log list from ${listPath}`);
626+
return Buffer.from(listContent.replace(/[^A-Za-z0-9+/=]/g, ''), 'base64');
627+
}
628+
629+
export async function injectCtLogLists(
630+
adbClient: Adb.DeviceClient,
631+
runAsRoot: RootCmd,
632+
logLists: { json: Buffer, ctfb: Buffer }
633+
) {
634+
const jsonPath = `${ANDROID_TEMP}/htk-ct-log-list.json`;
635+
const ctfbPath = `${ANDROID_TEMP}/htk-ct-log-list.ctfb`;
636+
637+
// Use tmpfs to shadow device's CT config with our extended version:
638+
await runRootScript(adbClient, runAsRoot, 'htk-inject-ct-logs.sh', `
639+
set -e # Fail on error
640+
641+
echo "\n---\nInjecting CT log lists:"
642+
643+
# Drop any previous injection, so that repeated runs don't stack up mounts. This
644+
# has to be lazy: apps keep the log list mmapped, so a normal umount fails while
645+
# any of them are still running.
646+
for i in 1 2 3 4 5; do umount -l ${CT_LOG_DIR} 2>/dev/null || break; done
647+
648+
# A fresh tmpfs is labelled u:object_r:tmpfs:s0, which apps can't read, so we
649+
# relabel everything to match the SELinux context Android uses here normally:
650+
set -- $(ls -Zd /data/misc/keychain 2>/dev/null || true)
651+
CT_CONTEXT=$1
652+
case "$CT_CONTEXT" in
653+
u:object_r:*) ;;
654+
*) CT_CONTEXT=u:object_r:keychain_data_file:s0 ;;
655+
esac
656+
657+
mkdir -p ${CT_LOG_DIR}
658+
mount -t tmpfs tmpfs ${CT_LOG_DIR}
659+
660+
mkdir -p ${[
661+
CT_LOG_LIST_PATHS.v1,
662+
CT_LOG_LIST_PATHS.v2,
663+
CT_LOG_LIST_PATHS.v3
664+
].map((listPath) => path.posix.dirname(listPath)).join(' ')}
665+
666+
mv ${jsonPath} ${CT_LOG_LIST_PATHS.v1}
667+
cp ${CT_LOG_LIST_PATHS.v1} ${CT_LOG_LIST_PATHS.v2}
668+
mv ${ctfbPath} ${CT_LOG_LIST_PATHS.v3}
669+
670+
# Make everything as readable as the real log lists are:
671+
chown -R root:root ${CT_LOG_DIR}
672+
chmod -R 755 ${CT_LOG_DIR}
673+
chmod 644 ${Object.values(CT_LOG_LIST_PATHS).join(' ')}
674+
chcon -R $CT_CONTEXT ${CT_LOG_DIR}
675+
676+
# Read-only, so that the daily CT log list update can't replace our lists. The
677+
# update fails & is retried later instead, which is harmless.
678+
mount -o remount,ro tmpfs ${CT_LOG_DIR} ||
679+
echo 'Could not remount the CT log lists as read-only'
680+
681+
echo "CT log lists successfully injected\n---\n"
682+
`, {
683+
files: [
684+
{ content: logLists.json, path: jsonPath },
685+
{ content: logLists.ctfb, path: ctfbPath }
686+
],
687+
successMarker: 'CT log lists successfully injected'
688+
});
689+
}
690+
532691
export async function setChromeFlags(
533692
adbClient: Adb.DeviceClient,
534693
runAsRoot: RootCmd,

src/interceptors/android/android-adb-interceptor.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,17 @@ import {
2121
startActivity,
2222
createPersistentReverseTunnel,
2323
closeReverseTunnel,
24-
EMULATOR_HOST_IPS
24+
readCtLogList,
25+
injectCtLogLists,
26+
EMULATOR_HOST_IPS,
27+
RootCmd
2528
} from './adb-commands';
29+
import {
30+
buildCtLogList,
31+
ctLogListIncludesCa,
32+
parseCtLogList,
33+
serializeCtLogLists
34+
} from './ct-log-list';
2635
import { streamLatestApk, clearAllApks } from './fetch-apk';
2736
import { parseCert, getCertificateFingerprint, getCertificateSubjectHash } from '../../certificates';
2837
import { getReachableInterfaces } from '../../util/network';
@@ -72,7 +81,7 @@ export class AndroidAdbInterceptor implements Interceptor {
7281
}): Promise<void | {}> {
7382
const deviceClient = new DeviceClient(this.adbClient, options.deviceId);
7483

75-
await this.injectSystemCertIfPossible(deviceClient, this.config.https.certContent);
84+
await this.setupCertificateTrust(deviceClient, this.config.https.certContent);
7685

7786
if (!(await deviceClient.isInstalled('tech.httptoolkit.android.v1'))) {
7887
console.log("App not installed, installing...");
@@ -160,13 +169,19 @@ export class AndroidAdbInterceptor implements Interceptor {
160169
);
161170
}
162171

163-
private async injectSystemCertIfPossible(deviceClient: DeviceClient, certContent: string) {
172+
private async setupCertificateTrust(deviceClient: DeviceClient, certContent: string) {
164173
const rootCmd = await getRootCommand(deviceClient);
165174
if (!rootCmd) {
166175
console.log('Root not available, skipping cert injection');
167176
return;
168177
}
169178

179+
// Read the device's CT log list in parallel with cert setup:
180+
const existingCtLogList = this.config.https.certificateTransparency
181+
? readCtLogList(deviceClient, rootCmd)
182+
: undefined;
183+
existingCtLogList?.catch(() => {}); // Errors are handled where we await this below
184+
170185
const cert = parseCert(certContent);
171186

172187
try {
@@ -191,10 +206,15 @@ export class AndroidAdbInterceptor implements Interceptor {
191206
console.log("Cert already installed, nothing to do");
192207
}
193208

209+
if (existingCtLogList) {
210+
await this.injectCtLogListIfNeeded(deviceClient, rootCmd, certContent, existingCtLogList)
211+
.catch(logError); // Continue but log the failure
212+
}
213+
194214
const spkiFingerprint = await generateSPKIFingerprint(certContent);
195215

196-
// Chrome requires system certificates to use certificate transparency, which we can't do. To work
197-
// around this, we need to explicitly trust our certificate in Chrome:
216+
// Chrome only trusts SCTs from the CT logs it recognizes itself, which we can't
217+
// provide. To work around that, we explicitly trust our certificate in Chrome:
198218
await setChromeFlags(deviceClient, rootCmd, [
199219
`--ignore-certificate-errors-spki-list=${spkiFingerprint}`
200220
]);
@@ -203,4 +223,27 @@ export class AndroidAdbInterceptor implements Interceptor {
203223
logError(e);
204224
}
205225
}
206-
}
226+
227+
// From Android 16 (and by default from Android 17) apps can require certificate
228+
// transparency, which rejects any certificate without SCTs from known CT logs - including
229+
// ours. Mockttp stamps our certificates with SCTs from logs derived from the CA, so here
230+
// we add those logs to the device's log list to make those SCTs verifiable.
231+
private async injectCtLogListIfNeeded(
232+
deviceClient: DeviceClient,
233+
rootCmd: RootCmd,
234+
certContent: string,
235+
existingListRead: Promise<Buffer | undefined>
236+
) {
237+
const existingList = parseCtLogList(await existingListRead);
238+
239+
if (ctLogListIncludesCa(existingList, certContent)) {
240+
console.log("CT log list already installed, nothing to do");
241+
return;
242+
}
243+
244+
await injectCtLogLists(deviceClient, rootCmd,
245+
serializeCtLogLists(buildCtLogList(certContent, existingList))
246+
);
247+
console.log('CT log list injected');
248+
}
249+
}

0 commit comments

Comments
 (0)