898 字
4 分钟
N1CTF_Junior_2025_2
2025-09-15

WEB#

Ping#

挺有意思的一道题
首先出题人是提供了源码的, 核心代码就是下面这些

def run_ping(ip_base64):
    try:
        decoded_ip = base64.b64decode(ip_base64).decode('utf-8')
        if not re.match(r'^\d+\.\d+\.\d+\.\d+$', decoded_ip):
            return False
        if decoded_ip.count('.') != 3:
            return False
        
        if not all(0 <= int(part) < 256 for part in decoded_ip.split('.')):
            return False
        if not ipaddress.ip_address(decoded_ip):
            return False
        if len(decoded_ip) > 15:
            return False
        if not re.match(r'^[A-Za-z0-9+/=]+$', ip_base64):
            return False
    except Exception as e:
        return False
    command = f"""echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh"""

    try:
        process = subprocess.run(
            command,
            shell=True,
            check=True,
            capture_output=True,
            text=True
        )
        return process.stdout
    except Exception as e:
        return False

基本上就是, 接收用户传入的一个字符串, 然后使用base64.b64decode(ip_base64).decode('utf-8')对这个字符串进行解码, 然后对解码后的结果进行各种检查. 如果通过了检查, 就将这个字符串放入echo "ping -c 1 $(echo '{ip_base64}' | base64 -d)" | sh里, 执行这个命令
可以看到, 检查确实是比较严格的, 基本上不可能直接绕过判断插入恶意代码
这里其实是要利用默认配置下base64.b64decode()与 Linux base64 的行为的不一致性
直接看看base64.b64decode()

# base64.py
...
def b64decode(s, altchars=None, validate=False):
    s = _bytes_from_decode_data(s)
    if altchars is not None:
        altchars = _bytes_from_decode_data(altchars)
        assert len(altchars) == 2, repr(altchars)
        s = s.translate(bytes.maketrans(altchars, b'+/'))
    return binascii.a2b_base64(s, strict_mode=validate)
...

注意, 默认情况下validate = False
跟进binascii.a2b_base64(s, strict_mode=validate)

// binascii_a2b_base64_impl() in binascii.c
binascii_a2b_base64_impl(PyObject *module, Py_buffer *data, int strict_mode)
{
    ...
    if (quad_pos >= 2 && quad_pos + ++pads >= 4) {
        // 当前四字符组已经处理了至少2个有效字符且有效字符数 + 填充字符数 >= 4
        if (strict_mode && i + 1 < ascii_len) {
            // 如果开启了严格模式,并且当前字符后面还有未处理的字符
            state = get_binascii_state(module);
            if (state) {
                PyErr_SetString(state->Error, "Excess data after padding");
                // 抛出“填充字符后有多余字符”的异常
            }
            goto error_end;
            // 跳转到错误处理代码
        }

        goto done;
        // 跳转到正常结束代码
    }
    ...
}

这里的 strict_mode 就是 validate
在默认配置下, 也就是validate = False的情况下, python 的 base64.b64decode()不会处理填充符号后的其他字符, 因为在判断字符组已经处理了至少2个有效字符且有效字符数 + 填充字符数 >= 4 之后他直接 goto done 跳出了
也就是说

import base64

print(base64.b64decode("MS4xLjEuMQ==").decode('utf-8'))
print(base64.b64decode("MS4xLjEuMQ==dGVzdA==").decode('utf-8'))

# 1.1.1.1
# 1.1.1.1

但是, Linux base64 默认情况下会处理填充符号后的其他字符

echo 'MS4xLjEuMQ==' | base64 -d
echo 'MS4xLjEuMQ==dGVzdA==' | base64 -d

# 1.1.1.1
# 1.1.1.1test

利用这个差异分别 base64 编码某个ip地址 (编码后需要含有=填充符) 和要执行的命令, 然后拼接在一起即可 RCE alt text

online_unzipper#

源码:

import ...

app = Flask(__name__)
app.secret_key = os.environ.get("FLASK_SECRET_KEY", "test_key")
UPLOAD_FOLDER = os.path.join(os.getcwd(), "uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

users = {}

...

@app.route("/upload", methods=["GET", "POST"])
def upload():
    if "username" not in session:
        return redirect(url_for("login"))

    if request.method == "POST":
        file = request.files["file"]
        if not file:
            return "未选择文件"

        role = session["role"]

        if role == "admin":
            dirname = request.form.get("dirname") or str(uuid.uuid4())
        else:
            dirname = str(uuid.uuid4())

        target_dir = os.path.join(UPLOAD_FOLDER, dirname)
        os.makedirs(target_dir, exist_ok=True)

        zip_path = os.path.join(target_dir, "upload.zip")
        file.save(zip_path)

        try:
            os.system(f"unzip -o {zip_path} -d {target_dir}")
        except:
            return "解压失败,请检查文件格式"

        os.remove(zip_path)
        return f"解压完成!<br>下载地址: <a href='{url_for('download', folder=dirname)}'>{request.host_url}download/{dirname}</a>"

    return render_template("upload.html")

@app.route("/download/<folder>")
def download(folder):
    target_dir = os.path.join(UPLOAD_FOLDER, folder)
    if not os.path.exists(target_dir):
        abort(404)

    files = os.listdir(target_dir)
    return render_template("download.html", folder=folder, files=files)

@app.route("/download/<folder>/<filename>")
def download_file(folder, filename):
    file_path = os.path.join(UPLOAD_FOLDER, folder, filename)
    try:
        with open(file_path, 'r') as file:
            content = file.read()
        return Response(
            content,
            mimetype="application/octet-stream",
            headers={
                "Content-Disposition": f"attachment; filename={filename}"
            }
        )
    except FileNotFoundError:
        return "File not found", 404
    except Exception as e:
        return f"Error: {str(e)}", 500

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

稍微记一下思路:
先软链接读/proc/self/environFLASK_SECRET_KEY, 伪造admin的session重新登陆, 配置 dirname = xxx/../ , 然后再传个包含到根目录的软链接的压缩包, 解压后利用@app.route("/download/<folder>")这个路由列出根目录各文件的文件名, 拿到文件名后即可利用@app.route("/download/<folder>/<filename>")这个路由下载flag

Peek a Fork#

TODO

Unfinished#

TODO

safenotes#

TODO

N1CTF_Junior_2025_2
https://blog.asteriax.site/posts/n1ctf_junior_2025_2/
作者
ASTERIAX
发布于
2025-09-15
许可协议
CC BY-NC-SA 4.0