Міжсайтовий скриптинг (XSS) — це вид кібератаки, при якій зловмисник вставляє шкідливий код у веб-сторінки, які переглядають інші користувачі. Цей код часто виконується в браузері жертви і може бути використаний для крадіжки даних, як-то сесійних токенів або іншої особистої інформації. XSS атаки класифікуються на збережені (або постійні), відображені (або відбиті), та DOM-базовані. Захист від XSS включає санітацію даних, обмеження виконання скриптів на стороні клієнта та використання політик безпеки змісту.
У статті ви знайдете детальні приклади атак міжсайтових сценаріїв (XSS) і різні стратегії їх пом’якшення. Розглядаються різні типи вразливостей XSS, як-от збережений XSS і відображений XSS, і показано, як можна виконати ці атаки. Вона також містить детальний огляд захисних методів, які можна застосувати для захисту веб-додатків від таких вразливостей. Це включає такі методи, як очищення даних, безпечні методи кодування та впровадження політик безпеки вмісту (CSP).
Дисклеймер: Ця стаття створена виключно з ознайомлювальною метою. У ній описано, як працюють фішингові атаки, щоб допомогти читачам зрозуміти їхню суть і навчитися захищатися від подібних загроз.
Поява цих можливостей призвела до того, що браузери не лише візуалізують HTML, а й вміщують у пам’яті як API для розробників уявлення, яке називається об’єктною моделлю документа (DOM). DOM пропонує деревоподібну структуру тегів HTML, а також доступ до файлів cookie для отримання стану. Згодом модель перетворилася з призначеної переважно для читання структури на структуру read-write, оновлення якої призводить до повторного рендерингу документа.
Як тільки документи отримали можливість запускати код, браузери мали визначити контекст виконання для програм на JavaScript. Політика, яка була розроблена, називається Same-Origin і, як і раніше, є одним з фундаментальних примітивів безпеки браузера. Спочатку в ній стверджувалося, що JavaScript в одному документі може отримати доступ тільки до власного DOM і DOM інших документів з тим самим походженням. Пізніше, коли додали XMLHttpRequest і Fetch , з’явилася модифікована версія Same-Origin . Ці API не можуть видавати запити до будь-якого джерела, вони можуть читати тільки відповідь на запити від того ж джерела.
Що таке походження? Це кортеж протоколу, імені хоста та порту документа.
Фрагмент 1: Кортеж із схеми, хоста та порту цієї URL-адреси.
https://www.example.com:443/app ^^^^^ ^^^^^^^^^^^^^^^ ^^^ Scheme Host Port
Same-Origin відмінно допомагає пом’якшувати атаки на статичні сайти, як показано на малюнку вище. Однак з атаками на динамічні ресурси, що приймають введення користувача, ситуація трохи складніше через змішування коду і даних, яка дозволяє зловмиснику виконувати контрольований введення у вихідному документі.
Атаки XSS зазвичай бувають трьох видів: рефлективними, збереженими та заснованими на DOM.
Рефлективні та збережені XSS-атаки принципово однакові, оскільки покладаються на шкідливе введення, що відправляється на бекенд і представляє це введення користувачеві сервер. Рефлективні XSS зазвичай виникають у вигляді зловмисно створеного зловмисником посилання, за яким потім переходить жертва. Збережені XSS відбуваються, коли зловмисник завантажує шкідливе введення. Атаки на основі DOM відрізняються тим, що вони відбуваються виключно на стороні клієнта та включають шкідливе введення, що маніпулює DOM.
Нижче можна побачити просте веб-додаток на Go, яке відображає своє введення (навіть якщо це шкідливий скрипт) назад користувачу. Ви можете використовувати цю програму, зберігши її у файлі xss1.go і запустивши go run xss1.go .
Фрагмент 2: приклад веб-програми з рефлективною (відбитою) XSS-атакою.
package main import ( "fmt" "log" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") messages, ok := r.URL.Query()["message"] if !ok { messages = []string{"hello, world"} } fmt.Fprintf(w, "<html><p>%v</p></html>", messages[0]) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
Щоб побачити XSS-атаку, перейдіть за вразливою URL-адресою нижче.
http://localhost:8080?message=<script>alert(1)</script>
Погляньте на джерело: сервер повернув документ, який виглядає приблизно так, як показано у фрагменті 4. Зверніть увагу, як змішання коду та даних дозволило статися цій атаці.
Фрагмент 3: Приклад виведення вразливої веб-програми XSS.
<html> <p> <script>alert(1)</script> </p> </html>
Цей приклад може здатися неправдоподібним, оскільки захист XSS був явно вимкнений. Ця її форма ґрунтується на евристиці з обхідними шляхами для різних браузерів. Вона була відключена для створення кросбраузерних прикладів, що ілюструють основні концепції XSS-атак. Деякі браузери видаляють цей захист: наприклад, у Google Chrome 78 і вище вам не знадобиться рядок w.Header().Set(“X-XSS-Protection”, “0”) , щоб атака спрацювала.
Збережені XSS-атаки схожі на рефлективні, але пейлоад надходить із сховища даних, а не із введення безпосередньо. Наприклад, зловмисник може завантажити на веб-додаток зловреда, який потім буде показаний кожному авторизованому користувачеві.
Нижче наведено простий чат, який ілюструє цей вид атак. Ви можете зберегти програму у файлі xss2.go і запустити за допомогою команди go run xss2.go.
Фрагмент 4: Зберігається XSS-атака.
package main import ( "fmt" "log" "net/http" "strings" "sync" ) var db []string var mu sync.Mutex var tmpl = ` <form action="/save"> Message: <input name="message" type="text"><br> <input type="submit" value="Submit"> </form> %v ` func saveHandler(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() r.ParseForm() messages, ok := r.Form["message"] if !ok { http.Error(w, "missing message", 500) } db = append(db, messages[0]) http.Redirect(w, r, "/", 301) } func viewHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "text/html; charset=utf-8") var sb strings.Builder sb.WriteString("<ul>") for _, message := range db { sb.WriteString("<li>" + message + "</li>") } sb.WriteString("</ul>") fmt.Fprintf(w, tmpl, sb.String()) } func main() { http.HandleFunc("/", viewHandler) http.HandleFunc("/save", saveHandler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
Щоб побачити атаку XSS, перейдіть за посиланням http://localhost:8080 та введіть повідомлення <script>alert(1);</script> .
payload зберігається в сховищі даних у функції storeHandler ;
Якщо сторінка візуалізується у ViewHandler , пейлоад додається до вихідних даних.
Нижче наведено приклад веб-програми , що обслуговує статичний контент . Код той же, що й у прикладі з рефлективними XSS, але атака буде відбуватися повністю на стороні клієнта. Ви можете зберегти програму у файлі xss3.go і запустити його командою go run xss3.go.
Фрагмент 5: Приклад веб-програми з XSS-атакою на основі DOM.
const content = ` <html> <head> <script> window.onload = function() { var params = new URLSearchParams(window.location.search); p = document.getElementById("content") p.innerHTML = params.get("message") }; </script> </head> <body> <p id="content"></p> </body> </html> ` func handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") fmt.Fprintf(w, content) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
Щоб побачити цю атаку, перейдіть за посиланням http://localhost:8080/?message=”<img src=1 onerror=alert(1);/>” . Зверніть увагу, що вектор атаки трохи відрізняється і innerHTML не буде виконувати скрипт безпосередньо, проте він додасть HTML-елементи, які потім виконають код JavaScript. У наведеному прикладі додається елемент image, який запускає скрипт у разі помилки (вона завжди з’являється, оскільки зловмисник підставляє неправильне джерело).
Якщо потрібно безпосередньо додати елемент скрипту, доведеться використовувати інший приймач XSS. Замініть елемент script з фрагмента 6 на елемент script з фрагмента 7 і перейдіть за наступним посиланням: http://localhost:8080/?message=”<script>alert(1);</script>” . Атака спрацює, тому що document.write приймає елементи прямого скрипту.
Фрагмент 6 Ще один приклад атаки XSS на основі DOM.
<script> window.onload = function() { var params = new URLSearchParams(window.location.search); document.open(); document.write(params.get("message")); document.close(); }; </script>
В усьому винні неправильне налаштування типу вмісту відповідей HTTP. Це може статися як на рівні бекенда (відповідь має невірний набір заголовків Content-Type ), так і при спробі браузера просніферити тип MIME. Internet Explorer був особливо сприйнятливий до цього, і класичним прикладом є служба завантаження зображень: зловмисник може завантажити JavaScript замість зображення. Браузер бачить, що тип контенту було встановлено на image/jpg , але пейлоад містить скрипт – він виконується, що призводить до атаки XSS.
Наступний тип атаки – активність через URL із схемою JavaScript. Представимо веб-сайт, який дозволяє користувачеві контролювати мету посилання, як показано у фрагменті 8. У цьому випадку зловмисник зможе надати URL, який виконує JavaScript за допомогою нашої схеми.
Щоб випробувати цей тип атаки, можна зберегти програму у файлі xss4.go , запустити командою go run xss4.go та перейти за посиланням http://localhost:8080?link=javascript:alert(1) .
Фрагмент 7: XSS-атака, введена через схему URL-адрес.
package main import ( "fmt" "log" "net/http" ) func handler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") links, ok := r.URL.Query()["link"] if !ok { messages = []string{"example.com"} } fmt.Fprintf(w, `<html><p><a href="%v">Next</p></html>`, links[0]) } func main() { http.HandleFunc("/", handler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
Єдиного методу вирішення цієї проблеми не існує, інакше XSS не був би такою поширеною проблемою. Фундаментальна складність викликана відсутністю поділу між кодом та даними. Пом’якшення наслідків XSS зазвичай включає очищення вхідних даних (потрібно переконатися, що вони не містять коду), екранування вихідних даних (вони також не повинні містити код) і реструктуризацію програми таким чином, щоб код завантажувався з певних кінцевих точок.
Валідація даних – складна проблема. Не існує універсального інструменту чи техніки для всіх ситуацій. Найкраще структурувати додаток таким чином, щоб він вимагав від розробників продумати тип даних і забезпечити зручне місце, де можна розмістити валідатор.
Хороший тон написання програм на Go полягає в тому, щоб не мати жодної логіки програми в обробниках запитів HTTP, а натомість використовувати їх для аналізу та перевірки вхідних даних. Потім дані вирушають у обробну логіку структуру. Обробники запитів стають простими і забезпечують зручне централізоване розташування контролю правильності очищення даних. На фрагменті 8 показано, як можна переписати saveHandler для прийому символів ASCII [A-Za-z\.]
Фрагмент 8: приклад використання обробників HTTP-запитів для перевірки даних.
func saveHandler(w http.ResponseWriter, r *http.Request) { r.ParseForm() messages, ok := r.Form["message"] if !ok { http.Error(w, "missing message", 500) } re := regexp.MustCompile(`^[A-Za-z\\.]+$`) if re.Find([]byte(messages[0]))) == "" { http.Error(w, "invalid message", 500) } db.Append(messages[0]) http.Redirect(w, r, "/", 301) }
Може здатися, що це зайве занепокоєння, але чат-програма приймає набагато більше, ніж обмежений набір символів. Багато даних, що приймаються додатками, досить структуровані: адреси, номери телефонів, поштові індекси і тому подібні речі можуть і повинні бути перевірені.
Наступний крок – екранування виводу. У випадку з нашим чатом, все вилучене з бази даних включалося безпосередньо у вихідний документ.
Один і той же додаток може бути набагато безпечнішим (навіть якщо в нього була зроблена ін’єкція коду), якщо екранувати всі небезпечні вихідні дані. Саме це робить пакет html/template у Go. Використання мови шаблонів та контекстно-залежного синтаксичного аналізатора для екранування даних до їх візуалізації зменшить ймовірність виконання шкідливого коду.
Нижче наведено приклад використання пакета html/template. Збережіть програму у файлі xss5.go , а потім виконайте командою go run xss5.go.
Фрагмент 9 : Використання екранування для усунення збережених XSS-атак.
package main import ( "bytes" "html/template" "io" "log" "net/http" "sync" ) var db []string var mu sync.Mutex var tmpl = ` <form action="/save"> Message: <input name="message" type="text"><br> <input type="submit" value="Submit"> </form> <ul> {{range .}} <li>{{.}}</li> {{end}} </ul>` func saveHandler(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() r.ParseForm() messages, ok := r.Form["message"] if !ok { http.Error(w, "missing message", 500) } db = append(db, messages[0]) http.Redirect(w, r, "/", 301) } func viewHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "text/html; charset=utf-8") t := template.New("view") t, err := t.Parse(tmpl) if err != nil { http.Error(w, err.Error(), 500) return } var buf bytes.Buffer err = t.Execute(&buf, db) if err != nil { http.Error(w, err.Error(), 500) return } io.Copy(w, &buf) } func main() { http.HandleFunc("/", viewHandler) http.HandleFunc("/save", saveHandler) log.Fatal(http.ListenAndServe("127.0.0.1:8080", nil)) }
Спробуйте використану атаку XSS, перейшовши за посиланням http://localhost:8080 і введіть <script>alert(1);</script> . Зауважте, що попередження не було викликано.
Відкрийте консоль браузера і подивіться елемент li в DOM. Інтерес представляють дві властивості: innerHTML і innerText .
Фрагмент 10: Перевірка DOM під час використання екранування.
innerHTML: "<script>alert(1);</script>" innerText: "<script>alert(1);</script>"
Зверніть увагу, як за допомогою екранування вдалося чітко розділити код та дані.
Content Security Policy (CSP) дозволяє веб-застосункам визначати набір довірених джерел для завантаження контенту (наприклад, скриптів). CSP можна використовувати для поділу коду та даних, відмовляючись від вбудованих скриптів та завантажуючи їх лише з певних джерел.
Написання CSP для невеликих автономних програм є простим завданням – почніть із політики, яка за замовчуванням забороняє всі джерела, а потім дозвольте невеликий набір. Однак, написати ефективний CSP для великих сайтів вже не так просто. Як тільки сайт починає завантажувати контент із зовнішніх джерел, CSP роздмухується і стає громіздким. Деякі розробники здаються і включають директиву unsafe-inline повністю руйнуючи теорію CSP.
Щоб спростити написання CSP, CSP3 вводиться директива strict-dynamic . Замість того, щоб підтримувати великий білий список надійних джерел, програма генерує випадкове число (nonce) щоразу, коли запитується сторінка. Цей nonce відправляється разом із заголовками сторінки та вбудований у тег script, що змушує браузери довіряти цим скриптам з відповідним nonce, а також будь-яким скриптам, які вони можуть завантажити. Замість того, щоб вносити скрипти в білий список і намагатися з’ясувати, які сценарії вони завантажують, а потім поповнювати білий список рекурсивно, вам потрібно достатньо внести в білий список імпортований скрипт верхнього рівня.
Використовуючи запропонований Google підхід Strict CSP , розглянемо просте додаток, що приймає введення користувача. Збережіть його у файлі xss6.go , а потім виконайте командою go run xss6.go.
Фрагмент 11: Приклад CSP, що пом’якшує XSS-атаку.
package main import ( "bytes" "crypto/rand" "encoding/base64" "fmt" "html/template" "log" "net/http" "strings" ) const scriptContent = ` document.addEventListener('DOMContentLoaded', function () { var updateButton = document.getElementById("textUpdate"); updateButton.addEventListener("click", function() { var p = document.getElementById("content"); var message = document.getElementById("textInput").value; p.innerHTML = message; }); }; ` const htmlContent = ` <html> <head> <script src="script.js" nonce="{{ . }}"></script> </head> <body> <p id="content"></p> <div class="input-group mb-3"> <input type="text" class="form-control" id="textInput"> <div class="input-group-append"> <button class="btn btn-outline-secondary" type="button" id="textUpdate">Update</button> </div> </div> <blockquote class="twitter-tweet" data-lang="en"> <a href="https://twitter.com/jack/status/20?ref_src=twsrc%5Etfw">March 21, 2006</a> </blockquote> <script async src="https://platform.twitter.com/widgets.js" nonce="{{ . }}" charset="utf-8"></script> </body> </html> ` func generateNonce() (string, error) { buf := make([]byte, 16) _, err := rand.Read(buf) if err != nil { return "", err } return base64.StdEncoding.EncodeToString(buf), nil } func generateHTML(nonce string) (string, error) { var buf bytes.Buffer t, err := template.New("htmlContent").Parse(htmlContent) if err != nil { return "", err } err = t.Execute(&buf, nonce) if err != nil { return "", err } return buf.String(), nil } func generatePolicy(nonce string) string { s := fmt.Sprintf(`'nonce-%v`, nonce) var contentSecurityPolicy = []string{ `object-src 'none';`, fmt.Sprintf(`script-src %v 'strict-dynamic';`, s), `base-uri 'none';`, } return strings.Join(contentSecurityPolicy, " ") } func scriptHandler(w http.ResponseWriter, r *http.Request) { nonce, err := generateNonce() if err != nil { returnError() return } w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "application/javascript; charset=utf-8") w.Header().Set("Content-Security-Policy", generatePolicy(nonce)) fmt.Fprintf(w, scriptContent) } func htmlHandler(w http.ResponseWriter, r *http.Request) { nonce, err := generateNonce() if err != nil { returnError() return } w.Header().Set("X-XSS-Protection", "0") w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Content-Security-Policy", generatePolicy(nonce)) htmlContent, err := generateHTML(nonce) if err != nil { returnError() return } fmt.Fprintf(w, htmlContent) } func returnError() { http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) } func main() { http.HandleFunc("/script.js", scriptHandler) http.HandleFunc("/", htmlHandler) log.Fatal(http.ListenAndServe(":8080", nil)) }
Щоб спробувати використати програму, перейдіть за посиланням: http://localhost:8080 і спробуйте відправити <img src=1 onerror”alert(1)”/> як і раніше. Ця атака спрацювала б і без CSP, але оскільки CSP не допускає inline-скриптів, ви повинні побачити приблизно такий висновок у консолі браузера:
«Відмовлено у виконанні вбудованого обробника подій, оскільки він порушує наступну директиву CSP: “script-src ‘nonce-XauzABRw9QtE0bzoiRmslQ==’ ‘unsafe-inline’ ‘unsafe-eval’ ‘strict-dynamic’ https: http: ” ‘ unsafe-inline ‘ ігнорується, якщо у вихідному списку є або хеш, або значення nonce.»
Частина 12: Базовий CSP. Nonce генерується повторно для кожного запиту
script-src 'strict-dynamic' 'nonce-XauzABRw9QtE0bzoiRmslQ=='; object-src 'none'; base-uri 'none';
Основна складність використання цього підходу полягає в необхідності генерувати nonce та інжектити його в заголовки при кожному завантаженні сторінки. Після цього шаблон може бути застосований до всіх сторінок, що завантажуються.
Ви повинні не тільки встановлювати свій Content-Type, але й стежити, щоб браузери не намагалися автоматично визначити тип контенту. Для цього використовуйте заголовок : X-Content-Type-Options: nosniff .
Хоча віртуальні домени не є функцією безпеки, їх сучасні фреймворки ( React і Vue ) можуть допомогти пом’якшити атаки XSS на основі DOM .
Ці фреймворки створюють DOM паралельно з тим, що знаходиться в браузері, та порівнюють їх. Відмінну частину DOM браузера вони оновлюють. Для цього необхідно створити віртуальний DOM, що призведе до зменшення використання клієнтами innerHTML та підштовхне розробників до переходу на innerText .
React вимагає використання атрибуту dangerouslySetInnerHTML , тоді як творці Vue попереджають , що використання innerHTML може призвести до появи вразливостей.
Якщо ви дочитали до кінця, у вас може з’явитися бажання розібратися, як працюють браузери, що таке помилки XSS і наскільки важливо розуміти, як їх позбутися. XSS важко викоренити, оскільки програми стають все більшими і все складнішими. Застосовуючи згадані у статті методи, можна зробити життя зловмисників важким.