leonitousconforti/tinyburg

View on GitHub
packages/insight/src/agents/base-frida-agent.ts

Summary

Maintainability
D
1 day
Test Coverage
import "frida-il2cpp-bridge";

// Fix for https://github.com/vfsfitvnm/frida-il2cpp-bridge/issues/264#issuecomment-1490798519
/* eslint-disable @typescript-eslint/no-explicit-any */
(globalThis as any).Module = new Proxy(Module, {
    cache: {},

    get(target: typeof Module, property: string | symbol): NativePointer | undefined {
        const patchedFindExportByName = (moduleName: string | null, exportName: string) => {
            if (moduleName === null) {
                return Reflect.get(target, property)(moduleName, exportName);
            }
            this.cache[moduleName] ??= (Module as any).enumerateExports(moduleName);
            return this.cache[moduleName]!.find((module: ModuleExportDetails) => module.name === exportName)?.address;
        };
        return property === "findExportByName" ? patchedFindExportByName : Reflect.get(target, property);
    },
} as ProxyHandler<typeof Module> & { cache: Record<string, ModuleExportDetails[]> });
/* eslint-enable @typescript-eslint/no-explicit-any */

/**
 * A dependency can be either an entire c# assembly, a single class, a
 * static/non-static object in a class, a field on a class or a method on a
 * class.
 */
export type Dependency = Il2Cpp.Assembly | Il2Cpp.Class | Il2Cpp.Object | Il2Cpp.Field | Il2Cpp.Method | Il2Cpp.Array;

/**
 * If the class has static fields that need to be initialized as well, the
 * static constructor on the class can be called by setting the
 * callStaticConstructor in the dependencies metadata field.
 *
 * @see https://learn.microsoft.com/en-us/dotnet/csharp/programming-guide/classes-and-structs/static-constructors
 */
export interface IDependenciesMeta {
    callStaticConstructor?: boolean;
}

/**
 * A dependency loader is a function that returns a map of names to
 * dependencies. The dependencies will be made available in the retrieve data
 * method using their names from this map. A value in this map can be either
 * just the {@link Dependency|dependency} or an object with a dependency field
 * and a {@link IDependenciesMeta|metadata} field.
 */
export type DependencyLoader = () => { [k: string]: Dependency | { dependency: Dependency; meta: IDependenciesMeta } };

/** These two methods are required for all agents */
export interface ITinyTowerFridaAgent {
    loadDependencies: DependencyLoader;
    retrieveData: () => unknown;
}

/**
 * Base Frida agent that handles retrying when loading dependencies and
 * communicating with the other side of the application.
 */
export abstract class TinyTowerFridaAgent<T extends ITinyTowerFridaAgent> {
    /**
     * Time to wait (in milliseconds) after the dependencies failed to load
     * before retrying.
     */
    private readonly _loadDependenciesWaitMs: number;

    /**
     * Maximum number of times to retry loading the dependencies before giving
     * up and throwing an error.
     */
    private readonly _loadDependenciesMaxRetries: number;

    /**
     * Any dependencies returned from the loadDependencies method will be
     * exposed in this field to any other method of the class, assuming that
     * they all loaded correctly.
     */
    public dependencies: ReturnType<T["loadDependencies"]>;

    /**
     * The data returned from the retrieveData method will be exposed in this
     * field to any other method in the class, assuming that there was no error
     * when getting the data.
     */
    public data: ReturnType<T["retrieveData"]>;

    public constructor(loadDependenciesMaxRetries: number = 5, loadDependenciesWaitMs: number = 5000) {
        this._loadDependenciesWaitMs = loadDependenciesWaitMs;
        this._loadDependenciesMaxRetries = loadDependenciesMaxRetries;
        this.data = {} as ReturnType<T["retrieveData"]>;
        this.dependencies = {} as ReturnType<T["loadDependencies"]>;
    }

    /**
     * A synchronous method that returns an object of the
     * {@link Dependency|dependencies} to be used in any other method. Other
     * methods have access to the dependencies via this.dependencies, which has
     * the return type of this method. This method might be called multiple
     * times until all the items in the object are non-null, which indicates
     * that they are properly loaded, or until the load dependencies max retries
     * value is reached at which point an error will be thrown that not all
     * dependencies could be loaded.
     *
     * @see {@link DependencyLoader}
     */
    public abstract loadDependencies(): ReturnType<T["loadDependencies"]>;

    /**
     * A synchronous method that retrieves the desired data from the application
     * using frida and frida-il2cpp-bridge.
     */
    public abstract retrieveData(): ReturnType<T["retrieveData"]>;

    /**
     * Kicks starts the frida agent by trying to load the dependencies through
     * the loadDependencies method. Once the dependencies are properly loaded,
     * calls the retrieveData method.
     */
    public async start(): Promise<this> {
        // Wrap all this in an Il2cpp.perform
        await Il2Cpp.perform(async () => {
            // Try to load the dependencies
            this.dependencies = await this._waitForDependencies(
                this.loadDependencies,
                this._loadDependenciesMaxRetries,
                this._loadDependenciesWaitMs
            );

            // Invoke the retrieveData method
            this.data = this.retrieveData();
        }, "bind");

        return this;
    }

    /**
     * Waits for all the dependencies from the dependency loader method to be
     * non-null. If one or more of the dependencies fails to load, then a retry
     * is performed after a set amount of time. If all the dependencies have not
     * properly loaded after the maximum number of retries then an error will be
     * thrown. This method is recursive and uses the retries parameter to track
     * how many retries it has left.
     *
     * @param dependencyLoader - The dependency loader function to call
     * @param retries - Base case for recursion, when retries is less than or
     *   equal to zero an error will be thrown because not all dependencies
     *   could be loaded after the specified number of retries.
     * @param waitMs - How long to wait, in milliseconds, after failing to load
     *   some dependencies from the dependencyLoader.
     * @returns - The successfully loaded dependencies
     */
    private async _waitForDependencies<T extends DependencyLoader>(
        dependencyLoader: T,
        retries: number,
        waitMs: number
    ): Promise<ReturnType<T>> {
        // Wrap in a try-catch, don't want errors to bubble up because there might
        // be the opportunity to retry.
        try {
            // Call the dependency loader function
            const dependencies = dependencyLoader();
            const dependencyEntries = Object.entries(dependencies);

            // Makes all dependencies into [Dependency, IDependenciesMeta | undefined]
            const justDependencies = dependencyEntries.map(([__name, dep]) =>
                dep instanceof Il2Cpp.Assembly ||
                dep instanceof Il2Cpp.Class ||
                dep instanceof Il2Cpp.Object ||
                dep instanceof Il2Cpp.Method ||
                dep instanceof Il2Cpp.Field ||
                dep instanceof Il2Cpp.Array
                    ? ([dep] as const)
                    : ([dep.dependency, dep.meta] as const)
            );

            // Get all the class dependencies, will need to check for callStaticConstructor
            const classDependencies = justDependencies.filter(([dep]) => dep instanceof Il2Cpp.Class);

            // If all dependencies did not load successfully
            if (!justDependencies.every(([dep]) => !dep.isNull())) {
                // Search for any classes with static constructors that might need to be called
                send("Searching for any class dependencies that have static constructors...");
                for (const [dep, meta] of classDependencies) {
                    const klass = dep as Il2Cpp.Class;
                    if (meta?.callStaticConstructor && klass.hasStaticConstructor) {
                        send(`Initializing class ${klass.name} by calling its static constructor now`);
                        klass.initialize();
                    }
                }

                throw new Error("Did not load all dependencies properly");
            }

            // All the dependencies were loaded correctly
            send("Success, all dependencies have been loaded!");
            return dependencies as ReturnType<T>;
        } catch (error: unknown) {
            // If there are retries remaining
            if (retries > 0) {
                send(
                    `Failed to load all dependencies, will try ${retries} more times, waiting for ${waitMs}ms before the next attempt`
                );

                // Wait for the desired timeout and then recurse
                await new Promise((resolve) => setTimeout(resolve, waitMs));
                return this._waitForDependencies(dependencyLoader, retries - 1, waitMs);
            }

            // Otherwise, throw this error to reject the promise
            throw error;
        }
    }

    protected transformEnumFieldsToSource = (enumFields: [string, string][]): string =>
        enumFields.map(([name, value]) => `${name} = "${value}"`).join(", \n");
}