Análisis de respuestas HTTP y de sus servidores

Resumen

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:

Objetivos

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)

Análisis

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)

Geolocalización

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

Decodificación

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.

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

Relación con CPE

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"))

Relación con CVE

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

Conclusiones

Revisando los resultados que hemos obtenidos de la muestra de datos, podemos llegar a decir:



DDS-MCSM/spring19-group-assignment-marism documentation built on June 4, 2019, 11:39 p.m.