0x00 前言
关于Empire这款优秀的后渗透工具,大家想必已经非常熟悉了。尽管网络上有各种各样的使用教程,真正深入到源码中去分析Empire本身实现的文章却寥寥无几。这次我会对Empire这个优秀的开源后渗透框架的源码进行分析,尽可能的去挖掘这个框架背后的设计方法和原理。我想,分析完这个框架之后,我们就能够借鉴其思想,自己来实现一个远控程序或后渗透框架来。
对于这个框架,我会用几篇文章来讲,大致会分以下几个部分来介绍:
1. Empire整体结构 & 程序入口
2. Stager & Listener & Agent
3. 数据流通 & 数据加密
4. 插件 & 扩展性
5. 第三方库们
如果大家有感兴趣的其他方面,也可以给我留言,如果在能力范围之内,我也会写一写
今天这一篇,谈一谈Empire的目录结构、入口文件、菜单逻辑,以及使用http listener时agent与CC建立连接的整个过程
0x01 目录结构、入口和菜单逻辑
从Github获取源码,只显示了二级目录并去除了一些安装部署相关的文件后,比较重要的几个目录结构如下:
Empire
├── data data目录用于存放静态文件、模板、数据库等
│ ├── agent
│ ├── empire-chain.pem
│ ├── empire-priv.key
│ ├── empire.db Empire使用了sqlite数据库存储
│ ├── misc
│ ├── module_source
│ ├── obfuscated_module_source
│ └── profiles
├── empire 主程序入口,python文件
├── lib
│ ├── common
│ ├── listeners 放置了不同的listener
│ ├── modules 放置了各种payload,后渗透功能相关
│ ├── powershell 放置Invoke-Obfuscation项目文件,用于混淆powershell
│ └── stagers 放置了各种平台下的stager
└── plugins 放置了插件示例文件
可以看见项目文件还是比较清晰的,由于当前远控多数情况还是被控端主动连接到控制端的,为了区分方便,在下文中我会将被控端称之为Client,将控制端称为Server。我们先来分析Server端,看一下这个项目是怎样运行的。首先关注一下Empire/empire这个文件,这是整个程序的入口
程序在最开始定义了一些数据库连接和查询相关的函数,这里的数据库使用的是sqlite。为了给RESTFUL API鉴权,这里还给出了生成token的函数。
def refresh_api_token(conn):
"""
Generates a randomized RESTful API token and updates the value
in the config stored in the backend database.
"""
# generate a randomized API token
apiToken = ''.join(random.choice(string.ascii_lowercase + string.digits) for x in range(40))
execute_db_query(conn, "UPDATE config SET api_current_token=?", [apiToken])
return apiToken
岔个话题,这种写法还是蛮pythonic的,直接用了random.choice函数来随机选择,而且还使用了类似列表推导的语法
之后开始的start_restful_api()函数则是使用Flask框架来注册了一堆路由,用于RESTFUL API。有人可能会好奇这玩意是做什么用的,其实在EmpireProject的github账号中已经创建了Empire GUI客户端的项目,应该是用于客户端与服务端的交互过程,或者是留了接口能够以后被第三方工具调用
####################################################################
#
# The Empire RESTful API.
#
# Adapted from http://blog.miguelgrinberg.com/post/designing-a-restful-api-with-python-and-flask
# example code at https://gist.github.com/miguelgrinberg/5614326
#
# Verb URI Action
# ---- --- ------
# GET http://localhost:1337/api/version return the current Empire version
#
# GET http://localhost:1337/api/config return the current default config
#
# GET http://localhost:1337/api/stagers return all current stagers
# GET http://localhost:1337/api/stagers/X return the stager with name X
# POST http://localhost:1337/api/stagers generate a stager given supplied options (need to implement)
# ...
官方已经给了很详细的注释,可以直接去读101行开始的源码,这一部分不再详细说明
从1362行来到了’main’,进入真正的逻辑,这里取了一些参数,我们只看最普通的执行情况,只有很简单的几句:
else:
# normal execution
main = empire.MainMenu(args=args)
main.cmdloop()
进入了empire模块中MainMenu类的cmdloop()函数,这个empire模块才是框架比较核心的部分,之前的只是入口
进入Empire/lib/common/empire.py文件继续阅读
# custom exceptions used for nested menu navigation
class NavMain(Exception):
"""
Custom exception class used to navigate to the 'main' menu.
"""
pass
一上来定义了几个类用于异常处理,其实这个是在菜单跳转中使用的。真正重要的是之后定义的几个大的类,MainMenu, SubMenu, AgentsMenu, AgentMenu, PowerShellAgentMenu, PythonAgentMenu, ListenersMenu, ListenerMenu, ModuleMenu, StagerMenu,下面一个一个讲
MainMenu是最核心的控制部分,程序启动后会首先进入主菜单。Empire菜单的控制逻辑其实不全是自己写的,而是继承了cmd模块中的Cmd类,这个类的详情可以看这里https://wiki.python.org/moin/CmdModule,简要来说的话就是提供了写命令行应用的一些很方便的特性,比如能够自定义命令和语法、整体读取命令和返回结果的循环、TAB键的语法补全,我们在MainMenu类中看到形如do_xxx的语法均为定义指令,help_xxx的语法均为定义帮助命令,complete_xxx的语法均为处理TAB补全相关
在写远控框架的时候另一个比较重要的问题是,当我们发送的命令得到Client回复时,Server如何获知这个消息。Empire在这个问题上给出的答案是使用dispatcher模块。详情可以见http://pydispatcher.sourceforge.net
# set up the event handling system
dispatcher.connect(self.handle_event, sender=dispatcher.Any)
77行这里指定了MainMenu的事件处理函数,这里对任何sender发出的信号都会接收并处理,我们去handle_event函数看一下
def handle_event(self, signal, sender):
"""
Whenver an event is received from the dispatcher, log it to the DB,
decide whether it should be printed, and if so, print it.
If self.args.debug, also log all events to a file.
"""
# load up the signal so we can inspect it
try:
signal_data = json.loads(signal)
except ValueError:
print(helpers.color("[!] Error: bad signal recieved {} from sender {}".format(signal, sender)))
return
# this should probably be set in the event itself but we can check
# here (and for most the time difference won't matter so it's fine)
if 'timestamp' not in signal_data:
signal_data['timestamp'] = helpers.get_datetime()
# if this is related to a task, set task_id; this is its own column in
# the DB (else the column will be set to None/null)
task_id = None
if 'task_id' in signal_data:
task_id = signal_data['task_id']
if 'event_type' in signal_data:
event_type = signal_data['event_type']
else:
event_type = 'dispatched_event'
event_data = json.dumps({'signal': signal_data, 'sender': sender})
...
注释写的挺清楚,收到信号时会记录到Database,并根据signal中的选项决定是否打印数据,在不同的菜单中显示不同的数据类型的相关逻辑就是这样控制的,除此之外,dispatcher也是listener与主控程序的通信方式
其实这里价值在于观察程序的启动方式,观察菜单跳转和命令都是怎么实现的,看完这里我们完全也可以自己做一个相似的CC出来
菜单跳转这里是通过raise Exception实现的,在主菜单中永续循环读取命令,判断需要显示的菜单,如果需要跳转到agents/listeners等菜单会抛出一个异常,修改需要显示菜单的变量,主菜单捕获相应异常,根据变量值实例化一个新的菜单对象,然后再开始新的菜单对象的cmdloop()函数
至此我们已经明确了empire的菜单实现,明确了菜单之间的跳转是怎么做到的,也明确了empire交互性良好的REPL模式命令行构建的秘密
0x02 深入Listener
下面我们来探究Empire中的listener, stager和agent。首先来明确下这三个组件的定位:
listener中文译为监听器,CC要想接收被控端发来的信息或者向其发布命令,必须要与之建立连接,此时会开启一个端口来等待被控端连接。在empire中,我们的http listener其实就是启动了一个flask web应用,通过flask内置的WSGI来作server
stager是empire的最终payload,在RAT程序中,常常会使用payload分离的手段。目的之一是躲避杀毒软件,目的之二是减少投放payload的体积。首先释放一个体积较小的程序,该程序一般会判断环境、检查杀毒软件和系统信息,当判读可以继续执行时,到CC服务器上下载更大体积的恶意程序执行。体积较小的程序通常称为dropper/launcher/downloader,体积较大的程序可以称为stager。此外还有一种payload不分离的情况,称为standalone,此时不会向服务器再请求新的payload,所有功能都在一个文件里,直接进入与CC通信执行命令的阶段
agent在empire中代指被控端,这个没什么需要细说的,等下继续看源码
下面我们按照时间顺序,以最基础的http listener为例,从建立监听开始,一直到执行命令,一起按照数据流串一遍流程,深入细节揭秘一下empire的主要逻辑
Debug In Docker With Pycharm
为了更清晰的分析整个过程,我们用Pycharm进行调试。为了方便,这里选用了Docker+Remote Debug的方式,使用了empire docker中的python解释器+本地源码,具体设置如下:
设置好之后即可开始调试
在调试的时候遇到的一个坑:使用uselistener http
命令发现没有反应,经过debug发现原因在于我之前在本机上试着初始化过empire.db
,而在初始化的时候会将安装目录的路径写进数据库,在load_listeners
操作的时候又会按照这个路径去取listener
文件,从docker container按照这个路径去取自然是取不到的,因此会按照处理不存在的listener
的流程继续进行
解决方案也很简单,在程序启动前打个断点,然后开个终端进入docker container,在docker container内重新执行一下setup/setup_database.py
即可
开启Listener
这一部分从建立Listener开始,首先我们建立一个最基础的HTTP Listener
使用set
指令为其中的必须项设置具体值,这里需要设定IP和端口。⚠️注意,由于这里的empire是放在docker容器中的,我们的Host地址添了容器宿主机的地址(同时做了端口映射)
(Empire: listeners/http) > set Port 443
(Empire: listeners/http) > set Host 172.16.132.1
之后启动listener
(Empire: listeners/http) > execute
[*] Starting listener 'http'
* Serving Flask app "http" (lazy loading)
* Environment: production
WARNING: Do not use the development server in a production environment.
Use a production WSGI server instead.
* Debug mode: off
[+] Listener successfully started!
在这一过程中,调用的是lib/common/empire.py
中的do_uselistener
方法
def do_uselistener(self, line):
"Use an Empire listener module."
parts = line.split(' ')
if parts[0] not in self.mainMenu.listeners.loadedListeners:
print
helpers.color("[!] Error: invalid listener module")
else:
listenerMenu = ListenerMenu(self.mainMenu, parts[0])
listenerMenu.cmdloop()
这里对我们使用的listener进行了查询,当存在对应listener时新建了ListenerMenu实例,进入其命令循环
在设置完Listener的相应值后,使用execute
命令其实调用的是:
def do_execute(self, line):
"Execute the given listener module."
self.mainMenu.listeners.start_listener(self.listenerName, self.listener)
跟进到start_listener函数,位于lib/common/listeners.py
def start_listener(self, moduleName, listenerObject):
...
try:
print helpers.color("[*] Starting listener '%s'" % (name))
success = listenerObject.start(name=name)
if success:
listenerOptions = copy.deepcopy(listenerObject.options)
self.activeListeners[name] = {'moduleName': moduleName, 'options':listenerOptions}
pickledOptions = pickle.dumps(listenerObject.options)
cur = self.conn.cursor()
cur.execute("INSERT INTO listeners (name, module, listener_category, enabled, options) VALUES (?,?,?,?,?)", [name, moduleName, category, True, pickledOptions])
cur.close()
# dispatch this event
message = "[+] Listener successfully started!"
signal = json.dumps({
'print': True,
'message': message,
'listener_options': listenerOptions
})
dispatcher.send(signal, sender="listeners/{}/{}".format(moduleName, name))
else:
print helpers.color('[!] Listener failed to start!')
except Exception as e:
...
绕来绕去还是最终调用了listener对象内的start()
函数,当启动成功时,empire还会保存一份listener对象中配置选项的副本,将其序列化保存到数据库中,这是为了在程序再次启动时能够恢复之前启动的listener。另外一个值得一提的细节,之前提到过empire在界面的展示是通过dispatcher,这里就是一个很好的例子,将signal以json的形式发过去,并决定是否打印出来
下面我们深入到HTTP Listener
对象,看一下它到底是什么。我们选择的listener,它的位置在lib/listeners/http.py
。其实可以看到这里还有很多其他的listener,我们随后再进行分析,先从基础款开始
这个listener中主要是一个Listener类,都是用统一的模板写的,这样能够做到插件化开发,当我们理解这个构成后应该也可以自定义自己的Listener。其中变量有:
- self.info 介绍作者和模块信息
- self.options 关键参数
- self.mainMenu 主菜单对象
- ...
一个好玩的事:其中self.options中有一个StagingKey参数
'StagingKey' : {
'Description' : 'Staging key for initial agent negotiation.',
'Required' : True,
'Value' : '2c103f2c4ed1e59c0b4e2e01821770fa'
}
Value的值其实是Password123!
的md5值,不过里面的设定并不会使用这个默认值,而是在按照empire的时候会在数据库随机生成一个,之后都是通过get_config
从数据库里取的
类中主要的方法有:
- default_response IIS 7.5 404 not found page
- Index_page HTTP默认页面
- validate_options 检查是否必须项都设置了内容
- generate_launcher 生成启动器
- generate_stager 生成stager相关代码
- generate_agent 生成agent代码
- generate_comms 生成通信相关代码
- start_server 启动服务
start_server函数其实启动了一个Flask Werkzeug服务器,然后上面挂了一个Flask应用,里面定义的路由有:
- /download/\<stager> 用于下载stager
- / & /index.htm 用于展示首页
- /welcome.png 用于展示图片
- /\<path:request_uri> 用于真正进行CC与agent之间的通信
建立连接
从代码注释来看,建立连接分为以下几步:
- client requests staging code【client - GET】
- return stager.ps1 (stage 1)【server - RESPONSE】
- client posts public key【client - POST】
- server returns RSA(nonce+AESsession)) / server returns HMAC(AESn(nonce+PUBs))【server - RESPONSE】
- client posts nonce+sysinfo and requests agent【client - POST】
- server sends patched agent.ps1/agent.py【server - RESPONSE】
STAGE0
当agent回连后,首先会发送GET请求到一个任意页面(这里可以在选项中配置,本次发送到的是/new.php页面)
之后会获取agent的IP、listener信息,以及从header中获取cookie。这里决定通信状态的,其实是cookie中的值,当然这些值有被编码和加密过,我们继续看
这里在解析cookie,试图从中获取session的值,并且使用base64解码
之后开始用stagingKey解密routingPacket,这里先不看具体的解密过程,只需要知道向某个函数传入了key和encrypted_info进行解密,解密后的具体信息是这样的
其中一个重要信息,STAGE0,标志着目前建立连接处于哪个阶段,在STAGE0这一阶段,会生成stager并发送给agent,当然生成的stager也是加密和混淆过的,请看lib/listeners/http.py
文件中的generate_stager
函数
获取配置信息,随机选取两个url作为stage1和stage2的地址。之后打开data/agent/stagers/http.ps1
模板文件,替换其中的相关配置信息
之后进行混淆操作,随机变换大小写
这里最终使用了RC4进行加密
此时完成了STAGE0阶段的信息交换
STAGE1
STAGE1阶段是agent向CC发送通信加密用的RSA公钥,处理函数在lib/common/agents.py
中,重点看handle_agent_staging
函数
这里主要进行的操作是:
- 判断信息格式是否完整
- 判断agent使用的语言
- 获取rsaKey
- 向全局变量mainMenu.agents中注册(字典),并且将相关信息添加到数据库。此时如果没有sessionKey,会在入库的时候自动生成一个随机值
- 返回加密后的
nonce+clientSessionKey
STAGE2
之后进入STAGE2阶段,client再次使用POST请求CC,这次传输的是agent的基本信息
可以看到这里传输了nonce、CC地址、主机名、用户、IP、操作系统等等。获取完之后,会更新数据库,将信息写入。另外还可以看到这里为slack接口预留了逻辑,如果填写了slack api,还会向slack发送通知
还有autoruns功能,用于在上线时自动执行脚本
最终返回值会存入dataResults,具体内容为agent语言和该agent的sessionID
然后进入step 6,返回给agent修改后的agent.ps1或agent.py
文件
跟进self.generate_agent
函数,进行的具体操作是:
- 更新通信相关代码(看了一下,重点是CC服务器地址和通信时的profile)
- 去除注释和空行
- 更新delay, jitter, lost limit, comms profile的值
- 更新killDate, obfuscate选项,如果开启混淆,则将代码替换成混淆后的值
命令获取 & 结果上报
我们以whoami
命令为例,跟进下命令发放和结果上报的全过程
将待执行命令写入数据库的操作在lib/common/empire.py
的PowerShellAgentMenu
类中
不具体展开了,里面有个处理细节是限定了命令队列的最大长度是65535,填满后会覆盖
等到建立连接之后,agent会周期性的向CC发起GET请求,检查是否有需要执行的命令。再次重申下,所有的GET请求中,需要传递的数据都是加密后放在cookie中的
在解析完数据包之后,请求的内容是这样的:
之后会进入lib/common/agents/py
文件中的handle_agent_request
函数,并完成以下操作:
- update_lastsee 更新beacon时间
- self.get_agent_tasks_db(sessionID) 从数据库中获取该agent需要执行的命令
- 如果有命令需要执行,构建相关packet
构建数据包相关的操作都在lib/common/packets.py
里,之后应该还会再分析,最终生成的是这样的一个数据包
+------+--------------------+----------+---------+--------+-----------+
| Type | total # of packets | packet # | task ID | Length | task data |
+------+--------------------+--------------------+--------+-----------+
| 2 | 2 | 2 | 2 | 4 | <Length> |
+------+--------------------+----------+---------+--------+-----------+
等发放完命令之后,agent使用POST请求,将结果发送给CC,详情可以跟一下lib/listeners/http.py
中的handle_post
方法
我们在lib/common/agents.py
中这里打断点,等待结果上报
跟进self.handle_agent_response
在经过解包后,responsePackets中可以发现命令的执行结果,这里没有什么需要讲的,结果已经传回来了,只需要处理和格式化展示即可
用一张图来展示会更清楚:
这就是从开启Listener,建立连接,到命令获取与结果上报的全过程。本篇到此结束,能力有限,也请大家多多指正🤪