这篇文章发表于 1681 天前,可能其部分内容已经发生变化,如有疑问可询问作者。
这次由于看到了一个点,觉得妙不可言,想要出成题。拖了几天之后最终还是行动了。。这次出题非常费劲,因为要完成的东西实在是太多了,而且自己也菜,更要命的是坑也非常多(还好是寒假,不过三四天时间就这样过去了)——对于坑,要么填上,要么就只能绕过,这也就导致了一些地方代码实现最终还是选择了绕弯。网上找不到这类完整的资料,就大多按照自己的想法来了,欢迎有人来喷。
(作者自己来批判了:本文思路可以看看,但是实现太复杂了,现在感觉js几句话就能解决……果然成长之后会觉得以前的自己很捞,其实现在也是…… —2021.10.14)
大致思路与流程
讲讲出这种题的思路流程:
首先XSS需要一个主页面,也就是含有漏洞的页面。这个页面不能太弱,不应该包含其他的漏洞,只包含存储型XSS为最佳。
有了这样一个主页面,而题目是存储型XSS——为了防止玩家间XSS的相互污染,就必须想办法让彼此间的留言隔离,这就需要有用户名,为了保护玩家间的劳动成果,就得加上密码。但是admin(作为被盗取cookie的“管理员”)就必须能够让所有的留言在其面前全部展露,这样才能够“中招”。于是乎这个主页面要将admin与普通用户分开处理,而且很自然需要一个登录界面,主页面通过cookie来判断玩家是否就是那个玩家。
登录界面需要注册和登录两个主要的效果,而且不能存在其他漏洞。注册或是登陆之后直接通过账号密码生成一对cookies,维持一定时间,让玩家能够正常访问主页面。
然后是后台XSSBOT的处理了,这部分更为繁杂,暂时略过。
这张图应该讲得够清楚了……现在应该明白大致思路了吧。虽然看起来老牛逼了,实则还行——把这些碎片拼起来即可。
ps:那些检测的名字是我瞎取的,总之能懂意思就好。。
环境与web前端
由于我使用的是lemp/lnmp并且其中的PHP的版本是7.3.4的环境,所以就先找到了一个docker镜像,然后就对数据库的可视化操作就用phpMyadmin4.8.5
来搞了。如果够不幸的话,这个环境能把人配到怀疑人生。。这个不多说了,实在没啥好说的,网上鱼龙混杂的教程去看看就行。
本文主要侧重在web前端页面的编写上面。
在一切开始之前,我们先得建一个数据库,名字就叫做xss了,然后其中的表取名为lyb,列有id,name,password,ip,content,history这几项,其中的id是系统直接自增的,其中的password存储的是密码md5之后的值,其中的history是检验是否该条留言是新增项(也就是未检验过的,未检验为0,检验过为1)。之后需要将对应的数据库连上。直接上代码,不赘述——
1
2
3
4
5
6
7
8
$serve = 'localhost';
$dbusername = 'root';
$dbpassword = 'password';
$dbname = 'xss';
$link = mysqli_connect($serve,$dbusername,$dbpassword,$dbname);
mysqli_set_charset($link,'UTF-8'); // 设置数据库字符集首先需要编写一个登录的页面,这个页面功能是这样的——对那些已注册的用户名直接验证密码是否正确(对比存放在MySQL中的用户密码即可),如果正确直接登录并跳转至主页面,而且保留一个时长为1小时的cookie会话;而对于那些未注册的用户名,直接将其用户名与对应的密码保存到MySQL中,然后跳转至主页面并保留一个时长为1小时的cookie会话。另外为了保证安全,使用白名单判断,只允许使用数字和普通英文字母。同时,若存在正则匹配的情况,小心redos,不能让相关字符串太长而且要确保正则写对了。
login.php
具体代码(PHP7.3.4)如下: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<html>
<?php
require('conn.php');
?>
<p>请输入您的昵称与密码(密码建议强度大一点,两者长度在10以内,只限输入数字与普通字母),若事先不存在这个昵称将直接注册并生效。</p>
<form method="get" action="">
昵称:<input type="text" name="username" size="12">
密码:<input type="text" name="password" size="12">
<input type="submit" name="login" value="登录">
</form>
<?php
if(isset($_GET['login'])){
if($_GET['username']!=null && $_GET['password']!=null){
$re = "/^[a-zA-Z0-9]+$/u";
$username = $_GET['username'];
if (strlen($username) > 10) { //防一手redos
die("用户名过长==");
}
if (!preg_match($re, $username)) { //防一手注入
die("非法字符,只限输入数字与普通字母>_<");
}
$password = $_GET['password'];
if (strlen($password) > 10) {
die("密码过长==");
}
if (!preg_match($re, $password)) {
die("非法字符,只限输入数字与普通字母。>_<");
}
$content = '0';
$ip = $_SERVER['REMOTE_ADDR'];
$response = mysqli_query($link, "select name,password from lyb where name='$username'");
$p = mysqli_fetch_object($response);
if ($p) { //用户已经存在
if (($p->name == $username) && ($p->password == $password)) {
setcookie('user', $username, time() + 3600);
setcookie('passwd', md5($password), time() + 3600);
header("location:task.php");
} else
echo '用户名已被注册或是密码不正确==';
}
else {
mysqli_query($link, "insert into lyb (name,password,ip,content,history) values('$username','$password','$ip','$content','0')") or die ('执行失败');
setcookie('user', $username, time() + 3600);
setcookie('passwd', md5($password), time() + 3600);
header("location:task.php");
}
}
else {
echo "<p>不要空着哎!</p>";
}
}
?>
</html>ps:部分地方已做微小的修改,代码展示的是大致框架。
从这个登录界面我们能够看到它会跳转到对应的
task.php
,也就是主页面。这里的代码首先得判断用户是不是有cookie来维持其访问,其次需要展现出一个留言板的大致样子(其实可以更low一点hhh)。当然,还得有相关的题目的代码于其中——所以这里我依然只展示一部分而且是修改过后的结果。主页面
task.php
代码如下: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<!--STATUS OK--><html>
<?
require('conn.php');
$username=$_COOKIE["user"];
$password=$_COOKIE["passwd"];
$ip=$_SERVER['REMOTE_ADDR'];
$re = "/^[a-zA-Z0-9]+$/u"; //防止cookie注入
if (strlen($username) > 10) { //防一手redos
die("用户名过长==");
}
if (strlen($password) > 32) { //防一手redos
die("密码有问题==");
}
if (!preg_match($re, $username)) { //防一手注入
die("你..你想干嘛。QAQ快去好好登陆一下");
}
if (!preg_match($re, $password)) {
die("你..你想干嘛。QAQ快去好好登陆一下");
}
$response = mysqli_query($link, "select name,password from lyb where name='$username'");
$p = mysqli_fetch_object($response);
if ($p) { //用户已经存在
if (($p->name == $username) && (md5($p->password) == $password)) {
?>
//用户判断没毛病,
//这里面可以放一些代码作为题目。
<?}
else die('蛇皮操作,拜拜您呐。2333');
}
else die('用户不存在,拜拜您呐。');
if ($username==='admin') {
$result = mysqli_query($link, "SELECT * FROM lyb where history='0'");
mysqli_query($link,"UPDATE lyb SET history='1' where history='0'");
}
else
$result = mysqli_query($link,"SELECT * FROM lyb where name in('ps:某些用来提示的用户留言,或者没有也行','$username')");
?>
</form>
<head>
<script>
window.alert = function()
{
confirm("很好,你快稳了。");
}
</script>
</head>
<body>
<p></p>
<table border="1" width="100%">
<tr bgcolor="#e0e0e0">
<th>昵称</th>
<th>查看状态</th>
<th>内容</th>
</tr>
<?
while ($data=mysqli_fetch_assoc($result)) {
?>
<tr></tr>
<td><?=$data['name']?></td>
<td><?=$data['history']=='0'?'管理员admin还没看':'管理员admin已经来看过这条内容了';?></td>
<?
}
?>
</table>
</body>
</html>同上,只是展示一下思路,仅此而已。
代码就大概是这样了,一定要注意环境!!!而且php.ini
里面的配置也可能是非常令人窒息的(切记:必须short_open_tag=On)。。。
好了,下一步就是要实现那个XSSBOT了,它和这个主页面很可能会有所联系,如果到时候硬编写不出来的话可以想想在主页面上调整。。。还有,这个js和html全部在前端能够通过查看元素直接暴露出来,所以flag不可以放在这里面哦。。。如果你想尝试通过PHP判断来实现这样的操作的话,我觉得应该是不可能的,PHP貌似会先于js和html被解析。。。。这一点很重要,也就导致了后面对弹窗型的XSS判读需要另开一个页面。
制作XSSBOT
正如最开始的那张图所示,需要用XSSBOT自动登录admin账号来实现各种XSS的检测。由于检测的种类繁多,就逐个击破吧。
a. 实现弹窗型XSS的检测
本来打算直接通过Python的脚本实现,但是由于为了节约空间(Firefox和Chrome还需要安装对应的浏览器,放弃),就使用了selenium+phantomjs(无头浏览器)+python3
来实现爬虫,后来发现这种搭配方式直接检验弹窗貌似会出点问题,于是通过下面这样的思路来实现。
(emmm传入用户留言的内容这几个字错了,应该是传入用户名,然后在test.php中调出该用户所有未经判断的留言直接检验)
弹窗型XSS检测的js脚本在上一篇文章中已经提及。这里test.php
部分的检验还可以有其他的方法来实现,当然我觉得修改id的内容比较省事,关键是对后面的爬虫的衔接流畅一点。。
代码大致如下(也做了部分删改)——
先是Python3脚本的,与此部分无关的代码未出现,但在下一部分会全部给出。
1 | import selenium |
这个代码可能有点冗余的语句,但是我太菜了,就这样凑合了。。。
然后是test.php
的内容——
1 | <html> |
好了,自己看代码理解。应该没什么难度。
b. 实现其他三种XSS的检测
这里直接打包来说了。。。
钓鱼型检测
这个我没写代码,因为我懒。。。总之爬虫定位元素之后直接向弹窗中输入即可。
直接盗取cookie型检测
这个非常轻松,直接通过get即可。不管是用requests的get还是selenium自带的,都是可以的,但是一定得带上cookie去访问。代码在3中一笔带过。。
跳转盗取cookie型检测
我实现的方式有点奇怪,因为遇到坑,最终还是绕了。讲下思路——先利用admin的cookie信息去访问主页面(由于之前对admin的处理不同于普通用户,会加载上玩家的恶意代码,可能就会打开一个全新的页面),然后如果产生了新开页面,就直接
.switch_to_window()
去访问这个页面(这个页面能记录下cookie),这样就完成了一次被盗取——但是问题又来了,万一碰巧遇到好几个这样的新开页面同时开启的情况,怎么办?且看下面代码里的操作。这里本来有个大坑,由于我使用的是
selenium+phantomjs+python3
,有个.switch_to_window()
方法有个BUG,然后一直没有办法带上之前的一个网页保存着的cookie。最后不得不使用requests的get带着cookie头去跳转。。。虽然绕过了坑,但是感觉好蠢。。好了,上代码(有删改,部分无关的代码已出现在上一部分中)——
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
61import selenium from selenium
import webdriver
import time
import pymysql
import requests
phantomjs_path='/usr/local/src/phantomjs/bin/phantomjs'
conn=pymysql.connect(host='localhost',port=3306,user='root',passwd='password',db='xss')
browser=webdriver.PhantomJS(executable_path=phantomjs_path)
popups='http://127.0.0.1/test.php?name='
url='login的网址,可以通过get的方法传入admin的信息(然后自动跳转到主页面还带着cookie)'
while True:
try: #找到数据库中的所有新增项
conn.ping(reconnect=True) #防止掉数据库,重连数据库
cursor=conn.cursor()
cursor.execute("SELECT distinct name from lyb where history='0'")
remains=cursor.fetchall()
except:
print('No new data')
cursor.close()
try: #将新增项中的内容提交到另外一个简单的网页进行弹窗验证
for row in remains:
try:
browser.get(popups+row[0]) #将这个人所有最新提交信息导入新的一页来判断
flag=False
jtext=browser.find_element_by_id('flag').text
if (jtext=='hegottheflag'):
flag=True
if (flag): #验证有弹窗的话就直接在conent里面留下flag
content='One of your sentences caused pop-up windows. The flag in {} is flag123456.'
try:
conn.ping(reconnect=True) #防止掉数据库,重连数据库
cursor=conn.cursor()
sql="UPDATE lyb set content='%s' where name='%s' and history='0'"%(content,row[0])
cursor.execute(sql)
conn.commit() #提交修改
cursor.close()
except:
conn.rollback() #发生错误就回滚
print('something wrong...')
except:
print('this is useless...')
continue
except:
print('remain is empty...')
try: #通过登录admin账号提供cookie被窃的机会
browser.get(url) #登录admin,同时检验了直接盗取
now_handle=browser.current_window_handle
all_handles=browser.window_handles
for handle in all_handles: #遍历所有可能出现的恶意弹窗
if (handle!=now_handle):
browser.switch_to_window(handle)
jump=browser.current_url #记下弹窗的网址
s.get(jump,cookies=cookies) #使用admin的cookie登录
time.sleep(2)
except Exception as e:
print('[!]Error:'+str(e))
i=i+1
print(i)
time.sleep(57)
conn.close()
browser.quit()可以看到代码里面的requests,jump等部分是我绕坑才出现的。。。
代码写完了,然后测试几下hhhhh快吐了。。。简直就是在DEBUG。。。
最后docker打包收工。
编写Dockerfile
作为一个有点追求的人,嫌镜像的tar太大,就开始编写Dockerfile。随便讲一下dockerfile怎么写吧,毕竟这玩意儿也耗了我一个晚上外加一个早上,关键是环境装配麻烦,又是首次写dockerfile,菜死了。
Dockerfile关于这次题目的主要部分(不完全)的传入。
1 | COPY ./src/conn.php ./src/login.php ./src/task.php /app/public/ |
当然requirements.txt
中是(那个selenium版本不要随便改)
1 | selenium==2.48.0 |
另外还有一点作为速度方面的优化要调整一下镜像源(主要是环境太难了,我枯了)——
直接在Dockerfile
中这样跟上
1 | FROM ubuntu:18.04 |
以下是sources.list
的内容:
1 | deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse |
这些倒还好,关键是后面的XSSBOT怎么在容器一生成就会自启动。
我们也看到了dockerfile最后是ENTRYPOINT ["/docker-entrypoint.sh"]
,于是在这个docker-entrypoint.sh
文件中稍加一点/usr/bin/python3.6 /root/admin.py
,但是其添加的位置极其重要,一定要确保所有的依赖服务以及权限足够大了。
那些我都注意到了,但是在操作中遇到了非常奇怪的情况,就是添加了之后没有任何效果。太奇怪了,后来经过大佬指点发现了问题。看以下两者的区别即可。
1 | #Create mariadb user |
主要是我一开始不知道tail
的作用,后来一查发现,,,,,就知道后者的最后一个命令是不可能运行的(woc我裂开了)。
最后让我们看一下在最终的测试之前总共建了多少镜像555。。。
才能在readme.txt
上面轻描淡写地写下
1 | docker build -t xssweb:2.0 . |
怎么突然有点感慨。。