diff --git a/agent/src/ios/nsuserdefaults.ts b/agent/src/ios/nsuserdefaults.ts index 1484ae08..9b70d587 100644 --- a/agent/src/ios/nsuserdefaults.ts +++ b/agent/src/ios/nsuserdefaults.ts @@ -8,11 +8,38 @@ import { export const get = (): NSUserDefaults | any => { // -- Sample Objective-C // - // NSUserDefaults *d = [[NSUserDefaults alloc] init]; + // NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; // NSLog(@"%@", [d dictionaryRepresentation]); - const defaults: NSUserDefaults = ObjC.classes.NSUserDefaults; - const data: NSDictionary = defaults.alloc().init().dictionaryRepresentation(); + const defaults: NSUserDefaults = ObjC.classes.NSUserDefaults.standardUserDefaults(); + const data: NSDictionary = defaults.dictionaryRepresentation(); return data.toString(); }; + +export const set = (key: string, value: any, valueType?: string): boolean => { + // -- Sample Objective-C + // + // NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + // [d setObject:value forKey:key]; + // [d synchronize]; + + const defaults: NSUserDefaults = ObjC.classes.NSUserDefaults.standardUserDefaults(); + + // Determine type and set accordingly + if (valueType === "bool") { + defaults.setBool_forKey_(value, key); + } else if (valueType === "int") { + defaults.setInteger_forKey_(value, key); + } else if (valueType === "float") { + defaults.setDouble_forKey_(value, key); + } else { + // Default to string/object + defaults.setObject_forKey_(value, key); + } + + // Persist to disk + defaults.synchronize(); + + return true; +}; diff --git a/agent/src/rpc/ios.ts b/agent/src/rpc/ios.ts index 9e7836ed..46b242c6 100644 --- a/agent/src/rpc/ios.ts +++ b/agent/src/rpc/ios.ts @@ -105,4 +105,5 @@ export const ios = { // ios nsuserdefaults iosNsuserDefaultsGet: (): NSUserDefaults | any => nsuserdefaults.get(), + iosNsuserDefaultsSet: (key: string, value: any, valueType?: string): boolean => nsuserdefaults.set(key, value, valueType), }; diff --git a/objection/commands/ios/nsuserdefaults.py b/objection/commands/ios/nsuserdefaults.py index e4ee42fd..aaf4b782 100644 --- a/objection/commands/ios/nsuserdefaults.py +++ b/objection/commands/ios/nsuserdefaults.py @@ -3,6 +3,18 @@ from objection.state.connection import state_connection +def _get_flag_value(args: list, flag: str) -> str: + """ + Returns the value for a flag. + + :param args: + :param flag: + :return: + """ + + return args[args.index(flag) + 1] if flag in args else None + + def get(args: list = None) -> None: """ Gets all of the values stored in NSUserDefaults and prints @@ -16,3 +28,78 @@ def get(args: list = None) -> None: defaults = api.ios_nsuser_defaults_get() click.secho(defaults, bold=True) + + +def set(args: list = None) -> None: + """ + Sets a value in NSUserDefaults. + + :param args: + :return: + """ + + if not args or len(args) < 2: + click.secho('Usage: ios nsuserdefaults set [--type string|int|float|bool]', fg='red') + return + + # Get explicit type if provided + value_type = _get_flag_value(args, '--type') + + # Remove --type and its value from args if present + if '--type' in args: + type_index = args.index('--type') + args = args[:type_index] + args[type_index + 2:] + + if len(args) < 2: + click.secho('Usage: ios nsuserdefaults set [--type string|int|float|bool]', fg='red') + return + + key = args[0] + value_str = args[1] + + # Parse value based on type + if value_type == 'bool': + value = value_str.lower() in ['true', '1', 'yes'] + elif value_type == 'int': + try: + value = int(value_str) + except ValueError: + click.secho(f'Invalid integer value: {value_str}', fg='red') + return + elif value_type == 'float': + try: + value = float(value_str) + except ValueError: + click.secho(f'Invalid float value: {value_str}', fg='red') + return + else: + # Default to string, but try to auto-detect type + if not value_type: + if value_str.lower() in ['true', 'false']: + value_type = 'bool' + value = value_str.lower() == 'true' + elif value_str.isdigit() or (value_str.startswith('-') and value_str[1:].isdigit()): + value_type = 'int' + value = int(value_str) + elif '.' in value_str: + try: + value = float(value_str) + value_type = 'float' + except ValueError: + value = value_str + value_type = 'string' + else: + value = value_str + value_type = 'string' + else: + value = value_str + + click.secho(f'Setting NSUserDefaults key: {key} = {value} (type: {value_type})', dim=True) + + api = state_connection.get_api() + result = api.ios_nsuser_defaults_set(key, value, value_type) + + if result: + click.secho(f'Successfully set {key}', fg='green') + else: + click.secho(f'Failed to set {key}', fg='red') diff --git a/objection/console/commands.py b/objection/console/commands.py index 51885422..4be5e096 100644 --- a/objection/console/commands.py +++ b/objection/console/commands.py @@ -588,6 +588,10 @@ 'get': { 'meta': 'Get all of the entries', 'exec': nsuserdefaults.get + }, + 'set': { + 'meta': 'Set a value for a key', + 'exec': nsuserdefaults.set } } }, diff --git a/objection/console/helpfiles/ios.nsuserdefaults.set.txt b/objection/console/helpfiles/ios.nsuserdefaults.set.txt new file mode 100644 index 00000000..fed5488b --- /dev/null +++ b/objection/console/helpfiles/ios.nsuserdefaults.set.txt @@ -0,0 +1,25 @@ +Command: ios nsuserdefaults set + +Usage: ios nsuserdefaults set [--type string|int|float|bool] + +Sets a value in the application's NSUserDefaults for the specified key. +The command will attempt to auto-detect the value type based on the input, +but you can explicitly specify the type using the --type flag. + +Type Detection: + - "true" or "false" -> boolean + - Numbers without decimal -> integer + - Numbers with decimal -> float + - Everything else -> string + +Arguments: + The NSUserDefaults key to set + The value to store + --type Optional: Explicitly specify the value type (string|int|float|bool) + +Examples: + ios nsuserdefaults set username "john.doe" + ios nsuserdefaults set isFirstLaunch false + ios nsuserdefaults set loginAttempts 3 + ios nsuserdefaults set apiVersion 2.5 + ios nsuserdefaults set debugMode true --type bool diff --git a/objection/console/repl.py b/objection/console/repl.py index 7f16da7b..fe27f6ef 100644 --- a/objection/console/repl.py +++ b/objection/console/repl.py @@ -17,6 +17,7 @@ from ..__init__ import __version__ from ..state.app import app_state from ..state.connection import state_connection +from ..utils.agent import Agent, AgentConfig from ..utils.helpers import get_tokens @@ -279,6 +280,59 @@ def _find_command_help(self, tokens: list) -> str: return user_help + @staticmethod + def perform_reconnect() -> bool: + """ + Performs the actual reconnection logic. + + :return: True if successful, False otherwise + """ + try: + # Get current connection config + current_agent = state_connection.agent + + # Cleanup current agent (ignore errors if already destroyed) + click.secho('Unloading current agent...', dim=True) + try: + if current_agent.script: + current_agent.script.unload() + except (frida.InvalidOperationError, Exception): + pass # Script already destroyed or detached + + try: + if current_agent.session: + current_agent.session.detach() + except (frida.InvalidOperationError, Exception): + pass # Session already detached + + # Create new agent with same config + click.secho('Creating new agent session...', dim=True) + new_agent = Agent(AgentConfig( + name=state_connection.name, + host=state_connection.host, + port=state_connection.port, + device_type=state_connection.device_type, + device_id=state_connection.device_id, + spawn=False, # Don't spawn on reconnect, attach to existing + foremost=state_connection.foremost, + debugger=state_connection.debugger, + pause=not state_connection.no_pause, + uid=state_connection.uid + )) + + new_agent.run() + state_connection.set_agent(new_agent) + + click.secho('Successfully reconnected!', fg='green') + return True + + except (frida.ServerNotRunningError, frida.TimedOutError) as e: + click.secho('Failed to reconnect with error: {0}'.format(e), fg='red') + return False + except Exception as e: + click.secho('Failed to reconnect: {0}'.format(e), fg='red') + return False + @staticmethod def handle_reconnect(document: str) -> bool: """ @@ -292,22 +346,8 @@ def handle_reconnect(document: str) -> bool: """ if document.strip() in ('reconnect', 'reset'): - click.secho('Reconnecting...', dim=True) - - try: - # TODO - # state_connection.a.unload() - # - # agent = OldAgent() - # agent.inject() - # state_connection.a = agent - - click.secho('Not yet implemented!', fg='yellow') - - except (frida.ServerNotRunningError, frida.TimedOutError) as e: - click.secho('Failed to reconnect with error: {0}'.format(e), fg='red') - + Repl.perform_reconnect() return True return False @@ -361,6 +401,19 @@ def run(self, quiet: bool) -> None: # find something to run self.run_command(document) + except frida.InvalidOperationError as e: + # Check if script was destroyed - attempt auto-reconnect + if 'script has been destroyed' in str(e).lower() or 'script is destroyed' in str(e).lower(): + click.secho('Script has been destroyed. Attempting auto-reconnect...', fg='yellow') + if self.perform_reconnect(): + click.secho('Reconnected! Please retry your command.', fg='green') + else: + click.secho('Auto-reconnect failed. Use "reconnect" to try again manually.', fg='red') + else: + click.secho('A Frida operation error has occurred.', fg='red', bold=True) + click.secho('{0}'.format(e), fg='red') + click.secho('\nPython stack trace: {}'.format(traceback.format_exc()), dim=True) + except frida.core.RPCException as e: click.secho('A Frida agent exception has occurred.', fg='red', bold=True) click.secho('{0}'.format(e), fg='red') diff --git a/tests/commands/ios/test_nsuserdefaults.py b/tests/commands/ios/test_nsuserdefaults.py index ba445a02..2219a013 100644 --- a/tests/commands/ios/test_nsuserdefaults.py +++ b/tests/commands/ios/test_nsuserdefaults.py @@ -1,7 +1,7 @@ import unittest from unittest import mock -from objection.commands.ios.nsuserdefaults import get +from objection.commands.ios.nsuserdefaults import get, set from ...helpers import capture @@ -14,3 +14,57 @@ def test_get(self, mock_api): output = o self.assertEqual(output, 'foo\n') + + @mock.patch('objection.state.connection.state_connection.get_api') + def test_set_string(self, mock_api): + mock_api.return_value.ios_nsuser_defaults_set.return_value = True + + with capture(set, ['testKey', 'testValue']) as o: + output = o + + self.assertIn('Successfully set testKey', output) + mock_api.return_value.ios_nsuser_defaults_set.assert_called_once_with('testKey', 'testValue', 'string') + + @mock.patch('objection.state.connection.state_connection.get_api') + def test_set_bool(self, mock_api): + mock_api.return_value.ios_nsuser_defaults_set.return_value = True + + with capture(set, ['isEnabled', 'true']) as o: + output = o + + self.assertIn('Successfully set isEnabled', output) + mock_api.return_value.ios_nsuser_defaults_set.assert_called_once_with('isEnabled', True, 'bool') + + @mock.patch('objection.state.connection.state_connection.get_api') + def test_set_int(self, mock_api): + mock_api.return_value.ios_nsuser_defaults_set.return_value = True + + with capture(set, ['count', '42']) as o: + output = o + + self.assertIn('Successfully set count', output) + mock_api.return_value.ios_nsuser_defaults_set.assert_called_once_with('count', 42, 'int') + + @mock.patch('objection.state.connection.state_connection.get_api') + def test_set_with_explicit_type(self, mock_api): + mock_api.return_value.ios_nsuser_defaults_set.return_value = True + + with capture(set, ['version', '2.5', '--type', 'float']) as o: + output = o + + self.assertIn('Successfully set version', output) + mock_api.return_value.ios_nsuser_defaults_set.assert_called_once_with('version', 2.5, 'float') + + @mock.patch('objection.state.connection.state_connection.get_api') + def test_set_missing_arguments(self, mock_api): + with capture(set, ['onlyKey']) as o: + output = o + + self.assertIn('Usage:', output) + + @mock.patch('objection.state.connection.state_connection.get_api') + def test_set_no_arguments(self, mock_api): + with capture(set, []) as o: + output = o + + self.assertIn('Usage:', output)