#!/usr/bin/env python3
# -*- coding: utf-8 -*-

##############################################################################
#                                                                            #
#  GhIDA: Ghidra decompiler for IDA Pro                                      #
#                                                                            #
#  Copyright 2019 Andrea Marcelli, Cisco Talos                               #
#                                                                            #
#  Licensed under the Apache License, Version 2.0 (the "License");           #
#  you may not use this file except in compliance with the License.          #
#  You may obtain a copy of the License at                                   #
#                                                                            #
#      http://www.apache.org/licenses/LICENSE-2.0                            #
#                                                                            #
#  Unless required by applicable law or agreed to in writing, software       #
#  distributed under the License is distributed on an "AS IS" BASIS,         #
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  #
#  See the License for the specific language governing permissions and       #
#  limitations under the License.                                            #
#                                                                            #
##############################################################################

import json
import os
import signal
import sys
import tempfile
import time

import Queue
import random
import requests
import string
import subprocess
import threading

import ida_auto
import ida_kernwin
import idaapi
import idautils
import idc

from idaxml import Cancelled
from idaxml import XmlExporter

# This value can be changed
TIMEOUT = 300

# Do not modify it
COUNTER_MAX = TIMEOUT * 10
# Do not modify it
SLEEP_LENGTH = 0.1
GLOBAL_CHECKIN = False
GLOBAL_FILENAME = None
EXPORT_XML_FILE = True

# ------------------------------------------------------------
#   PLUGIN CORE FUNCTIONS
# ------------------------------------------------------------


def force_export_XML_file():
    global EXPORT_XML_FILE
    EXPORT_XML_FILE = True
    return


def create_random_filename():
    global GLOBAL_FILENAME

    if not GLOBAL_FILENAME:
        letters = [random.choice(string.ascii_letters) for i in range(5)]
        random_string = ''.join(letters)
        GLOBAL_FILENAME = "%s_%s" % (idautils.GetInputFileMD5(), random_string)
    return GLOBAL_FILENAME


def terminate_process(pid):
    """
    Kill the process
    """
    if os.name == 'posix':
        os.killpg(os.getpgid(pid), signal.SIGTERM)
    else:
        os.kill(pid, -9)
    return


def get_ida_exported_files():
    """
    Return the path of the XML and bytes files.
    """
    create_random_filename()
    dirname = os.path.dirname(idc.get_idb_path())
    file_path = os.path.join(dirname, GLOBAL_FILENAME)
    xml_file_path = file_path + ".xml"
    bin_file_path = file_path + ".bytes"

    return xml_file_path, bin_file_path


def export_ida_project_to_xml():
    """
    Export the current project into XML format
    """
    global EXPORT_XML_FILE

    xml_file_path, bin_file_path = get_ida_exported_files()
    print("GhIDA:: [DEBUG] EXPORT_XML_FILE: %s" % EXPORT_XML_FILE)

    # Check if files are alredy available
    if os.path.isfile(xml_file_path) and \
            os.path.isfile(bin_file_path) and \
            not EXPORT_XML_FILE:
        return xml_file_path, bin_file_path

    EXPORT_XML_FILE = False

    # Otherwise call the XML exporter IDA plugin
    print("GhIDA:: [DEBUG] Exporting IDA project into XML format")
    st = idc.set_ida_state(idc.IDA_STATUS_WORK)
    xml = XmlExporter(1)

    try:
        xml.export_xml(xml_file_path)
        print("GhIDA:: [INFO] XML exporting completed")
    except Cancelled:
        ida_kernwin.hide_wait_box()
        msg = "GhIDA:: [!] XML Export cancelled!"
        print("\n" + msg)
        idc.warning(msg)
    except Exception:
        ida_kernwin.hide_wait_box()
        msg = "GhIDA:: [!] Exception occurred: XML Exporter failed!"
        print("\n" + msg + "\n", sys.exc_type, sys.exc_value)
        idc.warning(msg)
    finally:
        xml.cleanup()
        ida_auto.set_ida_state(st)

    # check if both xml and binary format exist
    if not os.path.isfile(xml_file_path) or \
            not os.path.isfile(bin_file_path):
        raise Exception("GhIDA:: [!] XML or bytes file non existing.")
    return xml_file_path, bin_file_path


def remove_temporary_files():
    """
    Remove XML and bytes temporary files.
    """
    try:
        xml_file_path, bin_file_path = get_ida_exported_files()
        if os.path.isfile(xml_file_path):
            os.remove(xml_file_path)

        if os.path.isfile(bin_file_path):
            os.remove(bin_file_path)

    except Exception:
        print("GhIDA:: [!] Unexpected error while removing temporary files.")


# ------------------------------------------------------------
#   PLUGIN UTILITY FUNCTIONS
# ------------------------------------------------------------

def is_ida_version_supported():
    """
    Check which IDA version is supported
    """
    major, minor = map(int, idaapi.get_kernel_version().split("."))
    if major >= 7:
        return True
    print("GhIDA:: [!] IDA Pro 7.xx supported only")
    return False


def ghida_finalize(use_ghidra_server, ghidra_server_url):
    """
    Remove temporary files and
    checkout from Ghidraaas server.
    """
    try:
        remove_temporary_files()

        if use_ghidra_server:
            ghidraaas_checkout(ghidra_server_url)

    except Exception:
        print("GhIDA:: [!] Finalization error")
        idaapi.warning("GhIDA finalization error")


# ------------------------------------------------------------
#   GHIDRA LOCAL
# ------------------------------------------------------------

def ghidra_headless(address,
                    xml_file_path,
                    bin_file_path,
                    ghidra_headless_path,
                    ghidra_plugins_path):
    """
    Call Ghidra in headless mode and run the plugin
    FunctionDecompile.py to decompile the code of the function.
    """
    try:
        if not os.path.isfile(ghidra_headless_path):
            print("GhIDA:: [!] ghidra analyzeHeadless not found.")
            raise Exception("analyzeHeadless not found")

        decompiled_code = None
        idaapi.show_wait_box("Ghida decompilation started")

        prefix = "%s_" % address
        output_temp = tempfile.NamedTemporaryFile(prefix=prefix, delete=False)
        output_path = output_temp.name
        # print("GhIDA:: [DEBUG] output_path: %s" % output_path)
        output_temp.close()

        cmd = [ghidra_headless_path,
               ".",
               "Temp",
               "-import",
               xml_file_path,
               '-readOnly',
               '-scriptPath',
               ghidra_plugins_path,
               '-postScript',
               'FunctionDecompile.py',
               address,
               output_path,
               "-noanalysis",
               "-deleteProject"]

        # Options to 'safely' terminate the process
        if os.name == 'posix':
            kwargs = {
                'preexec_fn': os.setsid
            }
        else:
            kwargs = {
                'creationflags': subprocess.CREATE_NEW_PROCESS_GROUP,
                'shell': True
            }

        p = subprocess.Popen(cmd,
                             stdout=subprocess.PIPE,
                             stderr=subprocess.STDOUT,
                             **kwargs)

        stop = False
        counter = 0
        print("GhIDA:: [INFO] Ghidra headless (timeout: %ds)" % TIMEOUT)
        print("GhIDA:: [INFO] Waiting Ghidra headless analysis to finish...")

        while not stop:
            time.sleep(SLEEP_LENGTH)
            counter += 1
            subprocess.Popen.poll(p)

            # Process terminated
            if p.returncode is not None:
                stop = True
                print("GhIDA:: [INFO] Ghidra analysis completed!")
                continue

            # User terminated action
            if idaapi.user_cancelled():
                # Termiante the process!
                terminate_process(p.pid)
                stop = True
                print("GhIDA:: [!] Ghidra analysis interrupted.")
                continue

            # Process timeout
            if counter > COUNTER_MAX:
                terminate_process(p.pid)
                stop = True
                print("GhIDA:: [!] Decompilation error - timeout reached")
                continue

        # Check if JSON response is available
        if os.path.isfile(output_path):
            with open(output_path) as f_in:
                j = json.load(f_in)
                if j['status'] == "completed":
                    decompiled_code = j['decompiled']
                else:
                    print("GhIDA:: [!] Decompilation error -",
                          " JSON response is malformed")

            # Remove the temporary JSON response file
            os.remove(output_path)
        else:
            print("GhIDA:: [!] Decompilation error - JSON response not found")
            idaapi.warning("Ghidra headless decompilation error")

    except Exception as e:
        print("GhIDA:: [!] %s" % e)
        print("GhIDA:: [!] Ghidra headless analysis failed")
        idaapi.warning("Ghidra headless analysis failed")
        decompiled_code = None

    finally:
        idaapi.hide_wait_box()
        return decompiled_code


# ------------------------------------------------------------
#   GHIDRAAAS - GHIDRA SERVER
# ------------------------------------------------------------

def ghidraaas_checkin_thread(bin_file_path,
                             filename,
                             ghidra_server_url,
                             md5_hash,
                             queue):
    """
    ghidraaas_checkin - inner thread
    """
    try:
        options = {
            "md5": md5_hash,
            "filename": filename,
        }

        bb = [
            ('bytes',
                (bin_file_path, open(bin_file_path, 'rb'),
                 'application/octet')),
            ('data',
                ('data', json.dumps(options),
                    'application/json'))
        ]

        r = requests.post("%s/ida_plugin_checkin/" %
                          ghidra_server_url, files=bb, timeout=TIMEOUT)

        print("GhIDA:: [DEBUG] Check-in status code: %d" % r.status_code)
        if r.status_code == 200:
            print("GhIDA:: [INFO] Check-in completed")
            queue.put(True)
            return

        else:
            print("GhIDA:: [!] Check-in error: %s (%s)" % (r.reason, r.text))
            queue.put(False)
            return

    except Exception as e:
        print("GhIDA:: [!] %s" % e)
        print("GhIDA:: [!] Check-in error (thread)." +
              " Please check Ghidraaas address.")
        queue.put(False)
        return


def ghidraaas_checkin(bin_file_path, filename, ghidra_server_url):
    """
    Upload the .bytes files in ghidraaas.
    One time only (until IDA is restarted...)
    """
    idaapi.show_wait_box("Connecting to Ghidraaas. Sending bytes file...")
    try:
        md5_hash = idautils.GetInputFileMD5()
        queue = Queue.Queue()

        my_args = (bin_file_path, filename, ghidra_server_url, md5_hash, queue)
        t1 = threading.Thread(target=ghidraaas_checkin_thread,
                              args=my_args)
        t1.start()

        counter = 0
        stop = False

        while not stop:
            time.sleep(SLEEP_LENGTH)
            counter += 1

            # User terminated action
            if idaapi.user_cancelled():
                stop = True
                print("GhIDA:: [!] Check-in interrupted.")
                continue

            # Reached TIIMEOUT
            if counter > COUNTER_MAX:
                stop = True
                print("GhIDA:: [!] Timeout reached.")
                continue

            # Thread terminated
            if not t1.isAlive():
                stop = True
                print("GhIDA:: [DEBUG] Thread terminated.")
                continue

        print("GhIDA:: [DEBUG] Joining check-in thread.")
        t1.join(0)
        q_result = queue.get_nowait()
        print("GhIDA:: [DEBUG] Thread joined. Got queue result.")
        idaapi.hide_wait_box()
        return q_result

    except Exception:
        idaapi.hide_wait_box()
        print("GhIDA:: [!] Check-in error.")
        idaapi.warning("GhIDA check-in error")
        return False


def ghidraaas_checkout_thread(md5_hash, ghidra_server_url):
    """
    ghidraaas_checkout - inner thread
    """
    try:
        data = {
            "md5": md5_hash,
            "filename": GLOBAL_FILENAME,
        }

        r = requests.post("%s/ida_plugin_checkout/" %
                          ghidra_server_url,
                          json=json.dumps(data),
                          timeout=TIMEOUT)

        print("GhIDA:: [DEBUG] Check-out status code: %d" % r.status_code)
        if r.status_code != 200:
            print("GhIDA:: [!] Check-out error: %s (%s)" % (r.reason, r.text))

    except Exception as e:
        print("GhIDA:: [!] %s" % e)
        print("GhIDA:: [!] Check-out error (thread)")


def ghidraaas_checkout(ghidra_server_url):
    """
    That's all. Remove .bytes file from Ghidraaas server.
    """
    if not GLOBAL_CHECKIN:
        return

    idaapi.show_wait_box(
        "Connecting to Ghidraaas. Removing temporary files...")
    try:
        md5_hash = idautils.GetInputFileMD5()
        aargs = (md5_hash, ghidra_server_url)

        t1 = threading.Thread(target=ghidraaas_checkout_thread,
                              args=aargs)
        t1.start()

        counter = 0
        stop = False

        while not stop:
            time.sleep(SLEEP_LENGTH)
            counter += 1

            if idaapi.user_cancelled():
                print("GhIDA:: [!] Check-out interrupted.")
                stop = True
                continue

            if counter > COUNTER_MAX:
                print("GhIDA:: [!] Timeout reached.")
                stop = True
                continue

            if not t1.isAlive():
                stop = True
                print("GhIDA:: [DEBUG] Thread terminated.")
                continue

        print("GhIDA:: [DEBUG] Joining check-out thread.")
        t1.join(0)
        print("GhIDA:: [DEBUG] Thread joined")
        idaapi.hide_wait_box()
        return

    except Exception:
        idaapi.hide_wait_box()
        print("GhIDA:: [!] Check-out error")
        idaapi.warning("GhIDA check-out error")
        return


def ghidraaas_decompile_thread(address,
                               xml_file_path,
                               bin_file_path,
                               ghidra_server_url,
                               filename,
                               md5_hash,
                               queue):
    """
    Connect to Ghidraaas to decompile a funciton -- inner thread
    """
    try:
        options = {
            "md5": md5_hash,
            "filename": filename,
            "address": address
        }

        bb = [
            ('xml',
                (xml_file_path, open(xml_file_path, 'rb'),
                    'application/octet')),
            ('data',
                ('data', json.dumps(options),
                    'application/json'))
        ]

        r = requests.post("%s/ida_plugin_get_decompiled_function/" %
                          ghidra_server_url, files=bb, timeout=TIMEOUT)

        print("GhIDA:: [DEBUG] Decompilation status code: %d" % r.status_code)

        if r.status_code == 200:
            print("GhIDA:: [INFO] Decompilation completed")
            j = r.json()
            if j['status'] == "completed":
                queue.put(j['decompiled'])
                return

            print("GhIDA:: [!] Unknown decompilation error")
            queue.put(None)
            return

        else:
            print("GhIDA:: [!] Decompilation error: %s (%s)" %
                  (r.reason, r.text))
            queue.put(None)
            return

    except Exception as e:
        print("GhIDA:: [!] %s" % e)
        print("GhIDA:: [!] Decompilation error (thread)")
        queue.put(None)


def ghidraaas_decompile(address,
                        xml_file_path,
                        bin_file_path,
                        ghidra_server_url):
    """
    Send the xml file to ghidraaas
    and ask to decompile a function
    """
    global GLOBAL_CHECKIN

    # Filename without the .xml extension
    filename = GLOBAL_FILENAME

    if not GLOBAL_CHECKIN:
        if ghidraaas_checkin(bin_file_path, filename, ghidra_server_url):
            GLOBAL_CHECKIN = True
        else:
            raise Exception("[!] Ghidraaas Check-in error")

    idaapi.show_wait_box(
        "Connecting to Ghidraaas. Decompiling function %s" % address)

    try:
        md5_hash = idautils.GetInputFileMD5()
        queue = Queue.Queue()

        aargs = (address, xml_file_path, bin_file_path,
                 ghidra_server_url, filename, md5_hash, queue)
        t1 = threading.Thread(target=ghidraaas_decompile_thread,
                              args=aargs)
        t1.start()

        counter = 0
        stop = False

        while not stop:
            time.sleep(SLEEP_LENGTH)
            counter += 1

            if idaapi.user_cancelled():
                print("GhIDA:: [!] decompilation interrupted.")
                stop = True
                continue

            if counter > COUNTER_MAX:
                print("GhIDA:: [!] Timeout reached.")
                stop = True
                continue

            if not t1.isAlive():
                stop = True
                print("GhIDA:: [DEBUG] Thread terminated.")
                continue

        print("GhIDA:: [DEBUG] Joining decompilation thread.")
        t1.join(0)
        q_result = queue.get_nowait()
        print("GhIDA:: [DEBUG] Thread joined. Got queue result.")
        idaapi.hide_wait_box()
        return q_result

    except Exception:
        idaapi.hide_wait_box()
        print("GhIDA:: [!] Unexpected decompilation error")
        idaapi.warning("GhIDA decompilation error")
        return None


# ------------------------------------------------------------
#   DECOMPILE FUNCTION - CORE
# ------------------------------------------------------------

def decompile_function(address,
                       use_ghidra_server,
                       ghidra_headless_path,
                       ghidra_plugins_path,
                       ghidra_server_url):
    """
    Decompile function at address @address
    """
    try:
        print("GhIDA:: [DEBUG] Decompiling %s" % address)

        xml_file_path, bin_file_path = export_ida_project_to_xml()

        # Get the decompiled code
        if use_ghidra_server:
            decompiled = ghidraaas_decompile(address,
                                             xml_file_path,
                                             bin_file_path,
                                             ghidra_server_url)
        else:
            decompiled = ghidra_headless(address,
                                         xml_file_path,
                                         bin_file_path,
                                         ghidra_headless_path,
                                         ghidra_plugins_path)
        return decompiled

    except Exception:
        print("GhIDA:: [!] Decompilation error")
        idaapi.warning("GhIDA decompilation error")