"freewifi" via ESP8266 Captive Portal

Many outdoor places do not have permanent Wi-Fi access points. Occasionally I can get a weak unencrypted WiFi signal from a nearby shop; otherwise, I'll have to face the fact of not having WiFi, and resort to my slow and expensive SIM card for cellular Internet access.

Since I learned that the ESP8266 can serve as a WiFi hotspot, I got an idea. I can make an ESP8266 appear as a Wi-Fi access point (AP), and provide free WiFi to everyone at the outdoor venue. Except that, this is a freewifi prank: I am providing free WiFi, but my WiFi does not offer Internet access.

Screenshots

ESP8266 makes a freewifi WiFi access point SSID:

freewifi SSID

When a user connects to the freewifi and attempts to access any webpage, they are redirected to a captive portal:

captive portal login page

After submitting an email address, you are pranked!

captive portal connected page

I know who were on my WiFi:

log file

Arduino sketch for "freewifi" on ESP8266

The following code can be flashed on an ESP8266 microcontroller, such as the ESP8266 Witty Cloud board. Four files are required:

  • CaptivePortal.ino is the main Arduino sketch code.
  • data/index.htm.gz is shown when a user attempts to connect to WiFi.
  • data/connect.htm.gz tells the victim that our WiFi does not have Internet.
  • data/pure.css.gz is a CSS framework used in webpages.

You may download from GitHub Gist and run bash prepare-data.sh to gzip source files.

You also need:

UPDATE 2021-09-10: Sketch was updated so that it works with Streaming version 6.

CaptivePortal.ino

This sketch establishes a WiFi access point named freewifi on the ESP8266. A DNS server is running on the ESP8266, which responds to every DNS request with the ESP8266's own IP address 192.168.0.1. Effectively, this DNS response makes the ESP8266 AP as a captive portal.

When a user connects to the freewifi and attempts to access any webpage, they are redirected to http://freewifi.lan/ and then presented with a login page data/index.htm.gz. If the user chooses to log on, they will be presented with the data/connect.htm.gz webpage.

The code also creates a log file named log.txt in the SPIFFS storage, which records every client that associates with the free WiFi AP, as well as hits to the HTTP server. The log file can be downloaded at http://freewifi.lan/log.txt.

#include <FS.h>
#include <ESP8266WiFi.h>
#include <DNSServer.h>
#include <ESP8266WebServer.h>
#include <PString.h>
#include <Streaming.h>

const IPAddress apIp(192, 168, 0, 1);
DNSServer dnsServer;
ESP8266WebServer server(80);

struct PrintMac
{
  const uint8_t* mac;
};

Print&
operator<<(Print& p, const PrintMac& pmac)
{
  const uint8_t* mac = pmac.mac;
  for (int i = 0; i < 6; ++i) {
    if (i > 0) {
      p << ':';
    }
    p << _WIDTHZ(_HEX(mac[i]), 2);
  }
  return p;
}

File logfile;
char logbuf[256];
PString logline(logbuf, sizeof(logbuf));

void
appendLog()
{
  const char* line = logline;
  Serial << millis() << ' ' << line << '\n';
  logfile << millis() << ' ' << line << '\n';
  logline.begin();
}

void
wifiStaConnect(const WiFiEventSoftAPModeStationConnected& evt)
{
  logline << PrintMac{evt.mac} << " assoc";
  appendLog();
}
WiFiEventHandler wifiStaConnectHandler;

void
wifiStaDisconnect(const WiFiEventSoftAPModeStationDisconnected& evt)
{
  logline << PrintMac{evt.mac} << " deassoc";
  appendLog();
}
WiFiEventHandler wifiStaDisconnectHandler;

void
httpDefault()
{
  logline << server.client().remoteIP() << " redirect";
  appendLog();
  server.sendHeader("Location", "http://freewifi.lan/", true);
  server.send(302, "text/plain", "");
  server.client().stop();
}

void
httpHome()
{
  if (server.hostHeader() != String("freewifi.lan")) {
    return httpDefault();
  }

  logline << server.client().remoteIP() << " home";
  appendLog();
  File file = SPIFFS.open("/index.htm.gz", "r");
  server.streamFile(file, "text/html");
  file.close();
}

void
httpConnect()
{
  logline << server.client().remoteIP() << " connect " << server.arg("email");
  appendLog();
  File file = SPIFFS.open("/connect.htm.gz", "r");
  server.streamFile(file, "text/html");
  file.close();
}

void
httpPureCss()
{
  File file = SPIFFS.open("/pure.css.gz", "r");
  server.streamFile(file, "text/css");
  file.close();
}

void
httpLog()
{
  logline << server.client().remoteIP() << " log";
  appendLog();
  logfile.seek(0, SeekSet);
  server.streamFile(logfile, "text/plain");
  logfile.seek(0, SeekEnd);
}

void
setup()
{
  Serial.begin(115200);
  Serial.println();
  Serial.println();

  SPIFFS.begin();
  logfile = SPIFFS.open("/log.txt", "a+");

  WiFi.persistent(false);
  WiFi.disconnect(true);
  wifiStaConnectHandler = WiFi.onSoftAPModeStationConnected(wifiStaConnect);
  wifiStaDisconnectHandler = WiFi.onSoftAPModeStationDisconnected(wifiStaDisconnect);
  WiFi.mode(WIFI_AP);
  WiFi.softAPConfig(apIp, apIp, IPAddress(255, 255, 255, 0));
  WiFi.softAP("freewifi", nullptr, 1);

  dnsServer.setErrorReplyCode(DNSReplyCode::NoError);
  dnsServer.start(53, "*", apIp);

  server.on("/", httpHome);
  server.on("/connect", httpConnect);
  server.on("/pure.css", httpPureCss);
  server.on("/log.txt", httpLog);
  server.onNotFound(httpDefault);
  server.begin();

  Serial << "ready" << endl;
}

void
loop()
{
  dnsServer.processNextRequest();
  server.handleClient();
}

data/index.htm.gz

This is the login page. The code should be saved as index.htm and then gzipped. ESP8266WebServer is capable of serving gzipped files to the client. Using gzip significantly saves space in the SPIFFS storage.

<!DOCTYPE html>
<title>Free WiFi</title>
<meta name="viewport" content="initial-scale=1.0">
<link rel="stylesheet" href="pure.css">

<h1>Welcome to Free WiFi</h1>

<form action="/connect" class="pure-form pure-form-aligned">
<fieldset>
<div class="pure-control-group">
  <label for="email">Email</label>
  <input name="email" id="email" type="email" required placeholder="someone@example.com">
</div>
<div class="pure-controls">
  <label class="pure-checkbox">
    <input type="checkbox" required>
    I've read the terms and conditions
  </label>
  <input type="submit" class="pure-button pure-button-primary" value="Connect">
</div>
</fieldset>
</form>

"terms and conditions" does not have a hyperlink. But nobody would read them, so why bother?

data/connect.htm.gz

This web page reveals the free WiFi is actually a prank. The code should be saved as connect.htm and then gzipped.

<!DOCTYPE html>
<title>Free WiFi</title>
<meta name="viewport" content="initial-scale=1.0">
<link rel="stylesheet" href="pure.css">

<h1>Connected to Free WiFi</h1>

<p>No, you don't get Internet access.
We offer free WiFi, but we didn't offer Internet access through WiFi.</p>

data/pure.css.gz

This is Pure CSS v0.6.0, gzipped.

How to flash this sketch?

The program code can be flashed to the ESP8266 through the Upload button normally. If you are using a Witty Cloud board, you can flash it with just six wires.

Contents of the SPIFFS storage should be placed in data/ sub-directory, and they need to be uploaded separately via Tools - ESP8266 Sketch Data Upload:

Arduino ESP8266 Sketch Data Upload menu option

Arduino IDE can only upload SPIFFS to ESP8266. There is no built-in command to download contents of the SPIFFS storage from the ESP8266. That is why I made a http://freewifi.lan/log.txt URI to download the log file over WiFi.

Field Test

2nd Saturdays Downtown Tucson, 2017-06-10

I brought the "freewifi" access point in my backpack, powered from a USB power bank, to 2nd Saturdays Downtown Tucson. I stayed there for a little over one hour, and then downloaded the log file after I have left the festival.

2016-07-09 field test results

Three unique devices associated with "freewifi" AP. Among them are two LG devices and one Apple device, according to the Wireshark manufacturer database. None of them accessed any webpage.

1013526 00:34:DA:xx:xx:E6 assoc
1055404 A0:91:69:xx:xx:32 assoc
1086522 A0:91:69:xx:xx:32 deassoc
1105990 A0:91:69:xx:xx:32 assoc
1315523 00:34:DA:xx:xx:E6 deassoc
1457988 A0:91:69:xx:xx:32 deassoc
2233390 E0:B9:BA:xx:xx:A7 assoc
2559386 E0:B9:BA:xx:xx:A7 deassoc
(10 records of my own connection and log downloading omitted)

2017-06-10 field test results

One Amazon and one Huawei device associated with my "freewifi". The Amazon user tried 7 times over a span of 44 minutes, and submitted 2 different email addresses 6 times. You have been pranked!

1696817 F4:CB:52:xx:xx:97 assoc
1697039 F4:CB:52:xx:xx:97 deassoc
2113706 F0:27:2D:xx:xx:5B assoc
2116548 192.168.0.2 redirect
(12 redirect/home records omitted)
2141937 192.168.0.2 home
2142675 192.168.0.2 redirect
2142930 192.168.0.2 redirect
2157340 192.168.0.2 connect sa...@hotmail.com
2157543 192.168.0.2 redirect
2157641 192.168.0.2 home
(15 redirect/home records omitted)
2195280 192.168.0.2 home
2209486 192.168.0.2 connect sa...@hotmail.com
2211928 192.168.0.2 redirect
2212283 192.168.0.2 home
2251723 F0:27:2D:xx:xx:5B deassoc
2254323 F0:27:2D:xx:xx:5B assoc
2580174 F0:27:2D:xx:xx:5B deassoc
2868511 F0:27:2D:xx:xx:5B assoc
2873737 192.168.0.2 redirect
2880483 192.168.0.2 redirect
2880567 192.168.0.2 home
(8 redirect/home records omitted)
2899386 192.168.0.2 home
2904325 0.0.0.0 connect sa...@hotmail.com
2904358 192.168.0.2 connect sa...@hotmail.com
2904372 192.168.0.2 redirect
2904384 0.0.0.0 redirect
2904386 192.168.0.2 redirect
2904475 192.168.0.2 redirect
2904502 192.168.0.2 home
2904943 192.168.0.2 redirect
2933490 192.168.0.2 redirect
2933586 192.168.0.2 home
2934456 192.168.0.2 redirect
2934479 192.168.0.2 redirect
2987757 192.168.0.2 connect 14...@gmail.com
2988460 192.168.0.2 redirect
2988503 192.168.0.2 redirect
2988527 192.168.0.2 home
2989658 192.168.0.2 redirect
3003795 F0:27:2D:xx:xx:5B deassoc
3156399 F0:27:2D:xx:xx:5B assoc
3160296 192.168.0.2 redirect
3187307 192.168.0.2 redirect
3187332 192.168.0.2 home
(9 redirect/home records omitted)
3190046 192.168.0.2 home
3215609 F0:27:2D:xx:xx:5B deassoc
3215754 F0:27:2D:xx:xx:5B assoc
3234420 192.168.0.2 redirect
3244858 F0:27:2D:xx:xx:5B deassoc
3248007 F0:27:2D:xx:xx:5B assoc
3249139 192.168.0.2 redirect
3254925 192.168.0.2 redirect
3254977 192.168.0.2 home
(38 redirect/home records omitted)
3325030 192.168.0.2 home
3326815 192.168.0.2 redirect
3326853 192.168.0.2 home
3491948 F0:27:2D:xx:xx:5B deassoc
4719211 F0:27:2D:xx:xx:5B assoc
4722230 192.168.0.2 redirect
4729892 192.168.0.2 redirect
4729918 192.168.0.2 home
(26 redirect/home records omitted)
4759175 192.168.0.2 home
4760062 192.168.0.2 redirect
4760067 192.168.0.2 redirect
4760171 192.168.0.2 redirect
4784634 192.168.0.2 connect sa...@gmail.com
4784757 192.168.0.2 redirect
4784791 192.168.0.2 home
(11 redirect/home records omitted)
4805342 192.168.0.2 home
4805700 192.168.0.2 redirect
4805711 253871 00:09:22:xx:xx:41 assoc
(33 records of my own connection and log downloading omitted)

The last line shown 4805711 253871 00:09:22:xx:xx:41 assoc is strange: it seems that the Witty Cloud board was reset while writing the log file, and then it was restarted to accept my own connection. Maybe the power cord was loose?

Conclusion

I played a prank by establishing a "freewifi" on downtown Tucson streets with an ESP8266 serving as WiFi access point. The ESP8266 AP has a captive portal, but does not actually offer Internet connectivity. Arduino sketch running on the ESP8266 reads web pages from the SPIFFS storage, and writes a log file to the SPIFFS storage detailing every action taken by WiFi clients. I got one victim desperately trying to connect to my free WiFi during one hour of field test.

If you reproduce this prank:

  • Do not collect credit card numbers or other sensitive information using the login page: they will be transmitted in plaintext and visible to any eavesdropper.
  • Use this AP only in public places; do not enter a private property.
  • Refrain from naming the SSID as a nearby shop or the public event, as that would infringe their trademarks.