2019年10月9号,Mozilla安全团队公开了iTerm2一个存在了7年的任意命令执行漏洞,用户在使用常规命令(如ssh、curl等存在信息返回的命令)时都存在被攻击的可能,而由于iTerm2的是目前Mac OSX上最流行的终端之一,因此该漏洞影响范围较大,CVSS评分为9.8(critical)
。
该漏洞存在于iTerm2的tmux集成模块中,但是与tmux的安装与否没有关系,只需要用户的iTerm2输出恶意的内容时,攻击者就可以在用户的计算机上执行命令,所以许多常见的命令都可以导致用户被攻击,如nc
、cat
、ssh
、curl
、head
、tail
等等。
tmux 是一款终端复用软件,用户可以在一个窗口里通过 tmux 创建、访问和控制多个分离的终端,同时还允许对终端进行“解绑”与“附加”。
tmux提供了一个纯文本交互的接口以方便其他应用与tmux进行交互,这一特性称为CONTROL MODE
,iTerm2也通过这一特性来实现了tmux集成模块。
在tmux的man page中,可以知道CONTROL MODE
可以由tmux -C
和tmux -CC
启动,该模式要求client需要发送以回车为结尾的tmux命令,每个tmux命令都会有一个以%begin
开头和%end
结尾的文本块代表输出内容,或者一个以%error开头的文本块代表错误内容。
在CONTROL MODE
中tmux服务端会向客户端输出如下内容,来通知其状态的改变:
首先先看对应的(commit)[https://github.com/gnachman/iTerm2/commit/538d570ea54614d3a2b5724f820953d717fbeb0c]描述:
Do not send server-controlled values in tmux integration mode.
CVE-2019-9535
- Use session number everywhere rather than session name
- Do not poll tmux for the set-titles-string, status-left, and status-right and
then request the values of the returned format strings. Use ${T:} eval
instead. These features are now only available for tmux 2.9 and later.- Hex-encode options saved in the tmux server to make them unexploitable (e.g.,
hotkeys, window affinities, window origins, etc.). The old values are
accepted as inputs but will never be produced as output.
可以知道漏洞存在于处理set-titles-string
,status-left
和 status-right
时没有对输入进行校验从而导致的命令注入。由于漏洞成因相近,本文只分析set-titles-string
的漏洞原理和利用。
阅读tmux源码时,发现当以-CC
进入CONTROL MODE
时,tmux会输出\033P1000p
和一个初始化文本块
,例如:
\033P1000p%begin 1337 0 0
%end 1337 0 0
而iTerm2也是利用这一输出判断是否进入tmux模式,因此通过构造输出,iTerm2也会进入tmux模式
$ printf "\033P1000p%%begin 1337 0 0\n%%end 1337 0 0"
** tmux mode started **
Command Menu
----------------------------
esc Detach cleanly.
X Force-quit tmux mode.
L Toggle logging.
C Run tmux command.
在阅读iTerm2源码后,发现当处于tmux模式时,iTerm2会将tmux的输出传入TmuxGateway.m
的executeToken
函数中,该函数负责处理tmux的返回数据并调用相应的回调函数。
在处理初始化文本块
时,会调用currentCommandResponseFinishedWithError
函数
- (void)currentCommandResponseFinishedWithError:(BOOL)withError { // ...... if (!_initialized) { _initialized = YES; if (withError) { [delegate_ tmuxInitialCommandDidFailWithError:currentCommandResponse_]; } else { [delegate_ tmuxInitialCommandDidCompleteSuccessfully]; } } // ...... }
最后会进入PTYSession.m
的 tmuxInitialCommandDidCompleteSuccessfully
函数来进行初始化
- (void)tmuxInitialCommandDidCompleteSuccessfully { // This kicks off a chain reaction that leads to windows being opened. [_tmuxController ping]; [_tmuxController validateOptions]; [_tmuxController checkForUTF8]; [_tmuxController guessVersion]; [_tmuxController loadTitleFormat]; }
而该函数会调用TmuxGateWay.m
的sendCommand
向服务端发送一系列的tmux命令用于初始化:
# | tmux命令 | 回调函数 |
---|---|---|
1 | display-message -p -F . | handlePingResponse |
2 | show-window-options -g aggressive-resize | showWindowOptionsResponse |
3 | show-option -g -v status | handleStatusResponse |
4 | list-sessions -F "\t" | checkForUTF8Response |
5 | display-message -p "#{version}" | handleDisplayMessageVersion |
6 | show-window-options pane-border-format | guessVersion23Response |
7 | list-windows -F "#{socket_path}" | guessVersion22Response |
8 | list-windows -F "#{session_activity}" | guessVersion21Response |
9 | list-clients -F "#{client_cwd}" | guessVersion18Response |
10 | show-options -v -g set-titles | handleShowSetTitles |
11 | show-options -v -g set-titles-string | handleShowSetTitlesString |
当命令#10(show-options -v -g set-titles
)的返回是on
时,变量_shouldSetTitles
值设为true
,而命令#11(show-options -v -g set-titles-string
)将返回的内容存入setTitlesString
变量中,使得该变量可控
- (void)handleShowSetTitles:(NSString *)result { _shouldSetTitles = [result isEqualToString:@"on"]; [[NSNotificationCenter defaultCenter] postNotificationName:kTmuxControllerDidFetchSetTitlesStringOption object:self]; } - (void)handleShowSetTitlesString:(NSString *)setTitlesString { _setTitlesString = [setTitlesString copy]; }
同时handleShowSetTitles
函数会广播kTmuxControllerDidFetchSetTitlesStringOption
消息,从而触发PTYTab.m
的tmuxDidFetchSetTitlesStringOption
函数。
- (void)tmuxDidFetchSetTitlesStringOption:(NSNotification *)notification { if (notification.object != tmuxController_) { return; } [self updateTmuxTitleMonitor]; } - (void)updateTmuxTitleMonitor { if (!self.isTmuxTab) { return; } if (tmuxController_.shouldSetTitles) { if (_tmuxTitleMonitor) { return; } [self installTmuxTitleMonitor]; } else { if (!_tmuxTitleMonitor) { return; } [self uninstallTmuxTitleMonitor]; } }
但是由于此时tmuxController_
为nil
,因此notification.object != tmuxController_
为真,并不会调用updateTmuxTitleMonitor
。
但是,如果执行了updateTmuxTitleMonitor
函数,由于_shouldSetTitles
为true
,所以会调用installTmuxTitleMonitor
- (void)installTmuxTitleMonitor { assert(!_tmuxTitleMonitor); if (self.tmuxWindow < 0) { return; } _tmuxTitleMonitor = [[iTermTmuxOptionMonitor alloc] initWithGateway:tmuxController_.gateway scope:self.variablesScope format:tmuxController_.setTitlesString target:[NSString stringWithFormat:@"@%@", @(self.tmuxWindow)] variableName:iTermVariableKeyTabTmuxWindowTitle block:nil]; [_tmuxTitleMonitor updateOnce]; if (self.titleOverride.length == 0) { // Show the tmux window title if both the tmux option set-titles is on and the user hasn't // already set a title override. self.variablesScope.tabTitleOverrideFormat = [NSString stringWithFormat:@"\\(%@?)", iTermVariableKeyTabTmuxWindowTitle]; } }
而该函数把可控的tmuxController_.setTitlesString
作为format
参数,调用initWithGateway
初始化一个iTermTmuxOptionMonitor
类,从而导致iTermTmuxOptionMonitor
的成员变量_format
是可控的,而后续又会调用iTermTmuxOptionMonitor
中的成员函数updateOnce
- (void)updateOnce { if (_haveOutstandingRequest) { DLog(@"Not making a request because one is outstanding"); return; } _haveOutstandingRequest = YES; NSString *command = [NSString stringWithFormat:@"display-message -t '%@' -p '%@'", _target, self.escapedFormat]; DLog(@"Request option with command %@", command); [self.gateway sendCommand:command responseTarget:self responseSelector:@selector(didFetch:) responseObject:nil flags:kTmuxGatewayCommandShouldTolerateErrors]; } - (NSString *)escapedFormat { return [[_format stringByReplacingOccurrencesOfString:@"\\" withString:@"\\\\"] stringByReplacingOccurrencesOfString:@"'" withString:@"\\'"]; }
而在updateOnce
函数中,将可控的_format
变量中的'
替换成\'
以及\
替换成\\
后发送给tmux服务端,由于没有过滤CRLF从而导致后续利用的发生。
可是,此时的updateTmuxTitleMonitor
函数并没有被执行,因此问题转变为如何触发updateTmuxTitleMonitor
的执行。
在对iTerm2的tmux集成模块源码进行一番阅读后,发现TmuxGateway.m
中的函数parseSessionChangeCommand
,该函数在接收到tmux服务端返回的以%session-changed
开头的命令时被执行,并且该函数最终会调用函数openWindowsInitial
。
而在openWindowsInitial
中,向tmux服务端发送命令show -v -q -t $%d @iterm2_size
并注册了回调函数handleShowSize
- (void)openWindowsInitial { NSString *command = [NSString stringWithFormat:@"show -v -q -t $%d @iterm2_size", sessionId_]; [gateway_ sendCommand:command responseTarget:self responseSelector:@selector(handleShowSize:)]; } - (void)handleShowSize:(NSString *)response { NSScanner *scanner = [NSScanner scannerWithString:response ?: @""]; int width = 0; int height = 0; BOOL ok = ([scanner scanInt:&width] && [scanner scanString:@"," intoString:nil] && [scanner scanInt:&height]); if (ok) { [self openWindowsOfSize:VT100GridSizeMake(width, height)]; } else { [self openWindowsOfSize:[[gateway_ delegate] tmuxClientSize]]; } } - (void)openWindowsOfSize:(VT100GridSize)size { // ...... NSString *listWindowsCommand = [NSString stringWithFormat:@"list-windows -F %@", kListWindowsFormat]; // ...... NSArray *commands = @[ // ...... [gateway_ dictionaryForCommand:listWindowsCommand responseTarget:self responseSelector:@selector(initialListWindowsResponse:) responseObject:nil flags:0] ]; [gateway_ sendCommandList:commands]; }
在handleShowSize
被回调时,会调用openWindowsOfSize
向tmux服务端发送一系列tmux命令,其中有一条命令list-windows -F %@
的回调函数是initialListWindowsResponse
,而该函数最终会通过函数openWindows
来创建tmux窗口,在这过程中函数appendRequestsForNode
会被调用。
由于appendRequestsForNode
的调用链过长,在此不再赘述,调用链如下:
- appendRequestsForNode
- appendRequestsForWindowPane
- dictForGetPendingOutputForWindowPane
- getPendingOutputResponse
- requestDidComplete
- loadTmuxLayout
- openTabWithTmuxLayout
- updateTmuxTitleMonitor // [漏洞触发]
自此,我们已经可以注入恶意命令到tmux服务端,如下图所示
但是这里存在一个问题,由于tmux server是由我们伪造的,那么即使注入了恶意的tmux命令,也只是把恶意命令返回给自己(即不存在真正的tmux server去处理它),那么所谓的命令注入又是如何执行的呢?
事实上,所有的tmux命令都经由TmuxGateway
进行处理,所有待处理的命令都会存放在TmuxGateway
的commandQueue_
队列中。当某条命令出错时,所有的待处理命令会被神奇的输出在iTerm2里(包括回车),这就造成了命令注入,一个简单的POC如下:
sh-3.2$ printf "\033P1000p%%begin 1337 0 0\n%%end 1337 0 0\n%%CVE-2019-9535\n" ** tmux mode started ** Command Menu ---------------------------- esc Detach cleanly. X Force-quit tmux mode. L Toggle logging. C Run tmux command. Unrecognized command from tmux. Did your ssh session die? The command was: sh-3.2$ display-message -p -F . Detached sh-3.2$ show-option -g -v status sh: show-option: command not found sh-3.2$ list-sessions -F "" sh: list-sessions: command not found sh-3.2$ display-message -p "#{version}" sh: display-message: command not found sh-3.2$ show-window-options pane-border-format sh: show-window-options: command not found sh-3.2$ list-windows -F "#{socket_path}" sh: list-windows: command not found sh-3.2$ list-windows -F "#{session_activity}" sh: list-windows: command not found sh-3.2$ list-clients -F "#{client_cwd}" sh: list-clients: command not found sh-3.2$ show-options -v -g set-titles; show-options -v -g set-titles-string sh: show-options: command not found sh: show-options: command not found sh-3.2$
综上,漏洞的主要利用过程如下:
"\033P1000p%%begin 1337 0 0\n%%end 1337 0 0"
伪装tmux服务端show-options -v -g set-titles
返回on
,show-options -v -g set-titles-string
返回恶意payload%session-changed
通知,用于触发updateTmuxTitleMonitor
,将恶意命令注入TmuxGateway.commandQueue_
[1]. Critical Security Issue identified in iTerm2 as part of Mozilla Open Source Audit
[2]. tmux source code
[3]. Do not send server-controlled values in tmux integration mode.
[5]. iTerm2 CVE-2019-9535 分析(待续)
[6]. [CVE-2019-9535] Iterm2命令执行的不完整复现
文中有误之处,还请师傅们斧正!