Compare commits

...

4 commits

5 changed files with 84 additions and 91 deletions

3
.prettierrc.json Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 100
}

View file

@ -1 +1,2 @@
- [Deta Space](https://deta.space)
- [Fly.io](https://fly.io/)

View file

@ -0,0 +1,59 @@
## Crear certificados no autorizados
Es posible crear certificados para "nombres" (dominios o IPs) que no te pertenecen de distintas maneras. De esta manera, podés interceptar conexiones y así romper TLS (la seguridad a nivel "transporte"), la base de toda la seguridad de la Web.
Pero ¿como? Primero hay que entender como funciona la "infraestructura de llaves pública" (PKI, Public Key Infrastructure).
Las "autoridades de certificados" son las organizaciones que verifican los dominios y otorgan certificados válidos a quienes le pertenecen. Los sistemas operativos y los navegadores confían en una lista de autoridades de distintos países y distintos estados (con y sin fines de lucros, etc). Si se duda la confianza de estas, se les saca de las listas y los certificados que otorgan paran de ser válidos (esto es importante más abajo :).
La forma que se verifica que "sos el nombre que decís" es distinta de cada autoridad. La forma más "moderna" y la que solemos usar es un sistema de verificación y otorgación **automático** llamado "[ACME]" (Automatic Certificate Management Environment). Esta fue desarrollada por Let's Encrypt, pero luego fue implementada por otros servicios. Acá solo voy a dar ejemplos de vulnerabilidades en este sistema, pero seguro existen otros en las otras autoridades con sus propios métodos.
ACME verifica a través de distintos "métodos", los más comúnes siendo `http-01` y `dns-01`.
1. `http-01` busca tu dominio en DNS **generalmente desde varios servidores**[[1]](https://letsencrypt.org/docs/challenge-types/) (esto es importante después) y hace un pedido al servidor en esa IP. Si todos los pedidos contestan con la llave compartida que te dijo ACME, estás verificadx, y obtenes un certificado.
2. `dns-01` busca tu dominio en DNS y busca la llave compartida en el registro de DNS mismo.
`dns-01` generalmente es más seguro porque no confía en la IP registrada en el DNS en si, sino solo en el registro mismo (del que confias de todas maneras en `http-01`).
Lo que pasa es que es posible interceptar conexiones entre los servidores de la autoridad y los del dominio verificado:
Por ejemplo, al momento de escribir esto nulo.ar está hosteado bajo mi conexión hogareña de Personal. Alguien dentro de Personal, o un hacker podría interceptar conexiones a mi dirección IP y hacer pedidos de certificados para nulo.ar. Esto sería posible sin tener que comprometer a NIC Argentina ni a Hurricane Electric, que manejan la cadena de DNS de mi dominio.
Sin ir a la conspiración, al ser una conexión hogareña Personal no me asigna una IP "estática" a mi, sino que es "dinámica". Esto significa que aleatoriamente, Personal puede decidir cambiarme de IP y darsela a otra persona. Tengo [un programa](https://gitea.nulo.in/Nulo/ddnser) que automáticamente actualiza el registro de DNS de nulo.ar para apuntar a la dirección actual, pero este puede fallar y de todas maneras corre cada ~5 minutos. En ese tiempo, lx otrx cliente que obtiene la IP podría hacer este mismo ataque.
Este es un ejemplo específico, pero es un ataque viable, y de hecho ya ejecutado. Abajo comento de un caso en el que se hace [BGP hijacking][BGP hijacking - Wikipedia] para interceptar las conexiones de un servidor, crear un certificado y insertar malware en el sitio para robar 1,9 millones de dolares.
Otro ataque es más "simple" pero requiere "una conspiración mayor" para ejecutar: que una autoridad de certificados comprometida cree un certificado bajo el nombre de nulo.ar sin verificación. Esto [ya](https://www.techrepublic.com/article/compromised-certificate-authorities-how-to-protect-yourself/) [pasó](https://blog.mozilla.org/security/2011/03/25/comodo-certificate-issue-follow-up/) [varias](https://www.mail-archive.com/dev-security-policy@lists.mozilla.org/msg05455.html) [veces](https://bugzilla.mozilla.org/show_bug.cgi?id=1496088), muchas veces por errores de la autoridad en su software. sslmate.com mantiene [una lista](https://sslmate.com/resources/certificate_authority_failures) con muchos incidentes de esta índole de las autoridades.
Previamente, la única forma de verificar que una autoridad había falsificado un certificado era obteniendo el certificado falsificado y compartiendolo. Esto es problematico porque un ataque teorico podría solo utilizar los certificados falsificados a las victimas a las que se quieren interceptar específicamente.
Sin embargo, hoy en día los navegadores (en Google Chrome desde 2018[[2]](https://groups.google.com/a/chromium.org/g/ct-policy/c/wHILiYf31DE/m/iMFmpMEkAQAJ)) requieren que los certificados estén registrados en un registro de transparencia ([Certificate Transparency - Wikipedia](https://en.wikipedia.org/wiki/Certificate_Transparency)). De esta manera, se puede inspeccionar estos registros públicos por certificados inusuales. [crt.sh](https://crt.sh/) es un sitio que permite acceder a estos registros fácilmente.
[ACME]: https://en.wikipedia.org/wiki/Automatic_Certificate_Management_Environment
### Casos
- > February 2022: Attackers hijacked BGP prefixes that belonged to a South Korean cryptocurrency platform, and then issued a certificate on the domain via ZeroSSL to serve a malicious JavaScript file, stealing $1.9 million worth of cryptocurrency.[[31]](https://therecord.media/klayswap-crypto-users-lose-funds-after-bgp-hijack)
de [BGP hijacking - Wikipedia](https://en.wikipedia.org/w/index.php?title=BGP_hijacking&oldid=1146362657)
[BGP hijacking - Wikipedia]: https://en.wikipedia.org/index.php?title=BGP_hijacking
### Soluciones o mitigaciones
#### Monitorear Certificate Transparency
Esto nos deja saber si se sacaron certificados inautorizados, pero no nos deja prevenirlos. Propuesta en Sutty: [Monitorear Certificate Transparency logs revisando que solo haya certificados nuestros](https://0xacab.org/sutty/sutty/-/issues/8880)
#### Restringir metodos de verificación via registros CAA
Gracias a [RFC8657] <small>Certification Authority Authorization (CAA) Record Extensions for Account URI and Automatic Certificate Management Environment (ACME) Method Binding</small>, podemos pedirles a los servidores de expedición de certificados que solo saquen certificados bajo ciertos proveedores y métodos. Esto nos permite limitar la expedición a un proveedor y al método `dns-01`. De esta manera, aún si pueden interceptar las conexiones a nuestra IP (via BGP hijacking o comprometiendo a nuestro ISP o...) no pueden generar certificados (tendrían que hackear nuestro DNS).
Let's Encrypt lo implementó en su entorno de prueba [en 2018] y en producción [en diciembre 2022]. Osea: ¡ya lo podemos usar!
[en 2018]: https://community.letsencrypt.org/t/acme-caa-validationmethods-support/63125
[en diciembre 2022]: https://community.letsencrypt.org/t/enabling-acme-caa-account-and-method-binding/189588
GrapheneOS [usa esto](https://github.com/GrapheneOS/ns1.grapheneos.org/blob/bc06ac067c5786180cceccdf8466b0a94a1a7e5c/zones.yaml#LL23C65-L23C65) pero usa http-01. Esto reduce las posibilidades de ataque (al menos solo tenés que confiar en Let's Encrypt) pero no es perfecto.
[RFC8657]: https://datatracker.ietf.org/doc/html/rfc8657

View file

@ -1,14 +1,5 @@
import {
copyFile,
mkdir,
opendir,
readFile,
readdir,
writeFile,
} from "fs/promises";
import { copyFile, mkdir, opendir, readFile, readdir, writeFile } from "fs/promises";
import { basename, extname, join } from "path";
import { execFile as execFileCallback } from "child_process";
import { promisify } from "util";
import * as commonmark from "commonmark";
import {
a,
@ -34,8 +25,6 @@ import {
img,
} from "@nulo/html.js";
const execFile = promisify(execFileCallback);
const reader = new commonmark.Parser({ smart: true });
const writer = new commonmark.HtmlRenderer({ safe: false, smart: true });
@ -68,19 +57,7 @@ for (const entry of dir) {
const { name } = entry;
const extension = extname(name);
if (
[
".ts",
".md",
".css",
".js",
".png",
".jpg",
".mp4",
".svg",
".html",
].includes(extension)
) {
if ([".ts", ".md", ".css", ".js", ".png", ".jpg", ".mp4", ".svg", ".html"].includes(extension)) {
await copyFile(join(config.sourcePath, name), join(config.buildPath, name));
}
@ -100,10 +77,7 @@ async function compilePage(config: Config, sourceFileName: string) {
let contentHtml, image;
if (extname(sourceFileName) === ".md") {
({ html: contentHtml, image } = await compileMarkdownHtml(
config,
sourceFileName
));
({ html: contentHtml, image } = await compileMarkdownHtml(config, sourceFileName));
} else if (sourceFileName.endsWith(".gen.js"))
contentHtml = await compileJavascript(config, sourceFileName);
else throw false;
@ -112,14 +86,7 @@ async function compilePage(config: Config, sourceFileName: string) {
...generateHead(title, name),
article(
{ itemscope: "", itemtype: "https://schema.org/Article" },
...(isIndex
? []
: generateHeader(
name,
sourceFileName,
fileConnections.length > 0,
image
)),
...(isIndex ? [] : generateHeader(name, sourceFileName, fileConnections.length > 0, image)),
main({ itemprop: "articleBody" }, raw(contentHtml)),
...generateConnectionsSection(fileConnections)
)
@ -142,10 +109,7 @@ async function compileMarkdownHtml(
config: Config,
sourceFileName: string
): Promise<{ html: string; image?: Image }> {
let markdown = await readFile(
join(config.sourcePath, sourceFileName),
"utf-8"
);
let markdown = await readFile(join(config.sourcePath, sourceFileName), "utf-8");
let image;
if (markdown.startsWith("!!")) {
@ -153,8 +117,7 @@ async function compileMarkdownHtml(
const imageNode = node.firstChild?.firstChild;
if (!imageNode || !imageNode.destination)
throw new Error("Intenté parsear un ^!! pero no era una imágen");
if (!imageNode.firstChild?.literal)
console.warn(`El ^!! de ${sourceFileName} no tiene alt`);
if (!imageNode.firstChild?.literal) console.warn(`El ^!! de ${sourceFileName} no tiene alt`);
image = {
src: imageNode.destination,
@ -171,10 +134,7 @@ async function compileMarkdownHtml(
return { html: contentHtml, image };
}
async function compileJavascript(
config: Config,
sourceFileName: string
): Promise<string> {
async function compileJavascript(config: Config, sourceFileName: string): Promise<string> {
const fn = await import("./" + join(config.sourcePath, sourceFileName));
return await fn.default();
}
@ -192,34 +152,13 @@ function generateHead(titlee: string, outputName: string): Renderable[] {
name: "viewport",
content: "width=device-width, initial-scale=1.0",
}),
meta({
name: "author",
content: "Nulo",
}),
meta({
property: "og:title",
content: titlee,
}),
meta({
property: "og:type",
content: "website",
}),
meta({
property: "og:url",
content: `https://nulo.ar/${outputName}.html`,
}),
meta({
property: "og:image",
content: "cowboy.svg",
}),
link({
rel: "stylesheet",
href: "drip.css",
}),
link({
rel: "icon",
href: "cowboy.svg",
}),
meta({ name: "author", content: "Nulo" }),
meta({ property: "og:title", content: titlee }),
meta({ property: "og:type", content: "website" }),
meta({ property: "og:url", content: `https://nulo.ar/${outputName}.html` }),
meta({ property: "og:image", content: "cowboy.svg" }),
link({ rel: "stylesheet", href: "drip.css" }),
link({ rel: "icon", href: "cowboy.svg" }),
title(titlee),
];
}
@ -239,10 +178,7 @@ interface Dateish {
day: number;
}
function dateishToString({ year, month, day }: Dateish): string {
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(
2,
"0"
)}`;
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
}
type TitleMetadata =
@ -253,8 +189,7 @@ type TitleMetadata =
}
| { date: Dateish };
function parseName(name: string): TitleMetadata {
const titleWithDate =
/^((?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}))? ?(?<title>.*)$/;
const titleWithDate = /^((?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}))? ?(?<title>.*)$/;
const found = name.match(titleWithDate);
if (!found || !found.groups) throw new Error("Algo raro pasó");
@ -338,16 +273,12 @@ function generateHeader(
},
"Historial"
),
...(linkConexiones
? [" / ", a({ href: "#conexiones" }, "Conexiones")]
: [])
...(linkConexiones ? [" / ", a({ href: "#conexiones" }, "Conexiones")] : [])
),
];
}
function generateConnectionsSection(
fileConnections: Connection[]
): Renderable[] {
function generateConnectionsSection(fileConnections: Connection[]): Renderable[] {
return fileConnections.length > 0
? [
section(
@ -408,9 +339,7 @@ async function hackilyTransformHtml(html: string): Promise<string> {
html = html
.replaceAll("<a h", '<a rel="noopener noreferrer" h')
.replaceAll(wikilinkExp, (_, l) => render(internalLink(l)));
for (const [match, archivo] of html.matchAll(
/<nulo-sitio-reemplazar-con archivo="(.+?)" \/>/g
)) {
for (const [match, archivo] of html.matchAll(/<nulo-sitio-reemplazar-con archivo="(.+?)" \/>/g)) {
if (!archivo.endsWith(".gen.js")) throw false;
html = html.replace(match, await compileJavascript(config, archivo));
}

View file

@ -7,6 +7,7 @@
"esModuleInterop": true,
"strict": true,
"allowJs": true,
"checkJs": true
"checkJs": true,
"noEmit": true
}
}