这篇文章发表于 719 天前,可能其部分内容已经发生变化,如有疑问可询问作者。

这次由于看到了一个点,觉得妙不可言,想要出成题。拖了几天之后最终还是行动了。。这次出题非常费劲,因为要完成的东西实在是太多了,而且自己也菜,更要命的是坑也非常多(还好是寒假,不过三四天时间就这样过去了)——对于坑,要么填上,要么就只能绕过,这也就导致了一些地方代码实现最终还是选择了绕弯。网上找不到这类完整的资料,就大多按照自己的想法来了,欢迎有人来喷。

(作者自己来批判了:本文思路可以看看,但是实现太复杂了,现在感觉js几句话就能解决……果然成长之后会觉得以前的自己很捞,其实现在也是…… —2021.10.14)

大致思路与流程

讲讲出这种题的思路流程:

  • 首先XSS需要一个主页面,也就是含有漏洞的页面。这个页面不能太弱,不应该包含其他的漏洞,只包含存储型XSS为最佳。

  • 有了这样一个主页面,而题目是存储型XSS——为了防止玩家间XSS的相互污染,就必须想办法让彼此间的留言隔离,这就需要有用户名,为了保护玩家间的劳动成果,就得加上密码。但是admin(作为被盗取cookie的“管理员”)就必须能够让所有的留言在其面前全部展露,这样才能够“中招”。于是乎这个主页面要将admin与普通用户分开处理,而且很自然需要一个登录界面,主页面通过cookie来判断玩家是否就是那个玩家。

  • 登录界面需要注册和登录两个主要的效果,而且不能存在其他漏洞。注册或是登陆之后直接通过账号密码生成一对cookies,维持一定时间,让玩家能够正常访问主页面。

  • 然后是后台XSSBOT的处理了,这部分更为繁杂,暂时略过。

    登录页面login

这张图应该讲得够清楚了……现在应该明白大致思路了吧。虽然看起来老牛逼了,实则还行——把这些碎片拼起来即可。

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
    <?php
    $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
    <!DOCTYPE html><!--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
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
import selenium
from selenium import webdriver
import time
import pymysql
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='
i=0

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...')
time.sleep(60)
conn.close()
browser.quit()

这个代码可能有点冗余的语句,但是我太菜了,就这样凑合了。。。

然后是test.php的内容——

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<html>
<p id="flag">none</p>
<?php
$name = $_GET['name'];
require('conn.php');
$back = mysqli_query($link, "select * from lyb where name='$name' and history='0'");
while ($data = mysqli_fetch_assoc($back)){

//这里放题目的源码。

}
mysqli_close($link);
?>
<script>
window.alert = function()
{
document.getElementById("flag").innerHTML = "hegottheflag";
}
</script>
</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
    61
    import 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
COPY ./src/conn.php ./src/login.php ./src/task.php /app/public/

COPY ./src/admin.py ./src/xss.sql ./src/phantomjs ./src/requirements.txt /root/

RUN chmod +x /root/phantomjs

RUN /bin/bash -c "/usr/bin/mysqld_safe --skip-grant-tables &" && \
sleep 5 && \
mysql -u root -e "CREATE DATABASE xss" && \
mysql -u root xss < /root/xss.sql

RUN apt-get update && \
apt-get -y install python3-pip && \
pip3 install -r /root/requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple some-package

ENTRYPOINT ["/docker-entrypoint.sh"]

当然requirements.txt中是(那个selenium版本不要随便改)

1
2
3
selenium==2.48.0
PyMySQL==0.9.3
requests==2.22.0

另外还有一点作为速度方面的优化要调整一下镜像源(主要是环境太难了,我枯了)——

直接在Dockerfile中这样跟上

1
2
3
FROM ubuntu:18.04

ADD sources.list /etc/apt/

以下是sources.list的内容:

1
2
3
4
5
6
7
8
9
10
deb http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-security main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-updates main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-proposed main restricted universe multiverse
deb-src http://mirrors.aliyun.com/ubuntu/ bionic-backports main restricted universe multiverse

这些倒还好,关键是后面的XSSBOT怎么在容器一生成就会自启动。

我们也看到了dockerfile最后是ENTRYPOINT ["/docker-entrypoint.sh"],于是在这个docker-entrypoint.sh文件中稍加一点/usr/bin/python3.6 /root/admin.py,但是其添加的位置极其重要,一定要确保所有的依赖服务以及权限足够大了。

那些我都注意到了,但是在操作中遇到了非常奇怪的情况,就是添加了之后没有任何效果。太奇怪了,后来经过大佬指点发现了问题。看以下两者的区别即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#Create mariadb user
mysql < /build.sql

#Run XSSBOT
/usr/bin/python3.6 /root/admin.py

#KEEP CONTAINER ALIVE
/usr/bin/tail -f /var/log/nginx/access.log
#Create mariadb user
mysql < /build.sql

#KEEP CONTAINER ALIVE
/usr/bin/tail -f /var/log/nginx/access.log

#Run XSSBOT
/usr/bin/python3.6 /root/admin.py

主要是我一开始不知道tail的作用,后来一查发现,,,,,就知道后者的最后一个命令是不可能运行的(woc我裂开了)。

最后让我们看一下在最终的测试之前总共建了多少镜像555。。。

太难了

才能在readme.txt上面轻描淡写地写下

1
2
3
4
docker build -t xssweb:2.0 .
镜像产生后,
docker run -d -p 30003:80 xssweb:2.0
即可

怎么突然有点感慨。。