Este informe se centra en realizar un estudio de los tipos de servidores que estan ofreciendo respuesta por el puerto 80, el puerto web por defecto.
En el transcurso del proyecto correlacionando distintos dataframes, se han obtenido datos valiosos para realizar el análisis como: geolocalización de cada uno de los servidores, versión de http con que se están comunicando, repuesta del servidor, tipo de servidor y su versión, cpe's, cve's, vulnerabilidades que les aplican y PIB de los países.
Todos los datos han sido obtenidos de fuentes publicas y dicho análisis corresponde a la situación en que nos encontrabamos a día 22-04-2019.
Adjuntamos el detalle de los enlaces:
El objetivo de este análisis se centra en responder las siguientes preguntas:
knitr::opts_chunk$set(echo = FALSE) # Definimos el directorio de trabajo # ".." is used because getwd() is executed inside vignettes directory knitr::opts_knit$set(root.dir = file.path(getwd(), "..")) verbose <- FALSE
# Required Packages pkg <- c("R.utils", "iptools", "parallel", "dplyr", "jsonlite", "GroupAssignmentPackage", "mapproj", "kableExtra", "ggplot2", "leaflet", "net.security", "RColorBrewer", "maptools", "maps") # Instalar paquetes que no esten instalados new.pkg <- pkg[!(pkg %in% installed.packages())] if (length(new.pkg)) { install.packages(new.pkg) } # Load packages lapply(pkg, require, character.only = TRUE)
Podemos descargar el fichero directamente de la web, muestras del puerto 80, no obstante este fichero descomprimido ocupa 170,70 GB y tardariamos horas en porderlo procesar integramente. Por ello hemos considerado trabajar con una parte significativa de las muestras.
Para generar nuestro archivo de pruebas realizamos las siguentes comando:
user@linux$ head -n 5000 2019-04-22-1555944117-http_get_80.json > sample80_5kjson
Convertimos el fichero json en un dataframe, utilizando los siguiente comandos:
sample.file <- file.path(getwd(), "data", "sample80_5k.json") lines <- readLines(sample.file) df.raw <- plyr::ldply(lines, function(x) as.data.frame(jsonlite::fromJSON(x), stringsAsFactors = FALSE)) saveRDS(object = df.raw, file = file.path(getwd(), "data", "df_http_get_80_raw_5k.rds"))
Si observamos detenidamente el fichero que con las muestras, podemos apreciar que la columan "data" tiene los datos cifrados en base64 y que algunas columnas tienen el contedido duplicado o no nos aporta ninguna información relevante para el analisis que queremos realizar.
Por ello descartamos la informacion de las columnas ("host", "vhost" y "path").
La información del dataframe que nos interesa es la siguiente:
source.file <- "df_http_get_80_raw_5k.rds" source.file.geo <- "df_http_get_80_raw_geo_5k.rds" df.raw <- readRDS(file.path(getwd(), "data", source.file)) df.raw[which(names(df.raw) == "host")] <- NULL df.raw[which(names(df.raw) == "vhost")] <- NULL df.raw[which(names(df.raw) == "path")] <- NULL str(df.raw)
Procedemos a cargar o descargar el fichero Maxmind, con los siguiente comandos:
# Get maxmind only if pre-computed raw_geo file is not present if (!file.exists(file.path(getwd(), "data", source.file.geo))) { if ((!file.exists(file.path(getwd(), "data", "GeoLite2-City-Blocks-IPv4.csv")))) { df.maxmind <- GroupAssignmentPackage::get.maxmind(verbose) } else { maxmind.source <- file.path(getwd(), "data", "GeoLite2-City-Blocks-IPv4.csv") df.maxmind <- read.csv(maxmind.source, stringsAsFactors = FALSE) } # Expanding MaxMind network ranges df.maxmind <- cbind(df.maxmind, iptools::range_boundaries(df.maxmind$network)) df.maxmind$rowname <- as.integer(row.names(df.maxmind)) }
En el fichero Maxmind obtenemos geolocalización dado un rango de IP y por otro lado en el fichero de df_http_get_80_raw_5k.rds tenemos una única IP, el objetivo de este apartado es conseguir la geolocalización de esa IP comparandolo con el fichero de Maxmind.
Para poder comparar en que rango de IP cae la IP del fichero de muestras y poder obtener su geolocalización, convertimos las IP en una variable numérica e iremos iterando hasta encontrar el rango en que IP en que la muestra sea mayor o igual al valor minimo y a su vez sea mas pequeña o igual al valor máximo.
Al realizar esta iteración se consumen mucho tiempo, por ello hemos paralelizado estas operaciones por cada uno de los procesadores que tenemos disponibles en la maquina.
El fichero resultante de todas estas iteraciones es el siguiente:
if (!file.exists(file.path(getwd(), "data", source.file.geo))){ df.raw <- GroupAssignmentPackage::add.numeric.ip(df.raw, "ip") df.raw.geo <- GroupAssignmentPackage::geolocate.http.responses(df.maxmind, df.raw) } else { df.raw.geo <- readRDS(file.path(getwd(), "data", source.file.geo)) } rm(df.raw) str(df.raw.geo)
Como se puede apreciar hemos juntado la parte del maxmaind que nos aporta datos de la geolocalizacion (latiude, longitude y accuracy_radius) al fichero de muestras que teneiamos incialmente.
Una vez aplicado todo el procesamiento y tratamiento de datos, es hora de mostrar los primeros resultados.
Cuando representamos esta información con un gráfico de dispersión, podemos apreciar que la distribución se asemeja notablemente al mapamundi:
library(ggplot2) # Creamos ggplot con los datos de symantec gg <- ggplot(data = df.raw.geo, aes(x = longitude, y = latitude)) # definimos la grafica por puntos con transparencia gg <- gg + geom_point(size=1, color="#000099", alpha=1/40) # Titulos de los ejes gg <- gg + xlab("Longitud") + ylab("Latitud") # aplicamos el tema simple gg <- gg + theme_bw() # tarda un poco pq son 800.000 puntos print(gg)
Sobre los resultados obtenidos, añadimos información sobre paises y sus fronteras para enriquecer el mapa y obtenemos el siguente resultado:
world <- map_data("world") # Quitamos el continete Antarctico ya que no aporta informaci?n # No es nada personal con los pinguinos... world <- subset(world, world$region!="Antarctica") gg <- ggplot(data=world, aes(x=long, y=lat)) gg <- gg + geom_path(aes(group=group), colour="gray70") # La definici?n de la proyeccion representa la "curvatura" del mapa gg <- gg + coord_map("mercator", xlim=c(-200, 200)) # A?adimos una capa al mapa con la informacion gg <- gg + geom_point(data = df.raw.geo, aes(longitude, latitude), colour="#000099", alpha=1/40, size=1) # Eliminamos texto y le damos un poco de color gg <- gg + theme(text=element_blank(), axis.ticks=element_blank(), panel.grid=element_blank(), panel.background=element_rect(color="gray50", fill="white")) print(gg)
Al ser una gráfica estática y de dispersión, nos permite hacernos idea de la geolocalización de estos servidores. Sin embargo hemos considerado oportuno introducir una nueva gráfica leaflet, que permita interactuar y realizar zoom, para tener mas detalle de los servidores y su ubicación (solo disponible en la versión HTML):
pal <- colorFactor( palette = c('blue'), domain = c('Servidores http') ) m <- leaflet(df.raw.geo) %>% addTiles() %>% addCircles(lng = ~longitude, lat = ~latitude, color = "blue")%>% addLegend("bottomright", pal = pal, values = c('Servidores http'), title = "Geolocalización",opacity = 2) m
Después de ver una primera localización de los servidores, vamos a decodificar los datos y a parsear los headers para obtener información más detallada.
df <- GroupAssignmentPackage::parse.headers(df.raw.geo)
Hacemos un poco de retrospectiva en la historia para poder entender el siguiente resultado.
La versión http/1.0 nacio en (1996). Se incluía como mejoras el soporte a algunos verbos como GET, POST y HEAD.
La versión http/1.1 nacio en (1999/2000). Se incluían verbos GET,POST,PUT,DELETE,etc y la web se empezaba a orientar a recursos (REST), teníamos las cabeceras en las peticiones, etc
En ambas versiones tanto las respuestas como las peticiones se realizan a través de texto plano.
Del muestreo de datos que hemos realizado, mostramos la cantidad de servidores que están utilizando cada una version de comunicación HTTP:
count.version <- plyr::count(df$http.version) names(count.version) <- c("HTTP version", "freq") #kable(count.version) %>% # kable_styling(bootstrap_options = c("striped", "hover", "condensed")) labels<-(as.integer(count.version$freq)/sum(as.integer(count.version$freq)))*100 labels<-paste(labels, "%", sep="") pie(as.integer(count.version$freq), labels, col = colorRampPalette(brewer.pal(3,"Dark2"))(length(count.version$freq)), main = "HTTP versión") legend("topleft", as.vector(count.version$`HTTP version`), cex=0.9, bty="n", fill=colorRampPalette(brewer.pal(3,"Dark2"))(length(count.version$freq)))
Obtenemos su geolocalización:
pal <- colorFactor( palette = c('blue', 'red'), domain = c('HTTP/1.0', 'HTTP/1.1') ) m <- leaflet(df) %>% addTiles() %>% addCircles(lng = ~longitude, lat = ~latitude, color = ~ifelse(http.version == 'HTTP/1.0', "blue", "red")) %>% addLegend("bottomright", pal = pal, values = c('HTTP/1.0', 'HTTP/1.1'), title = "HTTP version", opacity = 1) m
También podemos ver los tipos de respuesta:
http.status.number <- sapply(df$http.status, function(x) unlist(strsplit(x, " ", useBytes = TRUE))[2]) http.status.number <- plyr::count(http.status.number) ordered.status <- http.status.number[with(http.status.number, order(-freq)), ] names(ordered.status) <- c("HTTP status code", "freq") row.names(ordered.status) <- NULL label<-paste((ordered.status$freq/sum(ordered.status$freq))*100,"%","") pie(as.integer(ordered.status$freq), label[which(ordered.status$freq > 50)], col = colorRampPalette(brewer.pal(8,"Dark2"))(length(ordered.status$freq)), main = "HTTP status code") legend("topleft", as.vector(ordered.status$`HTTP status code`)[which(ordered.status$freq > 50)], cex=0.9, bty="n", fill=colorRampPalette(brewer.pal(8,"Dark2"))(length(ordered.status$`HTTP status code`)))
De todos los resultados que hemos obtenido, los agrupamos por servidor y mostramos los 20 primeros que contengan más resultados:
servers <- plyr::count(unlist(df$server)) servers <- servers[-which(servers$x == "" | servers$x == " " | is.na(servers$x)), ] # Delete empty values ordered.servers <- servers[with(servers, order(-freq)), ] names(ordered.servers) <- c("Server", "freq") row.names(ordered.servers) <- NULL kable(head(ordered.servers, 20)) %>% kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
Filtramos por el tipo de servidor independientemente de la versión y observamos que la mayoria se concentran en los siguientes fabricantes:
vendors <- plyr::count(unlist(df$vendor)) vendors <- vendors[-which(vendors$x == "" | vendors$x == " " | is.na(vendors$x)), ] # Delete empty values ordered.vendors <- vendors[with(vendors, order(-freq)), ] names(ordered.vendors) <- c("Vendor", "freq") row.names(ordered.vendors) <- NULL kable(head(ordered.vendors, 11)) %>% kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
Un dato interesante encontrado, es que filtrando los vendors segun la versión de HTTP usada, nos mustra que el servidor AkamaiGHost usa el protocolo HTTP/1.0 en el 100% de las muestras analizadas. 932 respuestas.
Investigando encontramos que se trata de un servidor de geolocalización caché, para proporcionar respuestas más rápidas en función de la ubicación. La mayoría de los servidores obtenidos utilizando la versión HTTP/1.0 tienen una función similar, utilizado por robots o por servidores de caché. La proporción de servidores web corrientes que utilizan HTTP/1.0 es casi despreciable.
vendors.http1.0 <- df$vendor[which(df$http.version == "HTTP/1.0")] vendors.http1.0 <- plyr::count(vendors.http1.0) vendors.http1.0 <- vendors.http1.0[with(vendors.http1.0, order(-freq)), ] names(vendors.http1.0) <- c("vendor using HTTP/1.0", "freq") row.names(vendors.http1.0) <- NULL labels <- paste(sprintf("%.2f",(vendors.http1.0$freq/sum(vendors.http1.0$freq))*100),"%","") labels <- vendors.http1.0$freq pie(as.integer(vendors.http1.0$freq), labels[which(vendors.http1.0$freq > 15)], col = colorRampPalette(brewer.pal(8,"Dark2"))(length(vendors.http1.0$freq)), main = "Vendors with HTTP/1.0 version") legend("bottomleft", as.vector(vendors.http1.0$`vendor using HTTP/1.0`)[which(vendors.http1.0$freq > 5)], cex=0.9, bty="n", fill=colorRampPalette(brewer.pal(8,"Dark2"))(length(vendors.http1.0$`vendor using HTTP/1.0`)))
Mostramos la geolocalización por tipología de servidor de los 4 tipos principales:
pal <- colorFactor( palette = c('orange', 'red', 'blue', 'green'), domain = c('Apache', 'Microsoft-IIS', 'nginx', 'AkamaiGHost') ) m <- leaflet() %>% addTiles() %>% addCircles(lat=subset(df, vendor=='Apache')$lat, lng=subset(df,vendor=='Apache')$lon, color= "red") %>% addCircles(lat=subset(df, vendor=='Microsoft-IIS')$lat, lng=subset(df,vendor=='Microsoft-IIS')$lon, color= "blue") %>% addCircles(lat=subset(df, vendor=='nginx')$lat, lng=subset(df,vendor=='nginx')$lon, color= "green") %>% addCircles(lat=subset(df, vendor=='AkamaiGHost')$lat, lng=subset(df,vendor=='AkamaiGHost')$lon, color= "orange") %>% addLegend("bottomright", pal = pal, values = c('Apache', 'Microsoft-IIS', 'nginx', 'AkamaiGHost'), title = "Main server vendors", opacity = 1) m
Buscamos las realiación que hay entre el servidor y versión con un CPE.
A partir de la información del servidor, parseamos los datos del vendor y la versión y obtenemos el CPE correspondiente:
# All cpe22 are actually cpe23, so no need to load them if (!file.exists(file.path(getwd(), "data", "cpes.rds"))) { library(net.security) #df$cpe22 <- mapply(x = df$vendor, y = df$version, FUN = function(x, y) GroupAssignmentPackage::get.cpe(x, y, 22)) df$cpe23 <- mapply(x = df$vendor, y = df$version, FUN = function(x, y) GroupAssignmentPackage::get.cpe(x, y, 23)) } else { cpes <- readRDS(file.path(getwd(), "data", "cpes.rds")) #df$cpe22 <- mapply(x = df$vendor, y = df$version, FUN = function(x, y) GroupAssignmentPackage::get.cpe(x, y, 22, cpes)) df$cpe23 <- mapply(x = df$vendor, y = df$version, FUN = function(x, y) GroupAssignmentPackage::get.cpe(x, y, 23, cpes)) }
Mostramos un listado del top 10 CPE para los cuales hemos encontrado coincidencia:
cpes.match <- plyr::count(unlist(df$cpe23)) cpes.match[] <- lapply(cpes.match, function(x) if (is.factor(x)) as.character(x) else {x}) cpes.match <- cpes.match[-which(is.na(cpes.match$x)), ] # Delete empty values cpes.match <- cpes.match[with(cpes.match, order(-freq)), ] names(cpes.match) <- c("cpe23", "freq") main.cpes <- head(cpes.match, 50) row.names(cpes.match) <- NULL kable(head(cpes.match, 10)) %>% kable_styling(bootstrap_options = c("striped", "hover", "condensed"))
A partir de los CPEs encontrados, podemos hacer una relación con CVEs, para obtener las vulnerabilidades y su criticidad.
Debido al coste computacional de la búsqueda de strings, hemos guardado un RDS con la relación de los 121 CPE encontrados. Si no disponemos del fichero "cpes.match.cves.rds" limitamos nuestra muestra a los 50 CPE principales de nuestro conjunto de datos.
# Get cves list if (!file.exists(file.path(getwd(), "data", "cves.rds"))) { library(net.security) cves <- net.security::GetDataFrame("cves") } else { cves <- readRDS(file.path(getwd(), "data", "cves.rds")) } # Match only main cpes. if (!file.exists(file.path(getwd(), "data", "cpes.match.cves.rds"))) { main.cpes$CVE <- lapply(main.cpes$cpe23, function(x) if (!is.na(x)) GroupAssignmentPackage::get.cve(x, cves) else NA) } else { main.cpes <- readRDS(file.path(getwd(), "data", "cpes.match.cves.rds")) } # Get score for cvss2 and cvss3 main.cpes$num.cve <- lapply(main.cpes$CVE, function(x) nrow(x)) main.cpes$score.cvss2 <- lapply(main.cpes$CVE, function(x) GroupAssignmentPackage::get.score(x, 2)) main.cpes$score.cvss3 <- lapply(main.cpes$CVE, function(x) GroupAssignmentPackage::get.score(x, 3)) main.cpes$max.score.cvss2 <- lapply(main.cpes$CVE, function(x) GroupAssignmentPackage::get.score.max(x, 2)) main.cpes$max.score.cvss3 <- lapply(main.cpes$CVE, function(x) GroupAssignmentPackage::get.score.max(x, 3)) #df$cpe23 <- as.character(df$cpe23) #main.cpes$cpe23 <- as.character(main.cpes$cpe23) df.cves <- dplyr::inner_join(df, main.cpes, by = "cpe23") df.cves$CVE[df.cves$CVE=='NULL'] <- NA df.cves$num.cve[df.cves$num.cve=='NULL'] <- NA df.cves$num.cve <- as.integer(df.cves$num.cve) df.cves$score.cvss2[df.cves$score.cvss2=='NULL'] <- NA df.cves$score.cvss3[df.cves$score.cvss3=='NULL'] <- NA df.cves$max.score.cvss2[df.cves$max.score.cvss2=='NULL'] <- NA df.cves$max.score.cvss3[df.cves$max.score.cvss3=='NULL'] <- NA
Mostramos los servidores, en su geolocalización mostrando el número de CVEs que tiene cada servidor.
pal <- colorNumeric( palette = c("white", "forestgreen", "gold", "red"), domain = plyr::count(df.cves$num.cve)$x ) labels <- sprintf( "<strong>%s</strong>", df.cves$num.cve ) %>% lapply(htmltools::HTML) m <- leaflet() %>% addTiles() %>% addCircles(lat=subset(df.cves,num.cve>0)$lat, lng=subset(df.cves,num.cve>0)$lon, color=pal(df.cves$num.cve), label= labels) %>% addLegend("bottomright", pal = pal, values = df.cves$num.cve, title = "Número de CVE", opacity = 1) m
El número de CVE que tiene un servidor no quiere decir que sea crítico, ahora mostramos los servidores con su mayor puntuaje de cvss2:
pal <- colorNumeric( palette = c("white", "forestgreen", "gold", "red"), domain = plyr::count(unlist(df.cves$max.score.cvss2))$x ) labels <- sprintf( "<strong>%s</strong>", unlist(df.cves$max.score.cvss2) ) %>% lapply(htmltools::HTML) m <- leaflet() %>% addTiles() %>% addCircles(lat=subset(df.cves,num.cve>0)$lat, lng=subset(df.cves,num.cve>0)$lon, color=pal(unlist(df.cves$max.score.cvss2)), label= labels) %>% addLegend("bottomright", pal = pal, values = unlist(df.cves$max.score.cvss2), title = "Criticidad de CVE", opacity = 1) m
Miramos los resultado a nivel de pais, ya que con el anterior gráfico es difícil de llegar a comprender que paises tienen mas vulnerabilidades que otros.
Para ello usaremos un mapa de tipo choropleth, que básicamente significa pintar los paises con colores.
# Esta funcion devuelve un vector con los nombres de los paises, de esta forma # se puede contar cuantas veces aparece un pais con la funcion table() # Lo que hay que hacer es transformar la información de lat/long a nombre de pais zworld <- latlong2map(data.frame(x=subset(df.cves,num.cve>0)$lon, y=subset(df.cves,num.cve>0)$lat), "world") # cuenta los puntos en un pais y lo convierte en data frame wct <- data.frame(table(zworld)) # definimos los nombres de la variables colnames(wct) <- c("region", "count") # la funcion merge se realiza por "region" al hacer match za.choro <- merge(world, wct) # ordenamos el mapa za.choro <- za.choro[with(za.choro, order(group, order)), ] # y lo "pintamos" gg <- ggplot(za.choro, aes(x=long, y=lat, group=group, fill=count)) gg <- gg + geom_path(colour="#666666") + geom_polygon() gg <- gg + coord_map("mercator", xlim=c(-200, 200), ylim=c(-60,200)) gg <- gg + scale_fill_gradient2(low="#FFFFFF", high="#4086AA", midpoint=median(za.choro$count), name="Número de CPE con almenos un CVE") # Eliminamos texto y le damos un poco de color gg <- gg + theme(axis.title=element_blank(), axis.text=element_blank(), axis.ticks=element_blank(), panel.grid=element_blank(), panel.background=element_rect(color="gray50", fill="white")) print(gg)
En el gráfico anterior estamos mostrando el numero de servidores con vulnerabilidades que tiene cada pais, pero estamos ponderando con el mismo valor cada vulnerabilidad.
Si mostramos el top 10 podemos apreciar que Estados Unidos encabeza el listado:
# for each wct$count, divide by sum, gives us proportion of the whole perc <- wct$count/sum(wct$count) # covert to a readable format, round it and create percent wct$perc <- round(perc, 4)*100 # now order the highest percentages on top wct <- wct[with(wct, order(perc, decreasing=T)), ] # look at the top few entries. head(wct, 10)
Nos faltaría extraer de cada servidor, que criticidad tiene cada vulnerabilidad, para poder mostrar con mas fidelidad los resultados.
#Crear un dataframe que tenga los siguientes datos: region, lon/lat, servidor, criticidad # Utlizar región y criticidad, para pintar el mapa
Normalizamos los resultado por el número de servidores que hay en cada región y sacamos el promedio de criticidad.
#Crear un mapa con: región, lon/lat, criticidad_promedio # Utlizar región y criticidad_promedio para pintar el mapa obteniendo valores normalizados
Lo correlaciamos con los datos del PIB:
#Crear un mapa con: region, criticidad_promedio, PIB #Utilizar los tres valores para mostrar los resultados
Revisando los resultados que hemos obtenidos de la muestra de datos, podemos llegar a decir:
Add the following code to your website.
For more information on customizing the embed code, read Embedding Snippets.