├── README.md ├── idarest.py ├── install.sh ├── requests.sh └── test.py /README.md: -------------------------------------------------------------------------------- 1 | IDA Rest 2 | ======== 3 | A simple REST-like API for basic interoperability with IDA Pro. 4 | 5 | Installing and Running 6 | ---------------------- 7 | Copy idarest.py to IDA Pro's plugin directory. 8 | 9 | Use the Edit menu in IDA Pro to start and stop the plugin 10 | * Edit -> Start IDARest 11 | * Edit -> Stop IDARest 12 | 13 | When starting the plugin you will be asked for the listening host and port. 14 | Provide it in '''host:port''' format. `127.0.0.1:8899` is the default setting. 15 | 16 | Conventions 17 | ----------- 18 | ### Request Method 19 | All APIs can be accessed with either GET or POST requests. Arguments in GET 20 | requests are passed as URL parameters. Arguments in POST requests are passed as 21 | JSON. 22 | 23 | ### Status and Errors 24 | HTTP status returned will always be 200, 400, or 404. 25 | 26 | 404 occurs when requesting an unknown URL / API. 27 | 28 | 400 occurs for 29 | * Bad POST arguments (must be application/json or malformed JSON) 30 | * Bad QUERY arguments (specifying the same var multiple times) 31 | 32 | 200 will be returned for everything else, including *invalid* API argument 33 | values. 34 | 35 | ### HTTP 200 Responses 36 | All responses will be either JSON (`application/json`) or JSONP 37 | (`application/javascript`) with JSON being the default format. To have JSONP 38 | returned, specify a URL parameter `callback` with both POST and GET requests. 39 | 40 | All responses (errors and non-errors) have `code` and `msg` fields. Responses 41 | which have a 200 code also have a `data` field with additional information. 42 | 43 | ### Other conventions 44 | * Numbers will be returned as hex formatted (0xABCD) strings. 45 | * Input numbers must be provided in hex form 46 | * `ea` is commonly used as address 47 | * Color input is RRGGBB format in hex 48 | 49 | API 50 | --- 51 | ### info : Meta information about the current IDB 52 | 53 | **Example:** 54 | 55 | curl http://127.0.0.1:8899/ida/api/v1.0/info 56 | 57 | ### cursor : Get and set current disassembly window cursor position 58 | 59 | **Example:** 60 | 61 | curl http://127.0.0.1:8899/ida/api/v1.0/cursor 62 | 63 | curl http://127.0.0.1:8899/ida/api/v1.0/cursor?ea=0x89ab 64 | 65 | ### segments : Get segment information 66 | 67 | **Example:** 68 | 69 | curl http://127.0.0.1:8899/ida/api/v1.0/segments 70 | 71 | curl http://127.0.0.1:8899/ida/api/v1.0/segments?ea=0x89ab 72 | 73 | ### names : Get name list 74 | 75 | **Example:** 76 | 77 | curl http://127.0.0.1:8899/ida/api/v1.0/names 78 | 79 | ### color : Get and set color information 80 | 81 | **Example:** 82 | 83 | curl http://127.0.0.1:8899/ida/api/v1.0/color?ea=0x89ab 84 | 85 | curl http://127.0.0.1:8899/ida/api/v1.0/color?ea=0x89ab?color=FF0000 86 | 87 | API To Do List 88 | -------------- 89 | * query 90 | 91 | Adding New APIs 92 | --------------- 93 | ### Registering handlers 94 | * HTTPRequestHandler.prefn 95 | * HTTPRequestHandler.postfn 96 | * HTTPRequestHandler.route 97 | 98 | ### Decorators for parameter checking 99 | * @check_ea 100 | * @require_params 101 | 102 | ### Exceptions 103 | * IDARequestError 104 | 105 | -------------------------------------------------------------------------------- /idarest.py: -------------------------------------------------------------------------------- 1 | from BaseHTTPServer import BaseHTTPRequestHandler,HTTPServer 2 | from SocketServer import ThreadingMixIn 3 | import re 4 | import threading 5 | import cgi 6 | import urlparse 7 | import json 8 | 9 | try: 10 | import idaapi 11 | import idautils 12 | import idc 13 | except: 14 | pass 15 | 16 | API_PREFIX = '/ida/api/v1.0' 17 | 18 | class HTTPRequestError(BaseException): 19 | def __init__(self, msg, code): 20 | self.msg = msg 21 | self.code = code 22 | 23 | class UnknownApiError(HTTPRequestError): 24 | pass 25 | 26 | class HTTPRequestHandler(BaseHTTPRequestHandler): 27 | routes = {} 28 | prefns = {} 29 | postfns = {} 30 | 31 | @staticmethod 32 | def build_route_pattern(route): 33 | return re.compile("^{0}$".format(route)) 34 | 35 | @staticmethod 36 | def route(route_str): 37 | def decorator(f): 38 | route_path = API_PREFIX + '/' + route_str + '/?' 39 | route_pattern = HTTPRequestHandler.build_route_pattern(route_path) 40 | HTTPRequestHandler.routes[route_str] = (route_pattern, f) 41 | return f 42 | return decorator 43 | 44 | @staticmethod 45 | def prefn(route_str): 46 | def decorator(f): 47 | HTTPRequestHandler.prefns.setdefault(route_str, []).append(f) 48 | return f 49 | return decorator 50 | 51 | @staticmethod 52 | def postfn(route_str): 53 | def decorator(f): 54 | HTTPRequestHandler.postfns.setdefault(route_str, []).append(f) 55 | return f 56 | return decorator 57 | 58 | def _get_route_match(self, path): 59 | for (key, (route_pattern,view_function)) in self.routes.items(): 60 | m = route_pattern.match(path) 61 | if m: 62 | return key,view_function 63 | return None 64 | 65 | def _get_route_prefn(self, key): 66 | try: 67 | return self.prefns[key] 68 | except: 69 | return [] 70 | 71 | def _get_route_postfn(self, key): 72 | try: 73 | return self.postfns[key] 74 | except: 75 | return [] 76 | 77 | def _serve_route(self, args): 78 | path = urlparse.urlparse(self.path).path 79 | route_match = self._get_route_match(path) 80 | if route_match: 81 | key,view_function = route_match 82 | for prefn in self._get_route_prefn(key): 83 | args = prefn(self, args) 84 | results = view_function(self, args) 85 | for postfn in self._get_route_postfn(key): 86 | results = postfn(self, results) 87 | return results 88 | else: 89 | raise UnknownApiError('Route "{0}" has not been registered'.format(path), 404) 90 | 91 | def _serve(self, args): 92 | try: 93 | response = { 94 | 'code' : 200, 95 | 'msg' : 'OK', 96 | 'data' : self._serve_route(args) 97 | } 98 | except UnknownApiError as e: 99 | self.send_error(e.code, e.msg) 100 | return 101 | except HTTPRequestError as e: 102 | response = {'code': e.code, 'msg' : e.msg} 103 | except ValueError as e: 104 | response = {'code': 400, 'msg': 'ValueError : ' + str(e)} 105 | except KeyError as e: 106 | response = {'code': 400, 'msg': 'KeyError : ' + str(e)} 107 | 108 | jsonp_callback = self._extract_callback() 109 | if jsonp_callback: 110 | content_type = 'application/javascript' 111 | response_fmt = jsonp_callback + '({0});' 112 | else: 113 | content_type = 'application/json' 114 | response_fmt = '{0}' 115 | 116 | self.send_response(200) 117 | self.send_header('Content-Type', content_type) 118 | self.end_headers() 119 | 120 | response = json.dumps(response) 121 | self.wfile.write(response_fmt.format(response)) 122 | 123 | def _extract_post_map(self): 124 | content_type,_t = cgi.parse_header(self.headers.getheader('content-type')) 125 | if content_type != 'application/json': 126 | raise HTTPRequestError( 127 | 'Bad content-type, use application/json', 128 | 400) 129 | length = int(self.headers.getheader('content-length')) 130 | try: 131 | return json.loads(self.rfile.read(length)) 132 | except ValueError as e: 133 | raise HTTPRequestError( 134 | 'Bad or malformed json content', 135 | 400) 136 | 137 | def _extract_query_map(self): 138 | query = urlparse.urlparse(self.path).query 139 | qd = urlparse.parse_qs(query) 140 | args = {} 141 | for k,v in qd.iteritems(): 142 | if len(v) != 1: 143 | raise HTTPRequestError( 144 | "Query param specified multiple times : " + k, 145 | 400) 146 | args[k.lower()] = v[0] 147 | return args 148 | 149 | def _extract_callback(self): 150 | try: 151 | args = self._extract_query_map() 152 | return args['callback'] 153 | except: 154 | return '' 155 | 156 | def do_POST(self): 157 | try: 158 | args = self._extract_post_map() 159 | except TypeError as e: 160 | # thrown on no content, just continue on 161 | args = '{}' 162 | except HTTPRequestError as e: 163 | self.send_error(e.code, e.msg) 164 | return 165 | self._serve(args) 166 | 167 | def do_GET(self): 168 | try: 169 | args = self._extract_query_map() 170 | except HTTPRequestError as e: 171 | self.send_error(e.code, e.msg) 172 | return 173 | self._serve(args) 174 | 175 | 176 | """ 177 | API handlers for IDA 178 | 179 | """ 180 | def check_ea(f): 181 | def wrapper(self, args): 182 | if 'ea' in args: 183 | try: 184 | ea = int(args['ea'], 16) 185 | except ValueError: 186 | raise IDARequestError( 187 | 'ea parameter malformed - must be 0xABCD', 400) 188 | if ea > idc.MaxEA(): 189 | raise IDARequestError( 190 | 'ea out of range - MaxEA is 0x%x' % idc.MaxEA(), 400) 191 | args['ea'] = ea 192 | return f(self, args) 193 | return wrapper 194 | 195 | def check_color(f): 196 | def wrapper(self, args): 197 | if 'color' in args: 198 | color = args['color'] 199 | try: 200 | color = color.lower().lstrip('#').rstrip('h') 201 | if color.startswith('0x'): 202 | color = color[2:] 203 | # IDA Color is BBGGRR, we need to convert from RRGGBB 204 | color = color[-2:] + color[2:4] + color[:2] 205 | color = int(color, 16) 206 | except: 207 | raise IDARequestError( 208 | 'color parameter malformed - must be RRGGBB form', 400) 209 | args['color'] = color 210 | return f(self, args) 211 | return wrapper 212 | 213 | def require_params(*params): 214 | def decorator(f): 215 | def wrapped(self, args): 216 | for x in params: 217 | if x not in args: 218 | raise IDARequestError('missing parameter {0}'.format(x), 400) 219 | return f(self, args) 220 | return wrapped 221 | return decorator 222 | 223 | class IDARequestError(HTTPRequestError): 224 | pass 225 | 226 | class IDARequestHandler(HTTPRequestHandler): 227 | @staticmethod 228 | def _hex(v): 229 | return hex(v).rstrip('L') 230 | 231 | @HTTPRequestHandler.route('info') 232 | def info(self, args): 233 | # No args, Return everything we can meta-wise about the ida session 234 | # file crcs 235 | result = { 236 | 'md5' : idc.GetInputMD5(), 237 | 'idb_path' : idc.GetIdbPath(), 238 | 'file_path' : idc.GetInputFilePath(), 239 | 'ida_dir' : idc.GetIdaDirectory(), 240 | 'min_ea' : self._hex(idc.MinEA()), 241 | 'max_ea' : self._hexidc.MaxEA()), 242 | 'segments' : self.segments({})['segments'], 243 | # idaapi.cvar.inf 244 | 'procname' : idc.GetLongPrm(idc.INF_PROCNAME), 245 | } 246 | return result 247 | 248 | @HTTPRequestHandler.route('query') 249 | @check_ea 250 | def query(self, args): 251 | # multiple modes 252 | # with address return everything about that address 253 | # with name, return everything about that name 254 | return {} 255 | 256 | 257 | @HTTPRequestHandler.route('cursor') 258 | @check_ea 259 | def cursor(self, args): 260 | # XXX - Doesn't work 261 | #if 'window' in args: 262 | # tform = idaapi.find_tform(args['window']) 263 | # if tform: 264 | # idaapi.switchto_tform(tform, 1) 265 | # else: 266 | # raise IDARequestError( 267 | # 'invalid window - {0}'.format(args['window']), 400) 268 | result = {} 269 | if 'ea' in args: 270 | ea = args['ea'] 271 | success = idaapi.jumpto(ea) 272 | result['moved'] = success 273 | result['ea'] = self._hex(idaapi.get_screen_ea()) 274 | return result 275 | 276 | def _get_segment_info(self, s): 277 | return { 278 | 'name' : idaapi.get_true_segm_name(s), 279 | 'ida_name' : idaapi.get_segm_name(s), 280 | 'start' : self._hex(s.startEA), 281 | 'end' : self._hex(s.endEA), 282 | 'size' : self._hex(s.size()) 283 | } 284 | 285 | @HTTPRequestHandler.route('segments') 286 | @check_ea 287 | def segments(self, args): 288 | if 'ea' in args: 289 | s = idaapi.getseg(args['ea']) 290 | if not s: 291 | raise IDARequestError('Invalid address', 400) 292 | return {'segment': self._get_segment_info(s)} 293 | else: 294 | m = {'segments': []} 295 | for i in range(idaapi.get_segm_qty()): 296 | s = idaapi.getnseg(i) 297 | m['segments'].append(self._get_segment_info(s)) 298 | return m 299 | 300 | @HTTPRequestHandler.route('names') 301 | def names(self, args): 302 | m = {'names' : []} 303 | for n in idautils.Names(): 304 | m['names'].append([self._hex(n[0]), n[1]]) 305 | return m 306 | 307 | @HTTPRequestHandler.route('color') 308 | @check_color 309 | @check_ea 310 | @require_params('ea') 311 | def color(self, args): 312 | ea = args['ea'] 313 | if 'color' in args: 314 | color = args['color'] 315 | def f(): 316 | idc.SetColor(ea, idc.CIC_ITEM, color) 317 | idaapi.execute_sync(f, idaapi.MFF_WRITE) 318 | idc.Refresh() 319 | return {} 320 | else: 321 | return {'color' : str(GetColor(ea, idc.CIC_ITEM))} 322 | 323 | 324 | # Figure out when this is really needed 325 | #def f(): 326 | # idaapi.jumpto(ea) # DO STUFF 327 | #idaapi.execute_sync(f, idaapi.MFF_FAST) 328 | 329 | """ 330 | Threaded HTTP Server and Worker 331 | 332 | Use a worker thread to manage the server so that we can run inside of 333 | IDA Pro without blocking execution. 334 | 335 | """ 336 | class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 337 | allow_reuse_address = True 338 | 339 | 340 | class Worker(threading.Thread): 341 | def __init__(self, host='127.0.0.1', port=8899): 342 | threading.Thread.__init__(self) 343 | self.httpd = ThreadedHTTPServer((host, port), IDARequestHandler) 344 | 345 | def run(self): 346 | self.httpd.serve_forever() 347 | 348 | def stop(self): 349 | self.httpd.shutdown() 350 | 351 | """ 352 | IDA Pro Plugin Interface 353 | 354 | Define an IDA Python plugin required class and function. 355 | """ 356 | 357 | MENU_PATH = 'Edit/Other' 358 | class idarest_plugin_t(idaapi.plugin_t): 359 | flags = idaapi.PLUGIN_KEEP 360 | comment = "" 361 | help = "IDA Rest API for basic RE tool interoperability" 362 | wanted_name = "IDA Rest API" 363 | wanted_hotkey = "Alt-7" 364 | 365 | def _add_menu(self, *args): 366 | idaapi.msg("Adding menu item\n") 367 | ctx = idaapi.add_menu_item(*args) 368 | if ctx is None: 369 | idaapi.msg("Add failed!\n") 370 | return False 371 | else: 372 | self.ctxs.append(ctx) 373 | return True 374 | 375 | def _add_menus(self): 376 | ret = [] 377 | ret.append( 378 | self._add_menu(MENU_PATH, 'Stop IDARest', '', 1, self.stop, tuple())) 379 | ret.append( 380 | self._add_menu(MENU_PATH, 'Start IDARest', '', 1, self.start, tuple())) 381 | if False in ret: 382 | return idaapi.PLUGIN_SKIP 383 | else: 384 | return idaapi.PLUGIN_KEEP 385 | 386 | 387 | def init(self): 388 | idaapi.msg("Initializing %s\n" % self.wanted_name) 389 | self.ctxs = [] 390 | self.worker = None 391 | self.port = 8899 392 | self.host = '127.0.0.1' 393 | ret = self._add_menus() 394 | idaapi.msg("Init done\n") 395 | return ret 396 | 397 | def _get_netinfo(self): 398 | info = idaapi.askstr(0, 399 | "{0}:{1}".format(self.host, self.port), 400 | "Enter IDA Rest Connection Info") 401 | if not info: 402 | raise ValueError("User canceled") 403 | host,port = info.split(':') 404 | port = int(port) 405 | return host,port 406 | 407 | def start(self, *args): 408 | idaapi.msg("Starting IDARest\n") 409 | if self.worker: 410 | idaapi.msg("Already running\n") 411 | return 412 | try: 413 | self.host,self.port = self._get_netinfo() 414 | except: 415 | pass 416 | 417 | try: 418 | self.worker = Worker(self.host,self.port) 419 | except Exception as e: 420 | idaapi.msg("Error starting worker : " + str(e) + "\n") 421 | return 422 | self.worker.start() 423 | idaapi.msg("Worker running\n") 424 | 425 | def stop(self, *args): 426 | idaapi.msg("Stopping IDARest\n") 427 | if self.worker: 428 | self.worker.stop() 429 | del self.worker 430 | self.worker = None 431 | 432 | def run(self, arg): 433 | pass 434 | 435 | def term(self): 436 | idaapi.msg("Terminating %s\n" % self.wanted_name) 437 | try: 438 | self.stop() 439 | except: 440 | pass 441 | for ctx in self.ctxs: 442 | idaapi.del_menu_item(ctx) 443 | 444 | def PLUGIN_ENTRY(): 445 | return idarest_plugin_t() 446 | 447 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | DST='/Applications/IDA Pro 6.6/idaq.app/Contents/MacOS/plugins/idarest.py' 4 | SRC=idarest.py 5 | 6 | cp $SRC "$DST" 7 | chmod +x "$DST" 8 | -------------------------------------------------------------------------------- /requests.sh: -------------------------------------------------------------------------------- 1 | BASE_URL="http://localhost:8899/ida/api/v1.0" 2 | 3 | curl $BASE_URL/info 4 | curl $BASE_URL/cursor 5 | curl $BASE_URL/cursor?ea=0x89ab 6 | curl $BASE_URL/segments 7 | curl $BASE_URL/segments?ea=0x89ab 8 | curl $BASE_URL/names 9 | curl $BASE_URL/color?ea=0x89ab 10 | curl $BASE_URL/color?ea=0x89ab?color=FF0000 11 | -------------------------------------------------------------------------------- /test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import urllib2 3 | import urllib 4 | import requests 5 | import json 6 | 7 | BASE_URL = 'http://localhost:8899/ida/api/v1.0' 8 | 9 | class HttpTestCase(unittest.TestCase): 10 | def setUp(self): 11 | self.url = BASE_URL + '/cursor' 12 | 13 | def _check_json_codes(self, r): 14 | self.assertEqual(r.headers['content-type'], 'application/json') 15 | response = r.json() 16 | self.assertTrue('code' in response, "missing code from response") 17 | self.assertEqual(response['code'], 200) 18 | self.assertTrue('msg' in response, "missing msg from response") 19 | 20 | def test_get(self): 21 | r = requests.get(self.url) 22 | self.assertEqual(r.status_code, 200, "bad status code on generic GET") 23 | self._check_json_codes(r) 24 | 25 | def test_post(self): 26 | r = requests.post(self.url) 27 | self.assertEqual(r.status_code, 200, "bad status code on generic POST") 28 | self._check_json_codes(r) 29 | 30 | def test_get_invalid_url(self): 31 | r = requests.get(BASE_URL + '/bad_api') 32 | self.assertEqual(r.status_code, 404, "failed to detect invalid api URL") 33 | 34 | def test_post_invalid_url(self): 35 | r = requests.post(BASE_URL + '/bad_api') 36 | self.assertEqual(r.status_code, 404, "failed to detect invalid api URL") 37 | 38 | def test_post_bad_content_type(self): 39 | r = requests.post(self.url, data={'ea' : '0x8888'}) 40 | self.assertEqual(r.status_code, 400, "failed to detect bad content-type") 41 | 42 | def test_multiple_instance_of_param(self): 43 | r = requests.get(self.url + '?ea=0x93232&ea=0x8888') 44 | self.assertEqual(r.status_code, 400, 45 | "failed to multiple instances of 1 param") 46 | 47 | def test_post_json_invalid(self): 48 | headers = {'content-type': 'application/json'} 49 | r = requests.post(self.url, 50 | data=json.dumps({'ea' : '0x8888'})+'foo}}}{{', 51 | headers={'content-type': 'application/json'}) 52 | self.assertEqual(r.status_code, 400, "failed to detect invalid json POST") 53 | 54 | def test_post_json(self): 55 | r = requests.post(self.url, data=json.dumps({'ea' : '0x8888'})) 56 | self.assertEqual(r.status_code, 200, "failed to POST json") 57 | 58 | def _verify_jsonp(self, r): 59 | self.assertEqual(r.status_code, 200) 60 | self.assertEqual(r.headers['content-type'], 'application/javascript') 61 | self.assertTrue(r.text.startswith('foobar(')) 62 | self.assertTrue(r.text.endswith(');')) 63 | 64 | def test_post_jsonp_response(self): 65 | r = requests.post(self.url, params={'callback': 'foobar'}) 66 | self._verify_jsonp(r) 67 | 68 | def test_get_jsonp_response(self): 69 | r = requests.get(self.url, params={'callback': 'foobar'}) 70 | self._verify_jsonp(r) 71 | 72 | 73 | class CursorTestCase(unittest.TestCase): 74 | def setUp(self): 75 | self.url = BASE_URL + '/cursor' 76 | 77 | def _check_json_codes(self, r): 78 | self.assertEqual(r.headers['content-type'], 'application/json') 79 | response = r.json() 80 | self.assertTrue('code' in response, "missing code from response") 81 | self.assertEqual(response['code'], 200) 82 | self.assertTrue('msg' in response, "missing msg from response") 83 | 84 | def test_get_cursor(self): 85 | r = requests.get(self.url) 86 | self._check_json_codes(r) 87 | 88 | def test_set_cursor(self): 89 | r = requests.get(self.url, params={'ea': '0x67a82'}) 90 | response = r.json() 91 | self.assertEqual(response['code'], 200) 92 | 93 | def test_set_invalid_cursor(self): 94 | r = requests.get(self.url, params={'ea': 'hhhhhh'}) 95 | response = r.json() 96 | self.assertEqual(response['code'], 400) 97 | 98 | class SegmentsTestCase(unittest.TestCase): 99 | def setUp(self): 100 | self.url = BASE_URL + '/segments' 101 | 102 | def test_get_all_segments(self): 103 | self.fail() 104 | 105 | def test_get_segment_by_address(self): 106 | self.fail() 107 | 108 | class NamesTestCase(unittest.TestCase): 109 | def setUp(self): 110 | self.url = BASE_URL + '/names' 111 | 112 | def test_get_names(self): 113 | self.fail() 114 | 115 | class ColorTestCase(unittest.TestCase): 116 | def setUp(self): 117 | self.url = BASE_URL + '/color' 118 | 119 | def test_get_address_color(self): 120 | self.fail() 121 | 122 | def test_set_address_color(self): 123 | self.fail() 124 | 125 | 126 | if __name__ == '__main__': 127 | unittest.main() 128 | --------------------------------------------------------------------------------