From 5563fdeb36dff4d149bbad744ebe52076af82d39 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Sat, 6 Jun 2020 16:50:05 +0200 Subject: [PATCH 1/6] some minor usability improvements and better error messages --- .gitignore | 1 + ida/idacode.py | 50 +++------------------- ida/idacode_utils/dbg.py | 2 +- ida/idacode_utils/plugin.py | 64 +++++++++++++++++++++++++++++ ida/idacode_utils/socket_handler.py | 14 +++---- idacode/package.json | 8 ++-- 6 files changed, 83 insertions(+), 56 deletions(-) create mode 100644 .gitignore create mode 100644 ida/idacode_utils/plugin.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58200d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/ida/idacode.py b/ida/idacode.py index 5603417..5fd4ed3 100644 --- a/ida/idacode.py +++ b/ida/idacode.py @@ -1,47 +1,9 @@ -import socket, sys, os, threading, inspect, asyncio -import tornado, debugpy -import idaapi -import idacode_utils.dbg as dbg -import idacode_utils.hooks as hooks -import idacode_utils.settings as settings -from idacode_utils.socket_handler import SocketHandler +import sys +if sys.version_info < (3, 3): + print("[IDACode] Python 2.7 is not (yet) supported, vote at https://github.com/ioncodes/idacode/issues/3") + sys.exit() -def setup_patches(): - hooks.install() - sys.executable = settings.PYTHON - -def create_socket_handler(): - asyncio.set_event_loop(asyncio.new_event_loop()) - app = tornado.web.Application([ - (r"/ws", SocketHandler), - ]) - server = tornado.httpserver.HTTPServer(app) - print(f"IDACode listening on {settings.HOST}:{settings.PORT}") - server.listen(address=settings.HOST, port=settings.PORT) - -def start_server(): - setup_patches() - create_socket_handler() - tornado.ioloop.IOLoop.current().start() - -class IDACode(idaapi.plugin_t): - def __init__(self): - self.flags = idaapi.PLUGIN_UNL - self.comment = "IDACode" - self.help = "IDACode" - self.wanted_name = "Start IDACode" - self.wanted_hotkey = "" - - def init(self): - return idaapi.PLUGIN_OK - - def run(self, args): - thread = threading.Thread(target=start_server) - thread.daemon = True - thread.start() - - def term(self): - pass +import idacode_utils.plugin as plugin def PLUGIN_ENTRY(): - return IDACode() \ No newline at end of file + return plugin.IDACode() \ No newline at end of file diff --git a/ida/idacode_utils/dbg.py b/ida/idacode_utils/dbg.py index 15c1b33..448c1cc 100644 --- a/ida/idacode_utils/dbg.py +++ b/ida/idacode_utils/dbg.py @@ -14,5 +14,5 @@ def bp(*args): break if condition: if message: - print(message) + print(f"[IDACode] {message}") api.breakpoint() \ No newline at end of file diff --git a/ida/idacode_utils/plugin.py b/ida/idacode_utils/plugin.py new file mode 100644 index 0000000..d63d112 --- /dev/null +++ b/ida/idacode_utils/plugin.py @@ -0,0 +1,64 @@ +import socket, sys, os, threading, inspect, asyncio +try: + import tornado, debugpy +except ImportError: + print("[IDACode] Dependencies missing, run: python3 -m pip install --user debugpy tornado") + sys.exit() +import idaapi +import idacode_utils.dbg as dbg +import idacode_utils.hooks as hooks +import idacode_utils.settings as settings +from idacode_utils.socket_handler import SocketHandler + +def setup_patches(): + hooks.install() + sys.executable = settings.PYTHON + +def create_socket_handler(): + asyncio.set_event_loop(asyncio.new_event_loop()) + app = tornado.web.Application([ + (r"/ws", SocketHandler), + ]) + server = tornado.httpserver.HTTPServer(app) + print(f"[IDACode] listening on {settings.HOST}:{settings.PORT}") + server.listen(address=settings.HOST, port=settings.PORT) + +def start_server(): + setup_patches() + create_socket_handler() + tornado.ioloop.IOLoop.current().start() + +class IDACode(idaapi.plugin_t): + def __init__(self): + self.flags = idaapi.PLUGIN_UNL + self.comment = "IDACode" + self.help = "IDACode" + self.wanted_name = "IDACode" + self.wanted_hotkey = "" + + def init(self): + import os.path + if os.path.isfile(settings.PYTHON): + from subprocess import check_output + settings_version = check_output([settings.PYTHON, "-c", "import sys; print(sys.version + sys.platform)"]) + settings_version = settings_version.decode("utf-8", "ignore").strip() + ida_version = sys.version + sys.platform + if settings_version != ida_version: + print("[IDACode] settings.PYTHON version mismatch, aborting load:") + print(f"[IDACode] IDA interpreter: {ida_version}") + print(f"[IDACode] settings.PYTHON: {settings_version}") + return idaapi.PLUGIN_SKIP + else: + print(f"[IDACode] settings.PYTHON ({settings.PYTHON}) does not exist, aborting load") + print("[IDACode] To fix this issue, modify idacode_utils/settings.py to point to the python executable") + return idaapi.PLUGIN_SKIP + print("[IDACode] Plugin loaded, use Edit -> Plugins -> IDACode to start the server") + return idaapi.PLUGIN_OK + + def run(self, args): + thread = threading.Thread(target=start_server) + thread.daemon = True + thread.start() + + def term(self): + pass \ No newline at end of file diff --git a/ida/idacode_utils/socket_handler.py b/ida/idacode_utils/socket_handler.py index 7407817..e90cd9f 100644 --- a/ida/idacode_utils/socket_handler.py +++ b/ida/idacode_utils/socket_handler.py @@ -16,13 +16,13 @@ def start_debug_server(): if settings.LOGGING: tmp_path = tempfile.gettempdir() debugpy.log_to(tmp_path) - print(f"Logging to {tmp_path} with pattern debugpy.*.log") + print(f"[IDACode] Logging to {tmp_path} with pattern debugpy.*.log") debugpy.listen((settings.HOST, settings.DEBUG_PORT)) - print(f"IDACode debug server listening on {settings.HOST}:{settings.DEBUG_PORT}") + print(f"[IDACode] IDACode debug server listening on {settings.HOST}:{settings.DEBUG_PORT}") class SocketHandler(tornado.websocket.WebSocketHandler): def open(self): - print("IDACode client connected") + print("[IDACode] client connected") def on_message(self, message): message = json.loads(message.decode("utf8")) @@ -30,7 +30,7 @@ def on_message(self, message): if message["event"] == "set_workspace": path = message["path"] hooks.set_script_folder(path) - print(f"Set workspace folder to {path}") + print(f"[IDACode] Set workspace folder to {path}") elif message["event"] == "attach_debugger": start_debug_server() self.write_message({ @@ -39,13 +39,13 @@ def on_message(self, message): elif message["event"] == "execute_script": script = message["path"] env = create_env() - print(f"Executing {script}") + print(f"[IDACode] Executing {script}") idaapi.execute_sync( lambda: idaapi.IDAPython_ExecScript(script, env), idaapi.MFF_WRITE ) else: - print(f"Invalid event {message['event']}") + print(f"[IDACode] Invalid event {message['event']}") def on_close(self): - print("IDACode client disconnected") \ No newline at end of file + print("[IDACode] client disconnected") \ No newline at end of file diff --git a/idacode/package.json b/idacode/package.json index 58538a8..b62f0f8 100644 --- a/idacode/package.json +++ b/idacode/package.json @@ -38,19 +38,19 @@ "commands": [ { "command": "idacode.connectToIDA", - "title": "Connect to IDA" + "title": "IDACode: Connect to IDA" }, { "command": "idacode.attachToIDA", - "title": "Attach a debugger to IDA" + "title": "IDACode: Attach a debugger to IDA" }, { "command": "idacode.connectAndAttachToIDA", - "title": "Connect and attach a debugger to IDA" + "title": "IDACode: Connect and attach a debugger to IDA" }, { "command": "idacode.executeScript", - "title": "Execute script in IDA" + "title": "IDACode: Execute script in IDA" } ], "configuration": { From 0eafa32d8172686fa0e67f3f55b532e12ebdef64 Mon Sep 17 00:00:00 2001 From: Duncan Ogilvie Date: Sat, 6 Jun 2020 16:50:05 +0200 Subject: [PATCH 2/6] some minor usability improvements and better error messages --- .gitignore | 1 + ida/idacode.py | 50 +++------------------ ida/idacode_utils/dbg.py | 2 +- ida/idacode_utils/plugin.py | 69 +++++++++++++++++++++++++++++ ida/idacode_utils/socket_handler.py | 14 +++--- idacode/package.json | 8 ++-- 6 files changed, 88 insertions(+), 56 deletions(-) create mode 100644 .gitignore create mode 100644 ida/idacode_utils/plugin.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58200d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/ida/idacode.py b/ida/idacode.py index 5603417..5fd4ed3 100644 --- a/ida/idacode.py +++ b/ida/idacode.py @@ -1,47 +1,9 @@ -import socket, sys, os, threading, inspect, asyncio -import tornado, debugpy -import idaapi -import idacode_utils.dbg as dbg -import idacode_utils.hooks as hooks -import idacode_utils.settings as settings -from idacode_utils.socket_handler import SocketHandler +import sys +if sys.version_info < (3, 3): + print("[IDACode] Python 2.7 is not (yet) supported, vote at https://github.com/ioncodes/idacode/issues/3") + sys.exit() -def setup_patches(): - hooks.install() - sys.executable = settings.PYTHON - -def create_socket_handler(): - asyncio.set_event_loop(asyncio.new_event_loop()) - app = tornado.web.Application([ - (r"/ws", SocketHandler), - ]) - server = tornado.httpserver.HTTPServer(app) - print(f"IDACode listening on {settings.HOST}:{settings.PORT}") - server.listen(address=settings.HOST, port=settings.PORT) - -def start_server(): - setup_patches() - create_socket_handler() - tornado.ioloop.IOLoop.current().start() - -class IDACode(idaapi.plugin_t): - def __init__(self): - self.flags = idaapi.PLUGIN_UNL - self.comment = "IDACode" - self.help = "IDACode" - self.wanted_name = "Start IDACode" - self.wanted_hotkey = "" - - def init(self): - return idaapi.PLUGIN_OK - - def run(self, args): - thread = threading.Thread(target=start_server) - thread.daemon = True - thread.start() - - def term(self): - pass +import idacode_utils.plugin as plugin def PLUGIN_ENTRY(): - return IDACode() \ No newline at end of file + return plugin.IDACode() \ No newline at end of file diff --git a/ida/idacode_utils/dbg.py b/ida/idacode_utils/dbg.py index 15c1b33..448c1cc 100644 --- a/ida/idacode_utils/dbg.py +++ b/ida/idacode_utils/dbg.py @@ -14,5 +14,5 @@ def bp(*args): break if condition: if message: - print(message) + print(f"[IDACode] {message}") api.breakpoint() \ No newline at end of file diff --git a/ida/idacode_utils/plugin.py b/ida/idacode_utils/plugin.py new file mode 100644 index 0000000..c1b05af --- /dev/null +++ b/ida/idacode_utils/plugin.py @@ -0,0 +1,69 @@ +import socket, sys, os, threading, inspect, asyncio +try: + import tornado, debugpy +except ImportError: + print("[IDACode] Dependencies missing, run: python3 -m pip install --user debugpy tornado") + sys.exit() +import idaapi +import idacode_utils.dbg as dbg +import idacode_utils.hooks as hooks +import idacode_utils.settings as settings +from idacode_utils.socket_handler import SocketHandler + +initialized = False + +def setup_patches(): + hooks.install() + sys.executable = settings.PYTHON + +def create_socket_handler(): + asyncio.set_event_loop(asyncio.new_event_loop()) + app = tornado.web.Application([ + (r"/ws", SocketHandler), + ]) + server = tornado.httpserver.HTTPServer(app) + print(f"[IDACode] listening on {settings.HOST}:{settings.PORT}") + server.listen(address=settings.HOST, port=settings.PORT) + +def start_server(): + setup_patches() + create_socket_handler() + tornado.ioloop.IOLoop.current().start() + +class IDACode(idaapi.plugin_t): + def __init__(self): + self.flags = idaapi.PLUGIN_UNL + self.comment = "IDACode" + self.help = "IDACode" + self.wanted_name = "IDACode" + self.wanted_hotkey = "" + + def init(self): + global initialized + if not initialized: + initialized = True + import os.path + if os.path.isfile(settings.PYTHON): + from subprocess import check_output + settings_version = check_output([settings.PYTHON, "-c", "import sys; print(sys.version + sys.platform)"]) + settings_version = settings_version.decode("utf-8", "ignore").strip() + ida_version = sys.version + sys.platform + if settings_version != ida_version: + print("[IDACode] settings.PYTHON version mismatch, aborting load:") + print(f"[IDACode] IDA interpreter: {ida_version}") + print(f"[IDACode] settings.PYTHON: {settings_version}") + return idaapi.PLUGIN_SKIP + else: + print(f"[IDACode] settings.PYTHON ({settings.PYTHON}) does not exist, aborting load") + print("[IDACode] To fix this issue, modify idacode_utils/settings.py to point to the python executable") + return idaapi.PLUGIN_SKIP + print("[IDACode] Plugin loaded, use Edit -> Plugins -> IDACode to start the server") + return idaapi.PLUGIN_OK + + def run(self, args): + thread = threading.Thread(target=start_server) + thread.daemon = True + thread.start() + + def term(self): + pass \ No newline at end of file diff --git a/ida/idacode_utils/socket_handler.py b/ida/idacode_utils/socket_handler.py index 7407817..e90cd9f 100644 --- a/ida/idacode_utils/socket_handler.py +++ b/ida/idacode_utils/socket_handler.py @@ -16,13 +16,13 @@ def start_debug_server(): if settings.LOGGING: tmp_path = tempfile.gettempdir() debugpy.log_to(tmp_path) - print(f"Logging to {tmp_path} with pattern debugpy.*.log") + print(f"[IDACode] Logging to {tmp_path} with pattern debugpy.*.log") debugpy.listen((settings.HOST, settings.DEBUG_PORT)) - print(f"IDACode debug server listening on {settings.HOST}:{settings.DEBUG_PORT}") + print(f"[IDACode] IDACode debug server listening on {settings.HOST}:{settings.DEBUG_PORT}") class SocketHandler(tornado.websocket.WebSocketHandler): def open(self): - print("IDACode client connected") + print("[IDACode] client connected") def on_message(self, message): message = json.loads(message.decode("utf8")) @@ -30,7 +30,7 @@ def on_message(self, message): if message["event"] == "set_workspace": path = message["path"] hooks.set_script_folder(path) - print(f"Set workspace folder to {path}") + print(f"[IDACode] Set workspace folder to {path}") elif message["event"] == "attach_debugger": start_debug_server() self.write_message({ @@ -39,13 +39,13 @@ def on_message(self, message): elif message["event"] == "execute_script": script = message["path"] env = create_env() - print(f"Executing {script}") + print(f"[IDACode] Executing {script}") idaapi.execute_sync( lambda: idaapi.IDAPython_ExecScript(script, env), idaapi.MFF_WRITE ) else: - print(f"Invalid event {message['event']}") + print(f"[IDACode] Invalid event {message['event']}") def on_close(self): - print("IDACode client disconnected") \ No newline at end of file + print("[IDACode] client disconnected") \ No newline at end of file diff --git a/idacode/package.json b/idacode/package.json index 58538a8..b62f0f8 100644 --- a/idacode/package.json +++ b/idacode/package.json @@ -38,19 +38,19 @@ "commands": [ { "command": "idacode.connectToIDA", - "title": "Connect to IDA" + "title": "IDACode: Connect to IDA" }, { "command": "idacode.attachToIDA", - "title": "Attach a debugger to IDA" + "title": "IDACode: Attach a debugger to IDA" }, { "command": "idacode.connectAndAttachToIDA", - "title": "Connect and attach a debugger to IDA" + "title": "IDACode: Connect and attach a debugger to IDA" }, { "command": "idacode.executeScript", - "title": "Execute script in IDA" + "title": "IDACode: Execute script in IDA" } ], "configuration": { From fde8e4aeb0886c76b11c36e09c332a2a1e6bd4b0 Mon Sep 17 00:00:00 2001 From: Layle Date: Sat, 6 Jun 2020 18:27:48 +0200 Subject: [PATCH 3/6] moved imports --- ida/idacode_utils/plugin.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ida/idacode_utils/plugin.py b/ida/idacode_utils/plugin.py index c1b05af..09b5342 100644 --- a/ida/idacode_utils/plugin.py +++ b/ida/idacode_utils/plugin.py @@ -1,4 +1,4 @@ -import socket, sys, os, threading, inspect, asyncio +import socket, sys, os, threading, inspect, asyncio, subprocess try: import tornado, debugpy except ImportError: @@ -42,10 +42,8 @@ def init(self): global initialized if not initialized: initialized = True - import os.path if os.path.isfile(settings.PYTHON): - from subprocess import check_output - settings_version = check_output([settings.PYTHON, "-c", "import sys; print(sys.version + sys.platform)"]) + settings_version = subprocess.check_output([settings.PYTHON, "-c", "import sys; print(sys.version + sys.platform)"]) settings_version = settings_version.decode("utf-8", "ignore").strip() ida_version = sys.version + sys.platform if settings_version != ida_version: From 917beb46975be32eab4b670315b0a1ffa34a1e05 Mon Sep 17 00:00:00 2001 From: Layle Date: Sat, 6 Jun 2020 18:31:41 +0200 Subject: [PATCH 4/6] extracted version gathering to its own function --- ida/idacode_utils/plugin.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ida/idacode_utils/plugin.py b/ida/idacode_utils/plugin.py index 09b5342..16253aa 100644 --- a/ida/idacode_utils/plugin.py +++ b/ida/idacode_utils/plugin.py @@ -30,6 +30,12 @@ def start_server(): create_socket_handler() tornado.ioloop.IOLoop.current().start() +def get_python_versions(): + settings_version = subprocess.check_output([settings.PYTHON, "-c", "import sys; print(sys.version + sys.platform)"]) + settings_version = settings_version.decode("utf-8", "ignore").strip() + ida_version = f"{sys.version}{sys.platform}" + return (settings_version, ida_version) + class IDACode(idaapi.plugin_t): def __init__(self): self.flags = idaapi.PLUGIN_UNL @@ -43,9 +49,7 @@ def init(self): if not initialized: initialized = True if os.path.isfile(settings.PYTHON): - settings_version = subprocess.check_output([settings.PYTHON, "-c", "import sys; print(sys.version + sys.platform)"]) - settings_version = settings_version.decode("utf-8", "ignore").strip() - ida_version = sys.version + sys.platform + settings_version, ida_version = get_python_versions() if settings_version != ida_version: print("[IDACode] settings.PYTHON version mismatch, aborting load:") print(f"[IDACode] IDA interpreter: {ida_version}") From 7a79d0fa77bedd902bc2cbf7f768d4a7b450c739 Mon Sep 17 00:00:00 2001 From: Layle Date: Sat, 6 Jun 2020 18:46:50 +0200 Subject: [PATCH 5/6] new minor version, added version to ida plugin and added credits --- README.md | 3 +++ ida/idacode_utils/plugin.py | 2 ++ idacode/CHANGELOG.md | 7 ++++++- idacode/README.md | 7 ++++++- idacode/package.json | 2 +- 5 files changed, 18 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d5659e7..080f4e7 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,6 @@ It is also important that attaching a debugger will create a new debugger instan ## Demo ![demo](idacode/images/preview.gif) + +## Credits +* [mrexodia](https://github.com/mrexodia) \ No newline at end of file diff --git a/ida/idacode_utils/plugin.py b/ida/idacode_utils/plugin.py index 16253aa..7f10c82 100644 --- a/ida/idacode_utils/plugin.py +++ b/ida/idacode_utils/plugin.py @@ -10,6 +10,7 @@ import idacode_utils.settings as settings from idacode_utils.socket_handler import SocketHandler +VERSION = "0.1.2" initialized = False def setup_patches(): @@ -59,6 +60,7 @@ def init(self): print(f"[IDACode] settings.PYTHON ({settings.PYTHON}) does not exist, aborting load") print("[IDACode] To fix this issue, modify idacode_utils/settings.py to point to the python executable") return idaapi.PLUGIN_SKIP + print(f"[IDACode] Plugin version {VERSION}") print("[IDACode] Plugin loaded, use Edit -> Plugins -> IDACode to start the server") return idaapi.PLUGIN_OK diff --git a/idacode/CHANGELOG.md b/idacode/CHANGELOG.md index dbe5bcb..dbe2389 100644 --- a/idacode/CHANGELOG.md +++ b/idacode/CHANGELOG.md @@ -8,4 +8,9 @@ ## 0.1.1 -- Added logging support \ No newline at end of file +- Added logging support + +### 0.1.2 + +- Enhanced UX +- Added configuration checks \ No newline at end of file diff --git a/idacode/README.md b/idacode/README.md index b0e4434..59783af 100644 --- a/idacode/README.md +++ b/idacode/README.md @@ -45,4 +45,9 @@ IDACode doesn't support host to VM communication unless the VM uses a shared vol ### 0.1.1 -- Added logging support \ No newline at end of file +- Added logging support + +### 0.1.2 + +- Enhanced UX +- Added configuration checks \ No newline at end of file diff --git a/idacode/package.json b/idacode/package.json index b62f0f8..f0ead47 100644 --- a/idacode/package.json +++ b/idacode/package.json @@ -3,7 +3,7 @@ "displayName": "IDACode", "description": "Run and debug your IDA scripts from VS Code", "icon": "images/icon.png", - "version": "0.1.1", + "version": "0.1.2", "publisher": "Layle", "license": "SEE LICENSE IN LICENSE.md", "preview": true, From f8abe98abdc149e702bb27635ae91ec5f7e12210 Mon Sep 17 00:00:00 2001 From: Layle Date: Sat, 6 Jun 2020 18:59:11 +0200 Subject: [PATCH 6/6] changed few things for 0.1.2 --- README.md | 2 +- images/commands.png | Bin 4960 -> 6282 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 080f4e7..530d85b 100644 --- a/README.md +++ b/README.md @@ -64,5 +64,5 @@ It is also important that attaching a debugger will create a new debugger instan ## Demo ![demo](idacode/images/preview.gif) -## Credits +## Contributors * [mrexodia](https://github.com/mrexodia) \ No newline at end of file diff --git a/images/commands.png b/images/commands.png index e9cfa282248aa38f2a8d7ed874d190a423edfcfd..8d08c209af3f340734662f16a73296ccdf757d05 100644 GIT binary patch literal 6282 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

6n literal 4960 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 Bh~NMK